diff --git a/.announcements/automations.json b/.announcements/automations.json new file mode 100644 index 000000000..8ea1801c2 --- /dev/null +++ b/.announcements/automations.json @@ -0,0 +1,7 @@ +{ + "items": [ + { + "text": "New: Automations — schedule a prompt to run in a chat on an interval (hourly, daily, weekly, or custom). Find it via the clock icon at the bottom of the sidebar, or ask an agent to set one up for you." + } + ] +} diff --git a/.changeset/calm-clocks-tick.md b/.changeset/calm-clocks-tick.md new file mode 100644 index 000000000..1070a23da --- /dev/null +++ b/.changeset/calm-clocks-tick.md @@ -0,0 +1,9 @@ +--- +"helmor": minor +--- + +Add Automations — scheduled prompts that run on an interval, like Codex's: +- New Automations page (clock icon at the bottom of the sidebar): create automations manually or via chat, pause/resume, edit the interval, or run one immediately. +- Each run sends the saved prompt into its chat as a normal agent turn, labeled "Sent via automation" — the chat itself is the run history. Runs can target an existing chat or create a fresh session per run in a workspace. +- Intervals: hourly, daily, weekly, or every N minutes/hours. Schedules survive restarts and sleep — a slot missed while Helmor was closed catches up exactly once on next launch, and never double-fires. +- New `helmor automation` CLI (list/create/show/pause/resume/run/delete), so agents can set up automations for you straight from a conversation. diff --git a/AGENTS.md b/AGENTS.md index 9ce1301ea..d394b0f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,14 +70,15 @@ Feature-based layout. Each feature folder follows: `index.tsx` (main) + `contain | Module | Role | | --- | --- | | `lib.rs` | Tauri app builder. Registers commands, runs setup hook. | -| `commands/` | Tauri command handlers split by domain (session, repository, workspace, editor, github, conductor, settings, system). `terminal_commands.rs` includes `set_terminal_session_busy` and `create_session` (now accepts `session_kind` and `agent_type` parameters). | -| `agents/` | Agent streaming + persistence (catalog, persistence, queries, streaming, support). | +| `commands/` | Tauri command handlers split by domain (session, repository, workspace, editor, github, conductor, settings, system). `terminal_commands.rs` includes `set_terminal_session_busy` and `create_session` (now accepts `session_kind` and `agent_type` parameters). `automation_commands.rs` provides IPC for automation management (list, create, update, pause, resume, delete, run). | +| `agents/` | Agent streaming + persistence (catalog, persistence, queries, streaming, support). Integrates with scheduled automations: `AgentSendRequest.source` and `ExchangeContext.is_background` track automation-initiated turns so they skip mark-read side effects. | +| `automations/` | Scheduled automations: `mod.rs` (main exports), `dispatch.rs` (dispatch logic), `ops.rs` (CRUD operations), `schedule.rs` (schedule calculation/parsing), `scheduler.rs` (background scheduler, 30-second tick). | | `cli/` | CLI entry point + subcommands. `terminal_hook.rs` provides the hidden `terminal-hook` command used by agent CLIs to communicate PTY lifecycle events (session id, busy/idle, prompt capture). | | `pipeline/` | Message pipeline: `accumulator/` -> `adapter/` + `collapse` -> `ThreadMessageLike[]`. Includes `event_filter.rs`, `classify.rs`, `types.rs`. | | `workspace/` | Workspace operations (branching, lifecycle, helpers) + `files/` sub-module (editor, changes, types). `scripts.rs` includes PTY output coalescing (8ms flush window, 16KB threshold, UTF-8 safe flush). | | `git/` | Git operations (ops, watcher). | | `github/` | GitHub integration (auth, CLI, GraphQL). | -| `models/` | Persistence layer (db, repos, sessions, settings, workspaces). Sessions table now has `session_kind` (distinguishes terminal vs GUI sessions) and `agent_type` (tracks which agent is being used). | +| `models/` | Persistence layer (db, repos, sessions, settings, workspaces, automations). Sessions table now has `session_kind` (distinguishes terminal vs GUI sessions) and `agent_type` (tracks which agent is being used). `automations` contains the Automation struct and persistence. | | `service.rs` | Service layer. | | `sidecar.rs` | Sidecar process manager (spawn, stdio, graceful SIGTERM). | | `schema.rs` | DB schema + idempotent migrations. | diff --git a/docs/cli-and-mcp.md b/docs/cli-and-mcp.md index a7dc20947..26719b8c7 100644 --- a/docs/cli-and-mcp.md +++ b/docs/cli-and-mcp.md @@ -43,6 +43,9 @@ helmor workspace stack helmor/earth # show PR stack for a workspace helmor session list --workspace helmor/earth helmor session new --workspace helmor/earth helmor send --workspace helmor/earth "Refactor the auth module" +helmor automation list +helmor automation create --title "Daily digest" --prompt "summarize inbox" \ + --workspace helmor/earth --daily 09:00 ``` Debug builds use the same commands under `helmor-dev`. @@ -98,6 +101,104 @@ The agent provides three commands for stacked PR workflows: ## CLI Commands +### Automation Commands + +Helmor automations allow you to schedule recurring prompts that run on an interval. Each automation can run in a chat (appending turns to an existing session) or in a workspace (creating a fresh session per run). + +#### helmor automation list + +List all automations. + +Example: +```bash +helmor automation list +``` + +#### helmor automation show + +Show details of an automation including title, status, schedule, prompt, and next/last run times. + +Example: +```bash +helmor automation show abc123 +``` + +#### helmor automation create + +Create a new automation with a title, prompt, target (chat or workspace), and schedule. + +Syntax: +```bash +helmor automation create --title --prompt <prompt> \ + [--chat <session-id> | --workspace <workspace>] \ + [--hourly | --daily HH:MM | --weekly DAY:HH:MM | --every Nm|Nh] +``` + +Options: +- **`--title`** — Descriptive name for the automation +- **`--prompt`** — The message to send when the automation runs +- **`--chat <session-id>`** — Run in an existing chat session (each run appends a turn) +- **`--workspace <workspace>`** — Run in a workspace (each run creates a fresh session) +- **`--hourly`** — Run every hour +- **`--daily HH:MM`** — Run every day at a local wall-clock time (e.g. 09:00) +- **`--weekly DAY:HH:MM`** — Run weekly on a specific day and time (e.g. mon:09:30) +- **`--every Nm|Nh`** — Run every N minutes or hours (e.g. 15m, 2h) + +Examples: +```bash +# Run hourly in a chat +helmor automation create --title "Order monitor" --prompt "check order status" \ + --chat <session-id> --hourly + +# Run daily in a workspace +helmor automation create --title "Daily digest" --prompt "summarize inbox" \ + --workspace helmor/earth --daily 09:00 + +# Run weekly +helmor automation create --title "Weekly report" --prompt "generate status report" \ + --workspace helmor/earth --weekly mon:09:30 + +# Run every 15 minutes +helmor automation create --title "Frequent check" --prompt "check status" \ + --workspace helmor/earth --every 15m +``` + +#### helmor automation pause <id> + +Pause an automation. The automation remains listed but stops firing until resumed. + +Example: +```bash +helmor automation pause abc123 +``` + +#### helmor automation resume <id> + +Resume a paused automation. The next run time is computed from now. + +Example: +```bash +helmor automation resume abc123 +``` + +#### helmor automation run <id> + +Manually trigger an automation to run on the app's next scheduler tick (within ~30 seconds). The automation must be active (not paused). + +Example: +```bash +helmor automation run abc123 +``` + +#### helmor automation delete <id> + +Delete an automation. The chats or sessions it created remain untouched. + +Example: +```bash +helmor automation delete abc123 +``` + ### Workspace Commands #### helmor workspace stack <ref> diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index d656f3d9a..43ba5c997 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -34,7 +34,7 @@ pub use self::streaming::{ abort_all_active_streams_blocking, bridge_aborted_event, bridge_done_event, bridge_error_event, bridge_permission_request_event, bridge_user_input_request_event, build_send_message_params, lookup_workspace_linked_directories, ActiveStreamSummary, ActiveStreams, - BuildSendMessageParamsInput, SessionStreamHub, + BuildSendMessageParamsInput, SessionStreamHub, SESSION_BUSY_MARKER, }; use self::persistence::{ @@ -191,6 +191,13 @@ pub struct AgentSendRequest { /// round-trip without regex re-extraction. #[serde(default)] pub images: Option<Vec<String>>, + /// Who initiated this turn. `None` = the user (frontend/CLI never set + /// it); `Some("automation")` = the automations scheduler. Persisted into + /// the `user_prompt` payload so the chat renders a "Sent via automation" + /// badge, and background turns skip the mark-read / active-session + /// side effects a human send performs. + #[serde(default)] + pub source: Option<String>, /// UTF-16 ranges of pasted-text tag spans inside `prompt` (composer /// badge pastes). Persisted with the user_prompt so the renderer shows /// those spans as tag chips; the agent still receives the full prompt @@ -208,6 +215,11 @@ pub(crate) struct ExchangeContext { pub(crate) model_id: String, pub(crate) model_provider: String, pub(crate) user_message_id: String, + /// True for scheduler-initiated turns (`request.source` set). Finalize + /// then skips marking the session read and stealing the workspace's + /// active session — a background run must surface as unread, not + /// rearrange the user's UI. + pub(crate) is_background: bool, } #[tauri::command] @@ -299,6 +311,46 @@ fn resolve_stream_working_directory( resolve_working_directory(request.working_directory.as_deref()) } +/// Start an agent turn with no owning IPC channel — used by the automations +/// scheduler. Identical to `send_agent_message_stream` minus the triage +/// priming: persistence, ActiveStreams busy-locking, and watcher fan-out +/// (`SessionStreamHub`) all run as for a frontend-initiated send, so an open +/// conversation still streams the turn live. The no-op channel only drops the +/// initiator-facing copy nobody is listening to. +pub(crate) fn start_background_turn(app: &AppHandle, request: AgentSendRequest) -> CmdResult<()> { + let prompt = request.prompt.trim().to_string(); + if prompt.is_empty() { + return Err(anyhow::anyhow!("Prompt cannot be empty.").into()); + } + + let model = resolve_model(&request.model_id, Some(request.provider.as_str())); + if request.provider != model.provider { + return Err(anyhow::anyhow!( + "Model {} does not belong to provider {}.", + request.model_id, + request.provider + ) + .into()); + } + + let working_directory = resolve_stream_working_directory(&request)?; + let stream_id = Uuid::new_v4().to_string(); + let sidecar = app.state::<crate::sidecar::ManagedSidecar>(); + let active_streams = app.state::<ActiveStreams>(); + + stream_via_sidecar( + app.clone(), + Channel::new(|_| Ok(())), + &sidecar, + &active_streams, + &stream_id, + &model, + &prompt, + &request, + &working_directory, + ) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentStopRequest { @@ -807,10 +859,11 @@ mod tests { model_id: "opus-1m".to_string(), model_provider: "claude".to_string(), user_message_id: Uuid::new_v4().to_string(), + is_background: false, }; // 1. Persist user message - persist_user_message(&conn, &ctx, "Hello", &[], &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "Hello", &[], &[], None, &[]).unwrap(); persist_result_and_finalize( &conn, @@ -880,9 +933,10 @@ mod tests { model_id: "opus-1m".to_string(), model_provider: "claude".to_string(), user_message_id: Uuid::new_v4().to_string(), + is_background: false, }; - persist_user_message(&conn, &ctx, "Hi", &[], &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "Hi", &[], &[], None, &[]).unwrap(); persist_result_and_finalize( &conn, &ctx, @@ -938,10 +992,11 @@ mod tests { model_id: "opus-1m".to_string(), model_provider: "claude".to_string(), user_message_id: Uuid::new_v4().to_string(), + is_background: false, }; // Persist user message - persist_user_message(&conn, &ctx, "Do something", &[], &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "Do something", &[], &[], None, &[]).unwrap(); // Persist two intermediate turns let turn1 = CollectedTurn { @@ -1009,10 +1064,11 @@ mod tests { model_id: "opus-1m".to_string(), model_provider: "claude".to_string(), user_message_id: "user-initial".to_string(), + is_background: false, }; // 1. Initial prompt persisted via the normal path. - persist_user_message(&conn, &ctx, "investigate the bug", &[], &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "investigate the bug", &[], &[], None, &[]).unwrap(); // 2. Drive the accumulator the same way the streaming loop does: // assistant deltas, steer event, more assistant deltas, result. diff --git a/src-tauri/src/agents/persistence.rs b/src-tauri/src/agents/persistence.rs index fc87dbdb8..2f4f8ef05 100644 --- a/src-tauri/src/agents/persistence.rs +++ b/src-tauri/src/agents/persistence.rs @@ -10,13 +10,15 @@ use super::ExchangeContext; /// Persist the user's prompt as the first message of the exchange. /// Wraps as `{"type":"user_prompt","text":"...","files":[...],"images":[...], /// "pastedTexts":[{"start":n,"end":n}]}`. Empty arrays are omitted from the -/// JSON. +/// JSON; `source` (e.g. `"automation"`) is only written when present so +/// pre-existing rows keep their exact shape. pub(super) fn persist_user_message( conn: &Connection, ctx: &ExchangeContext, prompt: &str, files: &[String], images: &[String], + source: Option<&str>, pasted_texts: &[PastedTextRange], ) -> Result<()> { let now = current_timestamp_string()?; @@ -25,6 +27,9 @@ pub(super) fn persist_user_message( "type": "user_prompt", "text": prompt, }); + if let Some(source) = source { + payload["source"] = serde_json::Value::String(source.to_string()); + } if !files.is_empty() { payload["files"] = serde_json::Value::Array( files @@ -290,17 +295,22 @@ fn finalize_session_metadata_in_transaction( ], )?; - transaction.execute( - r#" - UPDATE workspaces - SET - active_session_id = ?2 - WHERE id = (SELECT workspace_id FROM sessions WHERE id = ?1) - "#, - params![ctx.helmor_session_id, ctx.helmor_session_id], - )?; - - mark_session_read_in_transaction(transaction, &ctx.helmor_session_id)?; + // Background (automation) turns must not rearrange the user's UI: the + // result should surface as *unread*, and the workspace's active session + // should stay wherever the user left it. + if !ctx.is_background { + transaction.execute( + r#" + UPDATE workspaces + SET + active_session_id = ?2 + WHERE id = (SELECT workspace_id FROM sessions WHERE id = ?1) + "#, + params![ctx.helmor_session_id, ctx.helmor_session_id], + )?; + + mark_session_read_in_transaction(transaction, &ctx.helmor_session_id)?; + } Ok(()) } @@ -318,6 +328,7 @@ mod tests { model_id: "gpt-5.4".to_string(), model_provider: "codex".to_string(), user_message_id: "user-1".to_string(), + is_background: false, } } @@ -381,7 +392,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); make_messages_table(&conn); let ctx = test_exchange_context(); - persist_user_message(&conn, &ctx, "fix bug X", &[], &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "fix bug X", &[], &[], None, &[]).unwrap(); let (role, content, id): (String, String, String) = conn .query_row( @@ -408,7 +419,7 @@ mod tests { make_messages_table(&conn); let ctx = test_exchange_context(); let pasted = vec![PastedTextRange { start: 4, end: 16 }]; - persist_user_message(&conn, &ctx, "see <big paste> ok", &[], &[], &pasted).unwrap(); + persist_user_message(&conn, &ctx, "see <big paste> ok", &[], &[], None, &pasted).unwrap(); let content: String = conn .query_row( @@ -427,7 +438,7 @@ mod tests { make_messages_table(&conn); let ctx = test_exchange_context(); let files = vec!["a.rs".to_string(), "b.rs".to_string()]; - persist_user_message(&conn, &ctx, "refactor", &files, &[], &[]).unwrap(); + persist_user_message(&conn, &ctx, "refactor", &files, &[], None, &[]).unwrap(); let content: String = conn .query_row( @@ -452,7 +463,7 @@ mod tests { let images = vec![ "/Users/me/Library/Application Support/CleanShot/CleanShot 2026-04-29 at 08.24.35@2x.jpg".to_string(), ]; - persist_user_message(&conn, &ctx, "look at this", &[], &images, &[]).unwrap(); + persist_user_message(&conn, &ctx, "look at this", &[], &images, None, &[]).unwrap(); let content: String = conn .query_row( diff --git a/src-tauri/src/agents/streaming/cleanup.rs b/src-tauri/src/agents/streaming/cleanup.rs index ce76f92f8..a65d7a581 100644 --- a/src-tauri/src/agents/streaming/cleanup.rs +++ b/src-tauri/src/agents/streaming/cleanup.rs @@ -199,6 +199,7 @@ mod tests { model_id: "opus".to_string(), model_provider: "claude".to_string(), user_message_id: "user-1".to_string(), + is_background: false, } } diff --git a/src-tauri/src/agents/streaming/mod.rs b/src-tauri/src/agents/streaming/mod.rs index 0a73719f7..5a7947747 100644 --- a/src-tauri/src/agents/streaming/mod.rs +++ b/src-tauri/src/agents/streaming/mod.rs @@ -10,6 +10,12 @@ use std::time::{Duration, Instant}; /// regardless of what the AI is doing. const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(45); +/// Substring stamped into the "another stream is active for this session" +/// rejection. Callers that must react to a busy session (the automations +/// scheduler rolls its claim back and retries) match on this instead of the +/// full sentence, so wording tweaks don't silently break detection. +pub const SESSION_BUSY_MARKER: &str = "still running for this session"; + mod actions; mod active_streams; mod bridges; @@ -227,7 +233,7 @@ pub(super) fn stream_via_sidecar( "Rejecting send: another stream is already active for this session" ); return Err(anyhow::anyhow!( - "A previous send is still running for this session. Wait for it to finish or stop it first." + "A previous send is {SESSION_BUSY_MARKER}. Wait for it to finish or stop it first." ) .into()); } @@ -259,6 +265,7 @@ pub(super) fn stream_via_sidecar( let user_message_id_copy = request.user_message_id.clone(); let files_copy = request.files.clone().unwrap_or_default(); let images_copy = request.images.clone().unwrap_or_default(); + let source_copy = request.source.clone(); let pasted_texts_copy = request.pasted_texts.clone().unwrap_or_default(); let sidecar_session_id_copy = sidecar_session_id.clone(); let rid = request_id.clone(); @@ -316,6 +323,7 @@ pub(super) fn stream_via_sidecar( user_message_id: user_message_id_copy .clone() .unwrap_or_else(|| Uuid::new_v4().to_string()), + is_background: source_copy.is_some(), }; match crate::models::db::write_conn() { @@ -333,6 +341,7 @@ pub(super) fn stream_via_sidecar( &prompt_copy, &files_copy, &images_copy, + source_copy.as_deref(), &pasted_texts_copy, ) { Ok(()) => { @@ -1438,6 +1447,7 @@ fn build_exit_plan_review_message( })], status: None, streaming: None, + source: None, } } diff --git a/src-tauri/src/agents/streaming/stream_hub.rs b/src-tauri/src/agents/streaming/stream_hub.rs index 3528cc508..4918ac56c 100644 --- a/src-tauri/src/agents/streaming/stream_hub.rs +++ b/src-tauri/src/agents/streaming/stream_hub.rs @@ -152,6 +152,7 @@ mod tests { })], status: None, streaming: None, + source: None, }], } } diff --git a/src-tauri/src/automations/dispatch.rs b/src-tauri/src/automations/dispatch.rs new file mode 100644 index 000000000..49091e7af --- /dev/null +++ b/src-tauri/src/automations/dispatch.rs @@ -0,0 +1,172 @@ +//! Turn dispatch for automation runs. +//! +//! Shared by the scheduler tick and the "Run now" command. A run is a +//! completely normal agent turn started through +//! `agents::start_background_turn` (no-op IPC channel): persistence, +//! watcher fan-out, busy-locking, and shutdown handling are all the regular +//! streaming machinery. This module only resolves *where* the turn goes. + +use anyhow::anyhow; +use tauri::AppHandle; + +use crate::agents::AgentSendRequest; +use crate::models::automations::{AutomationRecord, RUNS_IN_CHAT, RUNS_IN_WORKSPACE}; +use crate::models::sessions; + +pub struct StartedRun { + pub session_id: String, +} + +pub enum RunError { + /// The bound session already has a turn in flight (claim/dispatch raced a + /// user send). The scheduler rolls the claim back and retries next tick. + SessionBusy, + /// The bound session/workspace no longer exists (or never resolved). The + /// scheduler pauses the automation instead of retrying forever. + TargetMissing(String), + /// Anything else. Deliberately not retried — the next scheduled slot is + /// the recovery path. + Other(anyhow::Error), +} + +/// Resolve the automation's target session and start a background turn with +/// its prompt. Returns once the sidecar accepted the turn (streaming +/// continues on the event-loop thread). `chat` mode targets the bound session; +/// `workspace` mode validates the workspace first, then creates a fresh +/// session for this run (without stealing the workspace's active selection). +pub fn run_automation_now( + app: &AppHandle, + automation: &AutomationRecord, +) -> Result<StartedRun, RunError> { + match automation.runs_in.as_str() { + RUNS_IN_CHAT => { + let session_id = automation.session_id.clone().ok_or_else(|| { + RunError::TargetMissing("chat automation has no bound session".to_string()) + })?; + let (workspace_id, permission) = + sessions::get_session_workspace_and_permission(&session_id) + .map_err(RunError::Other)? + .ok_or_else(|| { + RunError::TargetMissing(format!( + "bound session {session_id} no longer exists" + )) + })?; + let workspace_id = workspace_id.ok_or_else(|| { + RunError::TargetMissing(format!("session {session_id} has no workspace")) + })?; + let root_path = resolve_root_path(&workspace_id)?; + dispatch_turn( + app, + automation, + &session_id, + root_path, + Some(permission), + false, + ) + } + RUNS_IN_WORKSPACE => { + let workspace_id = automation.workspace_id.clone().ok_or_else(|| { + RunError::TargetMissing("workspace automation has no bound workspace".to_string()) + })?; + // Validate the workspace (and read its cwd) BEFORE creating the + // session, so a missing target never leaves an empty orphan behind. + let root_path = resolve_root_path(&workspace_id)?; + let created = sessions::create_session( + &workspace_id, + None, + None, + sessions::CreateSessionOverrides { + // A scheduled run must not yank the workspace's active + // session away from whatever the user is looking at. + skip_active_session: true, + ..Default::default() + }, + ) + .map_err(RunError::Other)?; + // Best-effort: name the fresh session after the automation so the + // sidebar reads "Target order monitor", not "Untitled". + if let Err(error) = sessions::rename_session(&created.session_id, &automation.title) { + tracing::warn!( + session_id = %created.session_id, + error = %format!("{error:#}"), + "automations: failed to title run session" + ); + } + dispatch_turn(app, automation, &created.session_id, root_path, None, true) + } + other => Err(RunError::TargetMissing(format!( + "unknown runs_in value {other:?}" + ))), + } +} + +/// Resolve a workspace to its on-disk cwd. Every failure maps to +/// `TargetMissing` so the scheduler pauses the automation rather than retrying. +fn resolve_root_path(workspace_id: &str) -> Result<String, RunError> { + let workspace = crate::workspaces::get_workspace(workspace_id) + .map_err(|error| RunError::TargetMissing(format!("workspace {workspace_id}: {error:#}")))?; + workspace.root_path.ok_or_else(|| { + RunError::TargetMissing(format!("workspace {workspace_id} has no root_path")) + }) +} + +/// Build the request and start the background turn. `created_fresh` marks a +/// throwaway `workspace`-mode session that must be cleaned up if dispatch fails +/// so failed runs don't litter the sidebar. +fn dispatch_turn( + app: &AppHandle, + automation: &AutomationRecord, + session_id: &str, + root_path: String, + permission_mode: Option<String>, + created_fresh: bool, +) -> Result<StartedRun, RunError> { + // Model: session row > "default", with the session's agent_type as the + // provider hint — same resolution as `helmor send` (service.rs). + let (session_model, session_provider) = + sessions::get_session_model_and_provider(session_id).unwrap_or((None, None)); + let model_id = session_model.unwrap_or_else(|| "default".to_string()); + let model = crate::agents::resolve_model(&model_id, session_provider.as_deref()); + + let request = AgentSendRequest { + provider: model.provider.to_string(), + model_id: model.id.to_string(), + prompt: automation.prompt.clone(), + prompt_prefix: None, + session_id: None, + helmor_session_id: Some(session_id.to_string()), + working_directory: Some(root_path), + effort_level: None, + permission_mode, + fast_mode: None, + user_message_id: None, + files: None, + images: None, + source: Some("automation".to_string()), + pasted_texts: None, + }; + + if let Err(error) = crate::agents::start_background_turn(app, request) { + if created_fresh { + if let Err(cleanup) = sessions::delete_session(session_id) { + tracing::warn!( + session_id, + error = %format!("{cleanup:#}"), + "automations: failed to clean up empty run session after dispatch failure" + ); + } + } + // CommandError exposes the chain via Debug; the busy rejection is the + // one failure we must distinguish (roll back + retry next tick). + let message = format!("{error:?}"); + return Err(if message.contains(crate::agents::SESSION_BUSY_MARKER) { + RunError::SessionBusy + } else { + RunError::Other(anyhow!(message)) + }); + } + + Ok(StartedRun { + session_id: session_id.to_string(), + }) +} diff --git a/src-tauri/src/automations/mod.rs b/src-tauri/src/automations/mod.rs new file mode 100644 index 000000000..0b814c80a --- /dev/null +++ b/src-tauri/src/automations/mod.rs @@ -0,0 +1,20 @@ +//! Scheduled automations — Codex-style recurring prompts. +//! +//! An automation periodically injects a fixed prompt into a session and runs +//! a normal agent turn; the chat itself is the run history. Reliability model: +//! +//! - SQLite is the single source of truth (`models::automations`); the +//! scheduler thread holds no durable state. +//! - The scheduler is a stateless 30s poll loop (`scheduler`); a tick that +//! arrives late (app restart, machine sleep) simply sees overdue rows. +//! - Claim-before-dispatch: a CAS UPDATE on `next_run_at` makes each slot +//! fire at most once, with `next_run_at` always recomputed from "now" +//! (`schedule`) so long offline gaps produce exactly one catch-up run. +//! - Dispatch (`dispatch`) reuses the regular streaming engine with a no-op +//! IPC channel — watchers and persistence behave exactly like a +//! user-initiated send, and no UI focus is stolen. + +pub mod dispatch; +pub mod ops; +pub mod schedule; +pub mod scheduler; diff --git a/src-tauri/src/automations/ops.rs b/src-tauri/src/automations/ops.rs new file mode 100644 index 000000000..042e8a4f9 --- /dev/null +++ b/src-tauri/src/automations/ops.rs @@ -0,0 +1,390 @@ +//! Domain operations shared by the Tauri commands and the `helmor +//! automation` CLI. Pure validation + persistence — UI notification +//! (`ui_sync::publish` vs `notify_running_app`) stays with the callers. + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde_json::Value; + +use super::schedule::{format_utc, next_run_after, Schedule}; +use crate::models::automations::{ + self, AutomationRecord, NewAutomation, RUNS_IN_CHAT, RUNS_IN_WORKSPACE, STATUS_ACTIVE, + STATUS_PAUSED, +}; + +pub struct CreateAutomationInput { + pub title: String, + pub prompt: String, + pub runs_in: String, + pub session_id: Option<String>, + pub workspace_id: Option<String>, + pub schedule: Value, +} + +#[derive(Default)] +pub struct UpdateAutomationInput { + pub title: Option<String>, + pub prompt: Option<String>, + pub runs_in: Option<String>, + pub session_id: Option<String>, + pub workspace_id: Option<String>, + pub schedule: Option<Value>, +} + +pub fn create_automation(input: CreateAutomationInput) -> Result<AutomationRecord> { + let title = input.title.trim(); + let prompt = input.prompt.trim(); + if title.is_empty() { + bail!("Automation title cannot be empty"); + } + if prompt.is_empty() { + bail!("Automation prompt cannot be empty"); + } + let schedule = parse_schedule(&input.schedule)?; + validate_target( + &input.runs_in, + input.session_id.as_deref(), + input.workspace_id.as_deref(), + )?; + let next_run_at = format_utc(next_run_after(&schedule, Utc::now())?); + // Store the canonical serde form, not caller-provided JSON verbatim. + let canonical = serde_json::to_value(&schedule)?; + automations::insert_automation(&NewAutomation { + title, + prompt, + runs_in: &input.runs_in, + session_id: input.session_id.as_deref(), + workspace_id: input.workspace_id.as_deref(), + schedule: &canonical, + next_run_at: &next_run_at, + }) +} + +/// Read-modify-write edit. A schedule change recomputes `next_run_at` from +/// now; binding changes are re-validated as a whole. +pub fn update_automation(id: &str, input: UpdateAutomationInput) -> Result<AutomationRecord> { + let mut record = + automations::get_automation(id)?.with_context(|| format!("Automation {id} not found"))?; + + if let Some(title) = input.title { + let title = title.trim().to_string(); + if title.is_empty() { + bail!("Automation title cannot be empty"); + } + record.title = title; + } + if let Some(prompt) = input.prompt { + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + bail!("Automation prompt cannot be empty"); + } + record.prompt = prompt; + } + if let Some(runs_in) = input.runs_in { + record.runs_in = runs_in; + } + if let Some(session_id) = input.session_id { + record.session_id = Some(session_id); + } + if let Some(workspace_id) = input.workspace_id { + record.workspace_id = Some(workspace_id); + } + validate_target( + &record.runs_in, + record.session_id.as_deref(), + record.workspace_id.as_deref(), + )?; + + if let Some(schedule_json) = input.schedule { + let schedule = parse_schedule(&schedule_json)?; + record.schedule = serde_json::to_value(&schedule)?; + record.next_run_at = format_utc(next_run_after(&schedule, Utc::now())?); + } + + automations::update_automation_record(&record)?; + automations::get_automation(id)?.with_context(|| format!("Automation {id} not found")) +} + +/// Pause or resume. Resume recomputes `next_run_at` from now so a +/// long-paused automation never fires immediately. +pub fn set_status(id: &str, status: &str) -> Result<AutomationRecord> { + let record = + automations::get_automation(id)?.with_context(|| format!("Automation {id} not found"))?; + match status { + STATUS_PAUSED => automations::set_automation_status(id, STATUS_PAUSED, None)?, + STATUS_ACTIVE => { + // Re-validate the binding: resuming onto a session/workspace that + // was deleted while paused would just dispatch-fail and re-pause. + validate_target( + &record.runs_in, + record.session_id.as_deref(), + record.workspace_id.as_deref(), + )?; + let schedule: Schedule = serde_json::from_value(record.schedule.clone()) + .context("Automation has an unparseable schedule")?; + let next_run_at = format_utc(next_run_after(&schedule, Utc::now())?); + automations::set_automation_status(id, STATUS_ACTIVE, Some(&next_run_at))?; + } + other => bail!("Invalid status {other:?} — expected active or paused"), + } + automations::get_automation(id)?.with_context(|| format!("Automation {id} not found")) +} + +/// Manual "Run now": dispatch immediately, record `last_run_at`, leave the +/// schedule (`next_run_at`) untouched. Returns the session the run landed in. +pub fn run_now(app: &tauri::AppHandle, id: &str) -> Result<String> { + let record = + automations::get_automation(id)?.with_context(|| format!("Automation {id} not found"))?; + let started = + super::dispatch::run_automation_now(app, &record).map_err(|error| match error { + super::dispatch::RunError::SessionBusy => anyhow::anyhow!( + "A turn is already running in this automation's chat. Wait for it to finish." + ), + super::dispatch::RunError::TargetMissing(reason) => { + anyhow::anyhow!("Automation target is gone: {reason}") + } + super::dispatch::RunError::Other(error) => error, + })?; + automations::set_last_run_at(id, &crate::models::db::current_timestamp()?)?; + Ok(started.session_id) +} + +pub fn parse_schedule(value: &Value) -> Result<Schedule> { + let schedule: Schedule = serde_json::from_value(value.clone()) + .context("Invalid schedule — expected {kind: hourly|daily|weekly|every, ...}")?; + schedule.validate()?; + Ok(schedule) +} + +fn validate_target( + runs_in: &str, + session_id: Option<&str>, + workspace_id: Option<&str>, +) -> Result<()> { + match runs_in { + RUNS_IN_CHAT => { + let session_id = + session_id.context("runs_in=chat requires a bound session (sessionId)")?; + let exists = crate::models::sessions::get_session_workspace_and_permission(session_id)? + .is_some(); + if !exists { + bail!("Session {session_id} does not exist"); + } + } + RUNS_IN_WORKSPACE => { + let workspace_id = + workspace_id.context("runs_in=workspace requires a workspace (workspaceId)")?; + crate::workspaces::get_workspace(workspace_id) + .with_context(|| format!("Workspace {workspace_id} does not exist"))?; + } + other => bail!("Invalid runs_in {other:?} — expected chat or workspace"), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::automations::STATUS_ACTIVE; + + fn workspace_fixture() -> (String, String) { + // Minimal repo+workspace+session rows so target validation passes. + let conn = crate::models::db::write_conn().unwrap(); + conn.execute_batch( + r#" + INSERT INTO repos (id, name, root_path) VALUES ('repo-1', 'demo', '/tmp/demo'); + INSERT INTO workspaces (id, repository_id, directory_name) VALUES ('ws-1', 'repo-1', 'demo-ws'); + INSERT INTO sessions (id, workspace_id, title) VALUES ('sess-1', 'ws-1', 'Chat'); + "#, + ) + .unwrap(); + ("ws-1".into(), "sess-1".into()) + } + + fn chat_input(session_id: &str) -> CreateAutomationInput { + CreateAutomationInput { + title: "Order monitor".into(), + prompt: "check the thing".into(), + runs_in: RUNS_IN_CHAT.into(), + session_id: Some(session_id.to_string()), + workspace_id: None, + schedule: serde_json::json!({"kind": "hourly"}), + } + } + + #[test] + fn create_validates_and_computes_next_run() { + let _env = crate::testkit::TestEnv::new("automation-ops-create"); + let (_ws, session) = workspace_fixture(); + + let record = create_automation(chat_input(&session)).unwrap(); + assert_eq!(record.status, STATUS_ACTIVE); + assert!(record.next_run_at > crate::models::db::current_timestamp().unwrap()); + + // Missing session → rejected. + let bad = create_automation(chat_input("nope")); + assert!(bad.is_err()); + + // Garbage schedule → rejected. + let mut input = chat_input(&session); + input.schedule = serde_json::json!({"kind": "fortnightly"}); + assert!(create_automation(input).is_err()); + } + + #[test] + fn resume_recomputes_next_run_from_now() { + let _env = crate::testkit::TestEnv::new("automation-ops-resume"); + let (_ws, session) = workspace_fixture(); + let record = create_automation(chat_input(&session)).unwrap(); + + set_status(&record.id, STATUS_PAUSED).unwrap(); + // Make the stored next_run_at stale, as after a long pause. + crate::models::automations::set_automation_status( + &record.id, + STATUS_PAUSED, + Some("2000-01-01T00:00:00.000Z"), + ) + .unwrap(); + + let resumed = set_status(&record.id, STATUS_ACTIVE).unwrap(); + assert_eq!(resumed.status, STATUS_ACTIVE); + // No immediate fire: next_run_at is in the future again. + assert!(resumed.next_run_at > crate::models::db::current_timestamp().unwrap()); + } + + #[test] + fn update_schedule_recomputes_but_title_edit_does_not() { + let _env = crate::testkit::TestEnv::new("automation-ops-update"); + let (_ws, session) = workspace_fixture(); + let record = create_automation(chat_input(&session)).unwrap(); + let original_next = record.next_run_at.clone(); + + let renamed = update_automation( + &record.id, + UpdateAutomationInput { + title: Some("Renamed".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(renamed.title, "Renamed"); + assert_eq!(renamed.next_run_at, original_next); + + let rescheduled = update_automation( + &record.id, + UpdateAutomationInput { + schedule: Some( + serde_json::json!({"kind": "every", "amount": 5, "unit": "minutes"}), + ), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(rescheduled.next_run_at, original_next); + assert_eq!(rescheduled.schedule["kind"], "every"); + } + + #[test] + fn resume_revalidates_missing_target() { + let _env = crate::testkit::TestEnv::new("automation-ops-resume-validate"); + // A paused chat automation pointing at a session that doesn't exist + // (target deleted via some path). Resume must refuse rather than + // reactivate a guaranteed-to-dispatch-fail automation. + let record = automations::insert_automation(&NewAutomation { + title: "orphan", + prompt: "check", + runs_in: RUNS_IN_CHAT, + session_id: Some("ghost-session"), + workspace_id: None, + schedule: &serde_json::json!({"kind": "hourly"}), + next_run_at: "2026-01-01T00:00:00.000Z", + }) + .unwrap(); + automations::set_automation_status(&record.id, STATUS_PAUSED, None).unwrap(); + + assert!(set_status(&record.id, STATUS_ACTIVE).is_err()); + } + + #[test] + fn delete_session_cascades_chat_automation() { + let _env = crate::testkit::TestEnv::new("automation-ops-cascade-session"); + let (_ws, session) = workspace_fixture(); + let record = create_automation(chat_input(&session)).unwrap(); + + crate::models::sessions::delete_session(&session).unwrap(); + assert!(automations::get_automation(&record.id).unwrap().is_none()); + } + + #[test] + fn delete_workspace_cascades_automations() { + let _env = crate::testkit::TestEnv::new("automation-ops-cascade-workspace"); + let (ws, session) = workspace_fixture(); + // One workspace-mode row + one chat row whose session lives in the + // workspace; both must be cascaded. Inserted directly to bypass the + // create-time target validation (irrelevant to the cascade). + let ws_auto = automations::insert_automation(&NewAutomation { + title: "ws monitor", + prompt: "check", + runs_in: RUNS_IN_WORKSPACE, + session_id: None, + workspace_id: Some(&ws), + schedule: &serde_json::json!({"kind": "hourly"}), + next_run_at: "2026-01-01T00:00:00.000Z", + }) + .unwrap(); + let chat_auto = automations::insert_automation(&NewAutomation { + title: "chat monitor", + prompt: "check", + runs_in: RUNS_IN_CHAT, + session_id: Some(&session), + workspace_id: None, + schedule: &serde_json::json!({"kind": "hourly"}), + next_run_at: "2026-01-01T00:00:00.000Z", + }) + .unwrap(); + + crate::models::workspaces::delete_workspace_and_session_rows(&ws).unwrap(); + assert!(automations::get_automation(&ws_auto.id).unwrap().is_none()); + assert!(automations::get_automation(&chat_auto.id) + .unwrap() + .is_none()); + } + + #[test] + fn workspace_run_session_does_not_steal_active_session() { + let _env = crate::testkit::TestEnv::new("automation-skip-active"); + let (ws, session) = workspace_fixture(); + // Pin `session` as the workspace's active selection. + crate::models::db::write_conn() + .unwrap() + .execute( + "UPDATE workspaces SET active_session_id = ?1 WHERE id = ?2", + [session.as_str(), ws.as_str()], + ) + .unwrap(); + + // A skip-active create (what automation workspace runs use) must leave + // the active selection where the user left it. + let created = crate::models::sessions::create_session( + &ws, + None, + None, + crate::models::sessions::CreateSessionOverrides { + skip_active_session: true, + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(created.session_id, session); + + let active: Option<String> = crate::models::db::read_conn() + .unwrap() + .query_row( + "SELECT active_session_id FROM workspaces WHERE id = ?1", + [ws.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(active.as_deref(), Some(session.as_str())); + } +} diff --git a/src-tauri/src/automations/schedule.rs b/src-tauri/src/automations/schedule.rs new file mode 100644 index 000000000..51f51875c --- /dev/null +++ b/src-tauri/src/automations/schedule.rs @@ -0,0 +1,393 @@ +//! Schedule spec + next-run computation. +//! +//! `next_run_after` is a pure function of (schedule, now) so it needs no +//! clock mocking in tests. Interval kinds are plain UTC arithmetic; daily and +//! weekly resolve the next *local wall-clock* occurrence (generic over +//! `chrono::TimeZone` — production passes `Local`, tests pass `FixedOffset`). +//! Crucially the result is always computed from "now", never incremented from +//! the previous value, so a machine that slept through N slots schedules +//! exactly one catch-up run and falls back into cadence. + +use anyhow::{bail, Context, Result}; +use chrono::{ + DateTime, Datelike, Duration, LocalResult, NaiveDateTime, NaiveTime, SecondsFormat, TimeZone, + Utc, Weekday, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Schedule { + /// Every hour, anchored to the moment of (re)computation. + Hourly, + /// Every day at a local wall-clock time, `"HH:MM"`. + Daily { time: String }, + /// Every week on `weekday` (0 = Sunday … 6 = Saturday, JS convention) + /// at a local wall-clock time, `"HH:MM"`. + Weekly { weekday: u8, time: String }, + /// Every N minutes/hours, anchored to the moment of (re)computation. + Every { amount: u32, unit: EveryUnit }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EveryUnit { + Minutes, + Hours, +} + +impl Schedule { + pub fn validate(&self) -> Result<()> { + match self { + Schedule::Hourly => Ok(()), + Schedule::Daily { time } => parse_hhmm(time).map(|_| ()), + Schedule::Weekly { weekday, time } => { + weekday_from_index(*weekday)?; + parse_hhmm(time).map(|_| ()) + } + Schedule::Every { amount, .. } => { + if *amount == 0 { + bail!("Custom interval must be at least 1"); + } + Ok(()) + } + } + } + + /// Human summary for list rows and chat badges ("Hourly", "Daily at 09:00"). + pub fn summary(&self) -> String { + match self { + Schedule::Hourly => "Hourly".to_string(), + Schedule::Daily { time } => format!("Daily at {time}"), + Schedule::Weekly { weekday, time } => { + let day = weekday_from_index(*weekday) + .map(weekday_label) + .unwrap_or("?"); + format!("Weekly on {day} at {time}") + } + Schedule::Every { amount, unit } => match unit { + EveryUnit::Minutes => format!("Every {amount}m"), + EveryUnit::Hours => format!("Every {amount}h"), + }, + } + } +} + +/// Next fire instant strictly after `now`, in the local timezone. +pub fn next_run_after(schedule: &Schedule, now: DateTime<Utc>) -> Result<DateTime<Utc>> { + next_run_after_in(schedule, now, &chrono::Local) +} + +/// Timezone-generic core of [`next_run_after`] — unit-testable with +/// `FixedOffset`. +pub fn next_run_after_in<Tz: TimeZone>( + schedule: &Schedule, + now: DateTime<Utc>, + tz: &Tz, +) -> Result<DateTime<Utc>> { + schedule.validate()?; + Ok(match schedule { + Schedule::Hourly => now + Duration::hours(1), + Schedule::Every { amount, unit } => { + now + match unit { + EveryUnit::Minutes => Duration::minutes(i64::from(*amount)), + EveryUnit::Hours => Duration::hours(i64::from(*amount)), + } + } + Schedule::Daily { time } => next_local_occurrence(now, tz, parse_hhmm(time)?, None), + Schedule::Weekly { weekday, time } => next_local_occurrence( + now, + tz, + parse_hhmm(time)?, + Some(weekday_from_index(*weekday)?), + ), + }) +} + +/// Storage format for `automations.next_run_at` — matches +/// `db::current_timestamp()` (RFC3339 UTC millis) so strings compare +/// chronologically. +pub fn format_utc(instant: DateTime<Utc>) -> String { + instant.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +/// Next local wall-clock occurrence of `time` (optionally constrained to a +/// weekday) strictly after `now`. Recomputed from scratch every call, so DST +/// shifts self-correct on the following cycle. +fn next_local_occurrence<Tz: TimeZone>( + now: DateTime<Utc>, + tz: &Tz, + time: NaiveTime, + weekday: Option<Weekday>, +) -> DateTime<Utc> { + let local_now = now.with_timezone(tz); + let mut date = local_now.date_naive(); + // 8 days covers a full weekday cycle plus the "today's slot already + // passed" case; the unreachable fallback below is pure defense. + for _ in 0..=8 { + let weekday_matches = weekday.is_none_or(|w| date.weekday() == w); + if weekday_matches { + if let Some(candidate) = resolve_local(tz, date.and_time(time)) { + if candidate > now { + return candidate; + } + } + } + match date.succ_opt() { + Some(next) => date = next, + None => break, + } + } + now + Duration::days(1) +} + +/// Resolve a naive local datetime to UTC. DST fold (ambiguous) takes the +/// earliest instant; DST gap (nonexistent) advances minute-by-minute to the +/// first valid instant after the gap. +fn resolve_local<Tz: TimeZone>(tz: &Tz, naive: NaiveDateTime) -> Option<DateTime<Utc>> { + let mut probe = naive; + // Bounded walk: real DST gaps are ≤ 2h; 240 minutes is generous. + for _ in 0..240 { + match tz.from_local_datetime(&probe) { + LocalResult::Single(dt) => return Some(dt.with_timezone(&Utc)), + LocalResult::Ambiguous(earliest, _) => return Some(earliest.with_timezone(&Utc)), + LocalResult::None => probe += Duration::minutes(1), + } + } + None +} + +fn parse_hhmm(time: &str) -> Result<NaiveTime> { + NaiveTime::parse_from_str(time, "%H:%M") + .with_context(|| format!("Invalid time {time:?} — expected HH:MM")) +} + +fn weekday_from_index(weekday: u8) -> Result<Weekday> { + Ok(match weekday { + 0 => Weekday::Sun, + 1 => Weekday::Mon, + 2 => Weekday::Tue, + 3 => Weekday::Wed, + 4 => Weekday::Thu, + 5 => Weekday::Fri, + 6 => Weekday::Sat, + other => bail!("Invalid weekday {other} — expected 0 (Sunday) … 6 (Saturday)"), + }) +} + +fn weekday_label(weekday: Weekday) -> &'static str { + match weekday { + Weekday::Sun => "Sunday", + Weekday::Mon => "Monday", + Weekday::Tue => "Tuesday", + Weekday::Wed => "Wednesday", + Weekday::Thu => "Thursday", + Weekday::Fri => "Friday", + Weekday::Sat => "Saturday", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::FixedOffset; + + fn utc(s: &str) -> DateTime<Utc> { + s.parse().unwrap() + } + + /// UTC+8 — no DST, deterministic across test machines. + fn tz_plus8() -> FixedOffset { + FixedOffset::east_opt(8 * 3600).unwrap() + } + + #[test] + fn hourly_and_every_are_anchored_to_now() { + let now = utc("2026-06-11T10:00:00Z"); + assert_eq!( + next_run_after_in(&Schedule::Hourly, now, &tz_plus8()).unwrap(), + utc("2026-06-11T11:00:00Z") + ); + let every = Schedule::Every { + amount: 5, + unit: EveryUnit::Minutes, + }; + assert_eq!( + next_run_after_in(&every, now, &tz_plus8()).unwrap(), + utc("2026-06-11T10:05:00Z") + ); + let every_hours = Schedule::Every { + amount: 3, + unit: EveryUnit::Hours, + }; + assert_eq!( + next_run_after_in(&every_hours, now, &tz_plus8()).unwrap(), + utc("2026-06-11T13:00:00Z") + ); + } + + #[test] + fn daily_today_when_time_not_yet_passed() { + // 2026-06-11 10:00 UTC = 18:00 local (+8). Daily at 21:00 local → + // today 21:00 local = 13:00 UTC. + let now = utc("2026-06-11T10:00:00Z"); + let schedule = Schedule::Daily { + time: "21:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-11T13:00:00Z") + ); + } + + #[test] + fn daily_tomorrow_when_time_already_passed() { + // 18:00 local, daily at 09:00 → tomorrow 09:00 local = 01:00 UTC. + let now = utc("2026-06-11T10:00:00Z"); + let schedule = Schedule::Daily { + time: "09:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-12T01:00:00Z") + ); + } + + #[test] + fn daily_exact_boundary_rolls_to_next_day() { + // Exactly 09:00 local: "strictly after now" → tomorrow. + let now = utc("2026-06-11T01:00:00Z"); // 09:00 local (+8) + let schedule = Schedule::Daily { + time: "09:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-12T01:00:00Z") + ); + } + + #[test] + fn weekly_same_day_later_time_fires_today() { + // 2026-06-11 is a Thursday. 18:00 local, weekly Thu 20:00 → today. + let now = utc("2026-06-11T10:00:00Z"); + let schedule = Schedule::Weekly { + weekday: 4, + time: "20:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-11T12:00:00Z") + ); + } + + #[test] + fn weekly_wraps_to_next_week() { + // Thursday 18:00 local, weekly Thu 09:00 → next Thursday. + let now = utc("2026-06-11T10:00:00Z"); + let schedule = Schedule::Weekly { + weekday: 4, + time: "09:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-18T01:00:00Z") + ); + } + + #[test] + fn weekly_crosses_into_earlier_weekday_next_week() { + // Thursday, weekly Monday 08:00 → the coming Monday (Jun 15). + let now = utc("2026-06-11T10:00:00Z"); + let schedule = Schedule::Weekly { + weekday: 1, + time: "08:00".into(), + }; + assert_eq!( + next_run_after_in(&schedule, now, &tz_plus8()).unwrap(), + utc("2026-06-15T00:00:00Z") + ); + } + + #[test] + fn result_is_always_strictly_after_now() { + let schedules = [ + Schedule::Hourly, + Schedule::Daily { + time: "00:00".into(), + }, + Schedule::Weekly { + weekday: 0, + time: "23:59".into(), + }, + Schedule::Every { + amount: 1, + unit: EveryUnit::Minutes, + }, + ]; + let nows = [ + utc("2026-06-11T00:00:00Z"), + utc("2026-06-11T23:59:00Z"), + utc("2026-12-31T23:59:59Z"), + ]; + for schedule in &schedules { + for now in nows { + let next = next_run_after_in(schedule, now, &tz_plus8()).unwrap(); + assert!(next > now, "{schedule:?} at {now} produced {next}"); + } + } + } + + #[test] + fn validation_rejects_bad_inputs() { + assert!(next_run_after_in( + &Schedule::Daily { + time: "25:00".into() + }, + utc("2026-06-11T00:00:00Z"), + &tz_plus8(), + ) + .is_err()); + assert!(Schedule::Weekly { + weekday: 7, + time: "09:00".into() + } + .validate() + .is_err()); + assert!(Schedule::Every { + amount: 0, + unit: EveryUnit::Hours + } + .validate() + .is_err()); + } + + #[test] + fn schedule_json_shape_is_stable() { + // The JSON tag shape is the storage + IPC contract. + let weekly = Schedule::Weekly { + weekday: 4, + time: "09:30".into(), + }; + assert_eq!( + serde_json::to_value(&weekly).unwrap(), + serde_json::json!({"kind": "weekly", "weekday": 4, "time": "09:30"}) + ); + let every: Schedule = serde_json::from_value( + serde_json::json!({"kind": "every", "amount": 15, "unit": "minutes"}), + ) + .unwrap(); + assert_eq!( + every, + Schedule::Every { + amount: 15, + unit: EveryUnit::Minutes + } + ); + } + + #[test] + fn format_utc_matches_db_timestamp_shape() { + let formatted = format_utc(utc("2026-06-11T10:00:00Z")); + assert_eq!(formatted, "2026-06-11T10:00:00.000Z"); + } +} diff --git a/src-tauri/src/automations/scheduler.rs b/src-tauri/src/automations/scheduler.rs new file mode 100644 index 000000000..9fadb50f2 --- /dev/null +++ b/src-tauri/src/automations/scheduler.rs @@ -0,0 +1,185 @@ +//! Stateless poll-loop scheduler for automations. +//! +//! A dedicated std::thread that ticks every 30s. All durable state lives in +//! SQLite — a tick reads due +//! rows, CAS-claims each slot (advancing `next_run_at` computed from *now*), +//! and only then dispatches. Consequences, by construction: +//! +//! - App quit / crash / machine sleep: the next tick (whenever it happens) +//! sees overdue rows and fires each exactly once — one catch-up run, no +//! backlog, no double-fire. +//! - Busy target session: skipped *without claiming*; the 30s cadence is the +//! retry loop, which serializes runs naturally. +//! - Dispatch failure after a claim: the claim stands (run lost, next slot +//! recovers) — except the busy race, which rolls the claim back, and a +//! missing target, which pauses the automation. + +use std::collections::{HashMap, HashSet}; +use std::thread; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use tauri::{AppHandle, Manager}; + +use super::dispatch::{self, RunError}; +use super::schedule::{format_utc, next_run_after, Schedule}; +use crate::agents::ActiveStreams; +use crate::models::automations::{self, AutomationRecord, RUNS_IN_CHAT, STATUS_PAUSED}; +use crate::models::db; + +const STARTUP_DELAY_SEC: u64 = 20; +const TICK_INTERVAL_SEC: u64 = 30; + +pub fn spawn_scheduler(app: AppHandle) { + if let Err(error) = thread::Builder::new() + .name("automations-scheduler".into()) + .spawn(move || scheduler_loop(app)) + { + tracing::error!(error = %error, "automations: failed to spawn scheduler thread"); + } +} + +fn scheduler_loop(app: AppHandle) { + // Defer the first tick so startup catch-up runs don't compete with the + // boot sequence for the single-writer DB pool. + thread::sleep(Duration::from_secs(STARTUP_DELAY_SEC)); + // automation_id → session_id of a run this process started and believes + // is still streaming. Purely an overlap guard; pruned against + // ActiveStreams each tick, so it self-heals and survives nothing. + let mut in_flight: HashMap<String, String> = HashMap::new(); + loop { + let start = Instant::now(); + if let Err(error) = tick(&app, &mut in_flight) { + tracing::warn!(error = %format!("{error:#}"), "automations: tick failed"); + } + let elapsed = start.elapsed(); + thread::sleep(Duration::from_secs(TICK_INTERVAL_SEC).saturating_sub(elapsed)); + } +} + +fn tick(app: &AppHandle, in_flight: &mut HashMap<String, String>) -> anyhow::Result<()> { + let active_sessions: HashSet<String> = app + .state::<ActiveStreams>() + .snapshot_for_ui() + .into_iter() + .map(|stream| stream.session_id) + .collect(); + in_flight.retain(|_, session_id| active_sessions.contains(session_id)); + + let now = db::current_timestamp()?; + let due = automations::due_automations(&now)?; + if due.is_empty() { + return Ok(()); + } + + let mut changed = false; + for automation in due { + match fire(app, &automation, &active_sessions, in_flight) { + Ok(row_changed) => changed |= row_changed, + Err(error) => tracing::warn!( + automation_id = %automation.id, + error = %format!("{error:#}"), + "automations: firing failed" + ), + } + } + if changed { + crate::ui_sync::publish(app, crate::ui_sync::UiMutationEvent::AutomationsChanged); + } + Ok(()) +} + +/// Claim-then-dispatch one due automation. Returns true when the row changed +/// (claimed, fired, or paused) so the tick knows to publish a UI event. +fn fire( + app: &AppHandle, + automation: &AutomationRecord, + active_sessions: &HashSet<String>, + in_flight: &mut HashMap<String, String>, +) -> anyhow::Result<bool> { + // Overlap guards — skip WITHOUT claiming; the next tick retries. + if in_flight.contains_key(&automation.id) { + return Ok(false); + } + if automation.runs_in == RUNS_IN_CHAT { + if let Some(session_id) = automation.session_id.as_deref() { + if active_sessions.contains(session_id) { + return Ok(false); + } + } + } + + let schedule: Schedule = match serde_json::from_value(automation.schedule.clone()) { + Ok(schedule) => schedule, + Err(error) => { + // A corrupt schedule would stay due forever — pause loudly + // instead of warning every 30s. + tracing::error!( + automation_id = %automation.id, + error = %error, + "automations: unparseable schedule — pausing" + ); + automations::set_automation_status(&automation.id, STATUS_PAUSED, None)?; + return Ok(true); + } + }; + + // Claim before dispatch: CAS on the observed `next_run_at` makes this + // slot fire at most once, and computing the new value from *now* means a + // long-offline automation catches up exactly once. + let new_next = format_utc(next_run_after(&schedule, Utc::now())?); + let claim_now = db::current_timestamp()?; + if !automations::claim_automation( + &automation.id, + &automation.next_run_at, + &new_next, + &claim_now, + )? { + // Someone else won (concurrent edit / second claimer). Not ours. + return Ok(false); + } + + match dispatch::run_automation_now(app, automation) { + Ok(started) => { + tracing::info!( + automation_id = %automation.id, + session_id = %started.session_id, + next_run_at = %new_next, + "automations: run dispatched" + ); + in_flight.insert(automation.id.clone(), started.session_id); + Ok(true) + } + Err(RunError::SessionBusy) => { + // The pre-check raced a user send. Roll the claim back so the + // slot retries on the next tick instead of losing the run. + automations::unclaim_automation( + &automation.id, + &new_next, + &automation.next_run_at, + automation.last_run_at.as_deref(), + )?; + Ok(false) + } + Err(RunError::TargetMissing(reason)) => { + tracing::warn!( + automation_id = %automation.id, + reason, + "automations: target missing — pausing" + ); + automations::set_automation_status(&automation.id, STATUS_PAUSED, None)?; + Ok(true) + } + Err(RunError::Other(error)) => { + // Keep the claim — deliberate no-retry policy. Unclaiming would + // hot-retry a persistent failure every 30s; the next scheduled + // slot is the recovery path. + tracing::error!( + automation_id = %automation.id, + error = %format!("{error:#}"), + "automations: dispatch failed — run skipped until next slot" + ); + Ok(true) + } + } +} diff --git a/src-tauri/src/cli/args.rs b/src-tauri/src/cli/args.rs index 72a990bb6..c6c3f8150 100644 --- a/src-tauri/src/cli/args.rs +++ b/src-tauri/src/cli/args.rs @@ -165,6 +165,11 @@ pub enum Commands { #[command(subcommand)] action: SessionAction, }, + /// Scheduled automations — recurring prompts on an interval. + Automation { + #[command(subcommand)] + action: AutomationAction, + }, /// File listing, reading, writing, staging (editor surface). Files { #[command(subcommand)] @@ -573,6 +578,59 @@ pub enum ReadState { Unread, } +// --------------------------------------------------------------------------- +// automation +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum AutomationAction { + /// List all automations. + List, + /// Show one automation in full (prompt, schedule, next/last run). + Show { automation: String }, + /// Create an automation. + /// + /// Examples: + /// helmor automation create --title "Order monitor" --prompt "check order status" \ + /// --chat <session-id> --hourly + /// helmor automation create --title "Daily digest" --prompt "summarize inbox" \ + /// --workspace my-repo/main --daily 09:00 + /// helmor automation create ... --weekly mon:09:30 + /// helmor automation create ... --every 15m + Create { + #[arg(long)] + title: String, + #[arg(long)] + prompt: String, + /// Bind to an existing chat session (each run appends a turn there). + #[arg(long, conflicts_with = "workspace")] + chat: Option<String>, + /// Bind to a workspace (each run creates a fresh session there). + #[arg(long)] + workspace: Option<String>, + /// Run every hour. + #[arg(long, group = "interval")] + hourly: bool, + /// Run every day at a local wall-clock time (HH:MM). + #[arg(long, group = "interval", value_name = "HH:MM")] + daily: Option<String>, + /// Run weekly: sun|mon|…|sat followed by HH:MM (e.g. mon:09:30). + #[arg(long, group = "interval", value_name = "DAY:HH:MM")] + weekly: Option<String>, + /// Run every N minutes/hours (e.g. 15m, 2h). + #[arg(long, group = "interval", value_name = "Nm|Nh")] + every: Option<String>, + }, + /// Pause an automation (keeps it listed; stops firing). + Pause { automation: String }, + /// Resume a paused automation. Next run is computed from now. + Resume { automation: String }, + /// Delete an automation. The chats it wrote into are untouched. + Delete { automation: String }, + /// Fire an automation on the app's next scheduler tick (≤30s). + Run { automation: String }, +} + // --------------------------------------------------------------------------- // session // --------------------------------------------------------------------------- diff --git a/src-tauri/src/cli/automation.rs b/src-tauri/src/cli/automation.rs new file mode 100644 index 000000000..9db7e0314 --- /dev/null +++ b/src-tauri/src/cli/automation.rs @@ -0,0 +1,292 @@ +//! `helmor automation` — scheduled automations (recurring prompts). +//! +//! Mutations write the shared SQLite DB directly and nudge a running app via +//! `AutomationsChanged`. Note `run`: the CLI never dispatches a turn itself — +//! it marks the automation due (`next_run_at = now`) and lets the app's +//! scheduler tick (≤30s) fire it, so runs always stream through the app's +//! shared sidecar without stealing UI focus. With the app closed, the due +//! row simply fires on next launch — same catch-up path as a missed slot. + +use anyhow::{bail, Context, Result}; + +use crate::automations::ops; +use crate::automations::schedule::Schedule; +use crate::models::automations::{ + self, AutomationRecord, RUNS_IN_CHAT, RUNS_IN_WORKSPACE, STATUS_ACTIVE, STATUS_PAUSED, +}; +use crate::service; +use crate::ui_sync::UiMutationEvent; + +use super::args::{AutomationAction, Cli}; +use super::notify_ui_event; +use super::output; + +pub fn dispatch(action: &AutomationAction, cli: &Cli) -> Result<()> { + match action { + AutomationAction::List => list(cli), + AutomationAction::Show { automation } => show(automation, cli), + AutomationAction::Create { + title, + prompt, + chat, + workspace, + hourly, + daily, + weekly, + every, + } => create( + title, + prompt, + chat.as_deref(), + workspace.as_deref(), + *hourly, + daily.as_deref(), + weekly.as_deref(), + every.as_deref(), + cli, + ), + AutomationAction::Pause { automation } => set_status(automation, STATUS_PAUSED, cli), + AutomationAction::Resume { automation } => set_status(automation, STATUS_ACTIVE, cli), + AutomationAction::Delete { automation } => delete(automation, cli), + AutomationAction::Run { automation } => run(automation, cli), + } +} + +fn list(cli: &Cli) -> Result<()> { + let records = automations::list_automations()?; + output::print(cli, &records, |records| { + if records.is_empty() { + return "No automations. Create one with `helmor automation create`.".to_string(); + } + records + .iter() + .map(|r| { + format!( + "{} [{}] {} — {} · next {}", + r.id, + r.status, + r.title, + schedule_summary(r), + r.next_run_at + ) + }) + .collect::<Vec<_>>() + .join("\n") + }) +} + +fn show(automation: &str, cli: &Cli) -> Result<()> { + let record = get(automation)?; + output::print(cli, &record, |r| { + format!( + "{}\n id: {}\n status: {}\n runs in: {}\n schedule: {}\n next run: {}\n last run: {}\n prompt:\n{}", + r.title, + r.id, + r.status, + match r.runs_in.as_str() { + RUNS_IN_CHAT => format!("chat {}", r.session_id.as_deref().unwrap_or("?")), + _ => format!("workspace {}", r.workspace_id.as_deref().unwrap_or("?")), + }, + schedule_summary(r), + r.next_run_at, + r.last_run_at.as_deref().unwrap_or("never"), + indent(&r.prompt), + ) + }) +} + +#[allow(clippy::too_many_arguments)] +fn create( + title: &str, + prompt: &str, + chat: Option<&str>, + workspace: Option<&str>, + hourly: bool, + daily: Option<&str>, + weekly: Option<&str>, + every: Option<&str>, + cli: &Cli, +) -> Result<()> { + let schedule = parse_schedule_flags(hourly, daily, weekly, every)?; + let (runs_in, session_id, workspace_id) = match (chat, workspace) { + (Some(session), None) => (RUNS_IN_CHAT, Some(session.to_string()), None), + (None, Some(reference)) => { + let workspace_id = service::resolve_workspace_ref(reference)?; + (RUNS_IN_WORKSPACE, None, Some(workspace_id)) + } + _ => bail!("Pass exactly one of --chat <session-id> or --workspace <workspace>"), + }; + + let record = ops::create_automation(ops::CreateAutomationInput { + title: title.to_string(), + prompt: prompt.to_string(), + runs_in: runs_in.to_string(), + session_id, + workspace_id, + schedule: serde_json::to_value(&schedule)?, + })?; + notify_ui_event(UiMutationEvent::AutomationsChanged); + output::print_id(cli, "automation_id", &record.id); + Ok(()) +} + +fn set_status(automation: &str, status: &str, cli: &Cli) -> Result<()> { + let record = get(automation)?; + ops::set_status(&record.id, status)?; + notify_ui_event(UiMutationEvent::AutomationsChanged); + output::print_ok( + cli, + &format!("Automation {} is now {status}.", record.title), + ); + Ok(()) +} + +fn delete(automation: &str, cli: &Cli) -> Result<()> { + let record = get(automation)?; + automations::delete_automation(&record.id)?; + notify_ui_event(UiMutationEvent::AutomationsChanged); + output::print_ok(cli, &format!("Deleted automation {}.", record.title)); + Ok(()) +} + +/// Mark the automation due now. The app's scheduler tick claims and fires it +/// (≤30s) without any UI focus change; if the app isn't running, it fires on +/// next launch via the normal catch-up path. +fn run(automation: &str, cli: &Cli) -> Result<()> { + let record = get(automation)?; + if record.status != STATUS_ACTIVE { + bail!( + "Automation {} is paused — `helmor automation resume {}` first.", + record.title, + record.id + ); + } + let now = crate::models::db::current_timestamp()?; + automations::set_automation_status(&record.id, STATUS_ACTIVE, Some(&now))?; + notify_ui_event(UiMutationEvent::AutomationsChanged); + let human = if service::is_app_running() { + format!( + "Automation {} will run within ~30s (next scheduler tick).", + record.title + ) + } else { + format!( + "Automation {} is due now — Helmor isn't running, so it runs on next launch.", + record.title + ) + }; + output::print_ok(cli, &human); + Ok(()) +} + +fn get(reference: &str) -> Result<AutomationRecord> { + automations::get_automation(reference)? + .with_context(|| format!("Automation {reference} not found — try `helmor automation list`")) +} + +fn schedule_summary(record: &AutomationRecord) -> String { + serde_json::from_value::<Schedule>(record.schedule.clone()) + .map(|s| s.summary()) + .unwrap_or_else(|_| "invalid schedule".to_string()) +} + +fn parse_schedule_flags( + hourly: bool, + daily: Option<&str>, + weekly: Option<&str>, + every: Option<&str>, +) -> Result<Schedule> { + if hourly { + return Ok(Schedule::Hourly); + } + if let Some(time) = daily { + return Ok(Schedule::Daily { + time: time.to_string(), + }); + } + if let Some(spec) = weekly { + // "mon:09:30" — day prefix, rest is HH:MM. + let (day, time) = spec + .split_once(':') + .context("Invalid --weekly — expected DAY:HH:MM (e.g. mon:09:30)")?; + let weekday = match day.to_ascii_lowercase().as_str() { + "sun" | "sunday" => 0, + "mon" | "monday" => 1, + "tue" | "tuesday" => 2, + "wed" | "wednesday" => 3, + "thu" | "thursday" => 4, + "fri" | "friday" => 5, + "sat" | "saturday" => 6, + other => bail!("Invalid weekday {other:?} — expected sun|mon|tue|wed|thu|fri|sat"), + }; + return Ok(Schedule::Weekly { + weekday, + time: time.to_string(), + }); + } + if let Some(spec) = every { + let spec = spec.trim().to_ascii_lowercase(); + let (digits, unit) = spec.split_at(spec.len().saturating_sub(1)); + let amount: u32 = digits + .parse() + .with_context(|| format!("Invalid --every {spec:?} — expected e.g. 15m or 2h"))?; + let unit = match unit { + "m" => crate::automations::schedule::EveryUnit::Minutes, + "h" => crate::automations::schedule::EveryUnit::Hours, + _ => bail!("Invalid --every {spec:?} — unit must be m or h"), + }; + return Ok(Schedule::Every { amount, unit }); + } + bail!("Pass exactly one of --hourly, --daily HH:MM, --weekly DAY:HH:MM, --every Nm|Nh") +} + +fn indent(text: &str) -> String { + text.lines() + .map(|line| format!(" {line}")) + .collect::<Vec<_>>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::automations::schedule::EveryUnit; + + #[test] + fn schedule_flags_parse() { + assert_eq!( + parse_schedule_flags(true, None, None, None).unwrap(), + Schedule::Hourly + ); + assert_eq!( + parse_schedule_flags(false, Some("09:00"), None, None).unwrap(), + Schedule::Daily { + time: "09:00".into() + } + ); + assert_eq!( + parse_schedule_flags(false, None, Some("mon:09:30"), None).unwrap(), + Schedule::Weekly { + weekday: 1, + time: "09:30".into() + } + ); + assert_eq!( + parse_schedule_flags(false, None, None, Some("15m")).unwrap(), + Schedule::Every { + amount: 15, + unit: EveryUnit::Minutes + } + ); + assert_eq!( + parse_schedule_flags(false, None, None, Some("2h")).unwrap(), + Schedule::Every { + amount: 2, + unit: EveryUnit::Hours + } + ); + assert!(parse_schedule_flags(false, None, None, None).is_err()); + assert!(parse_schedule_flags(false, None, Some("noday"), None).is_err()); + assert!(parse_schedule_flags(false, None, None, Some("15x")).is_err()); + } +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index d865e4610..7445ec15c 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -12,6 +12,7 @@ //! / human formatting) and `refs` (UUID / name disambiguation). pub mod args; +mod automation; mod conductor; mod data; mod files; @@ -153,6 +154,7 @@ fn dispatch(cli: &Cli) -> Result<()> { C::Repo { action } => repo::dispatch(action, cli), C::Workspace { action } => workspace::dispatch(action, cli), C::Session { action } => session::dispatch(action, cli), + C::Automation { action } => automation::dispatch(action, cli), C::Files { action } => files::dispatch(action, cli), C::Send(opts) => send::send(opts, cli), C::Models { action } => send::dispatch_models(action, cli), diff --git a/src-tauri/src/commands/automation_commands.rs b/src-tauri/src/commands/automation_commands.rs new file mode 100644 index 000000000..b79c08529 --- /dev/null +++ b/src-tauri/src/commands/automation_commands.rs @@ -0,0 +1,113 @@ +//! Tauri commands for automations — IPC glue over `automations::ops`. +//! Every mutation publishes `UiMutationEvent::AutomationsChanged` so the +//! frontend invalidates the `automations` query through the global bridge. + +use serde::Deserialize; +use tauri::AppHandle; + +use super::common::{run_blocking, CmdResult}; +use crate::automations::ops; +use crate::models::automations::AutomationRecord; +use crate::ui_sync::{self, UiMutationEvent}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAutomationRequest { + pub title: String, + pub prompt: String, + pub runs_in: String, + pub session_id: Option<String>, + pub workspace_id: Option<String>, + pub schedule: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAutomationRequest { + pub id: String, + pub title: Option<String>, + pub prompt: Option<String>, + pub runs_in: Option<String>, + pub session_id: Option<String>, + pub workspace_id: Option<String>, + pub schedule: Option<serde_json::Value>, +} + +#[tauri::command] +pub async fn list_automations() -> CmdResult<Vec<AutomationRecord>> { + run_blocking(crate::models::automations::list_automations).await +} + +#[tauri::command] +pub async fn create_automation( + app: AppHandle, + request: CreateAutomationRequest, +) -> CmdResult<AutomationRecord> { + let record = run_blocking(move || { + ops::create_automation(ops::CreateAutomationInput { + title: request.title, + prompt: request.prompt, + runs_in: request.runs_in, + session_id: request.session_id, + workspace_id: request.workspace_id, + schedule: request.schedule, + }) + }) + .await?; + ui_sync::publish(&app, UiMutationEvent::AutomationsChanged); + Ok(record) +} + +#[tauri::command] +pub async fn update_automation( + app: AppHandle, + request: UpdateAutomationRequest, +) -> CmdResult<AutomationRecord> { + let record = run_blocking(move || { + ops::update_automation( + &request.id, + ops::UpdateAutomationInput { + title: request.title, + prompt: request.prompt, + runs_in: request.runs_in, + session_id: request.session_id, + workspace_id: request.workspace_id, + schedule: request.schedule, + }, + ) + }) + .await?; + ui_sync::publish(&app, UiMutationEvent::AutomationsChanged); + Ok(record) +} + +#[tauri::command] +pub async fn delete_automation(app: AppHandle, automation_id: String) -> CmdResult<()> { + run_blocking(move || crate::models::automations::delete_automation(&automation_id)).await?; + ui_sync::publish(&app, UiMutationEvent::AutomationsChanged); + Ok(()) +} + +/// Pause (`paused`) or resume (`active`). Resume recomputes `next_run_at` +/// from now — no immediate fire. +#[tauri::command] +pub async fn set_automation_status( + app: AppHandle, + automation_id: String, + status: String, +) -> CmdResult<AutomationRecord> { + let record = run_blocking(move || ops::set_status(&automation_id, &status)).await?; + ui_sync::publish(&app, UiMutationEvent::AutomationsChanged); + Ok(record) +} + +/// Dispatch immediately; records `last_run_at` only — the schedule is +/// untouched. Returns the session id the run landed in so the frontend can +/// offer a "view chat" jump. +#[tauri::command] +pub async fn run_automation_now(app: AppHandle, automation_id: String) -> CmdResult<String> { + let handle = app.clone(); + let session_id = run_blocking(move || ops::run_now(&handle, &automation_id)).await?; + ui_sync::publish(&app, UiMutationEvent::AutomationsChanged); + Ok(session_id) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 543776bd3..21e7986ee 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod automation_commands; mod common; pub(crate) mod companion_commands; pub(crate) mod conductor_commands; diff --git a/src-tauri/src/commands/session_commands.rs b/src-tauri/src/commands/session_commands.rs index 9fa980439..69d32227e 100644 --- a/src-tauri/src/commands/session_commands.rs +++ b/src-tauri/src/commands/session_commands.rs @@ -64,6 +64,7 @@ pub async fn create_session( seed_session_id: seed_session_id.as_deref(), session_kind: session_kind.as_deref(), agent_type: agent_type.as_deref(), + ..Default::default() }, ) }) @@ -86,8 +87,11 @@ pub async fn unhide_session(session_id: String) -> CmdResult<()> { } #[tauri::command] -pub async fn delete_session(session_id: String) -> CmdResult<()> { - run_blocking(move || sessions::delete_session(&session_id)).await +pub async fn delete_session(app: tauri::AppHandle, session_id: String) -> CmdResult<()> { + run_blocking(move || sessions::delete_session(&session_id)).await?; + // The session delete cascades to its chat automations; refresh that view. + crate::ui_sync::publish(&app, crate::ui_sync::UiMutationEvent::AutomationsChanged); + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/companion/rpc.rs b/src-tauri/src/companion/rpc.rs index 6ddc303c2..6c339e449 100644 --- a/src-tauri/src/companion/rpc.rs +++ b/src-tauri/src/companion/rpc.rs @@ -66,9 +66,14 @@ async fn dispatch( crate::commands::workspace_commands::create_and_checkout_branch(arg_string(&args, "repoId")?, arg_string(&args, "branch")?).await?; Ok(Value::Null) } + "create_automation" => to_value(crate::commands::automation_commands::create_automation(app.clone(), arg_json(&args, "request")?).await?), "create_repo_run_action" => to_value(crate::commands::script_commands::create_repo_run_action(app.clone(), arg_string(&args, "repoId")?, arg_string(&args, "name")?, arg_string(&args, "command")?, arg_string(&args, "mode")?, arg_opt_string(&args, "stopCommand")).await?), "create_session" => to_value(crate::commands::session_commands::create_session(arg_string(&args, "workspaceId")?, arg_opt_json(&args, "actionKind")?, arg_opt_string(&args, "permissionMode"), arg_opt_string(&args, "model"), arg_opt_string(&args, "effortLevel"), arg_opt_bool(&args, "fastMode"), arg_opt_string(&args, "seedSessionId"), arg_opt_string(&args, "sessionKind"), arg_opt_string(&args, "agentType")).await?), "create_workspace_from_repo" => to_value(crate::commands::workspace_commands::create_workspace_from_repo(app.clone(), arg_string(&args, "repoId")?).await?), + "delete_automation" => { + crate::commands::automation_commands::delete_automation(app.clone(), arg_string(&args, "automationId")?).await?; + Ok(Value::Null) + } "delete_query_cache" => { crate::commands::system_commands::delete_query_cache(arg_string(&args, "key")?).await?; Ok(Value::Null) @@ -82,7 +87,7 @@ async fn dispatch( Ok(Value::Null) } "delete_session" => { - crate::commands::session_commands::delete_session(arg_string(&args, "sessionId")?).await?; + crate::commands::session_commands::delete_session(app.clone(), arg_string(&args, "sessionId")?).await?; Ok(Value::Null) } "detect_installed_editors" => to_value(crate::commands::editors::detect_installed_editors().await?), @@ -160,6 +165,7 @@ async fn dispatch( .await?, ), "list_archived_workspaces" => to_value(crate::commands::workspace_commands::list_archived_workspaces().await?), + "list_automations" => to_value(crate::commands::automation_commands::list_automations().await?), "list_branches_for_local_picker" => to_value(crate::commands::workspace_commands::list_branches_for_local_picker(arg_string(&args, "repoId")?).await?), "list_branches_for_workspace_picker" => to_value(crate::commands::workspace_commands::list_branches_for_workspace_picker(arg_string(&args, "repoId")?).await?), "list_cursor_models" => to_value(crate::agents::list_cursor_models(app.state::<crate::sidecar::ManagedSidecar>(), arg_opt_string(&args, "apiKey")).await?), @@ -267,6 +273,7 @@ async fn dispatch( } "restore_workspace" => to_value(crate::commands::workspace_commands::restore_workspace(app.clone(), arg_string(&args, "workspaceId")?, arg_opt_string(&args, "targetBranchOverride")).await?), "retry_repo_forge_binding" => to_value(crate::commands::repository_commands::retry_repo_forge_binding(app.clone(), arg_string(&args, "repoId")?).await?), + "run_automation_now" => to_value(crate::commands::automation_commands::run_automation_now(app.clone(), arg_string(&args, "automationId")?).await?), "save_auto_close_action_kinds" => { crate::commands::settings_commands::save_auto_close_action_kinds(arg_json(&args, "kinds")?).await?; Ok(Value::Null) @@ -280,6 +287,7 @@ async fn dispatch( crate::commands::system_commands::save_text_file_as(arg_string(&args, "path")?, arg_string(&args, "contents")?).await?; Ok(Value::Null) } + "set_automation_status" => to_value(crate::commands::automation_commands::set_automation_status(app.clone(), arg_string(&args, "automationId")?, arg_string(&args, "status")?).await?), "set_session_context_usage" => { crate::commands::session_commands::set_session_context_usage(app.clone(), arg_string(&args, "sessionId")?, arg_string(&args, "meta")?).await?; Ok(Value::Null) @@ -357,6 +365,7 @@ async fn dispatch( crate::commands::settings_commands::update_app_settings(app.state::<crate::sidecar::ManagedSidecar>(), arg_json(&args, "settingsMap")?).await?; Ok(Value::Null) } + "update_automation" => to_value(crate::commands::automation_commands::update_automation(app.clone(), arg_json(&args, "request")?).await?), "update_intended_target_branch" => to_value(crate::commands::workspace_commands::update_intended_target_branch(app.clone(), arg_string(&args, "workspaceId")?, arg_string(&args, "targetBranch")?).await?), "update_repo_auto_run_setup" => { crate::commands::repository_commands::update_repo_auto_run_setup(arg_string(&args, "repoId")?, arg_bool(&args, "enabled")?).await?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e63511e5e..4dc4a2d79 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod agents; +pub mod automations; pub mod cli; pub(crate) mod codex_config; pub(crate) mod commands; @@ -398,6 +399,11 @@ pub fn run() { tracing::error!(error = %error, "Failed to start UI sync listener"); } + // Automations: stateless 30s poll over `automations.next_run_at`. + // Overdue rows (app was closed / machine slept) catch up once on + // the first tick after the startup delay. + automations::scheduler::spawn_scheduler(app.handle().clone()); + // Mobile browser companion (experimental, opt-in via env). Starts a // loopback-bound HTTP/SSE server that mirrors the IPC surface so the // same frontend can be served to a phone browser. Default app @@ -503,6 +509,12 @@ pub fn run() { agents::list_slash_commands, agents::prewarm_slash_commands_for_workspace, agents::prewarm_slash_commands_for_repo, + commands::automation_commands::list_automations, + commands::automation_commands::create_automation, + commands::automation_commands::update_automation, + commands::automation_commands::delete_automation, + commands::automation_commands::set_automation_status, + commands::automation_commands::run_automation_now, commands::workspace_commands::prepare_archive_workspace, commands::workspace_commands::start_archive_workspace, commands::workspace_commands::validate_archive_workspace, diff --git a/src-tauri/src/models/automations.rs b/src-tauri/src/models/automations.rs new file mode 100644 index 000000000..b8dd0f86a --- /dev/null +++ b/src-tauri/src/models/automations.rs @@ -0,0 +1,394 @@ +//! Persistence for scheduled automations. +//! +//! SQLite is the single source of truth for scheduling: `next_run_at` and +//! `status` live here, and the in-process scheduler is a stateless poll loop +//! over this table. The two scheduler primitives (`due_automations`, +//! `claim_automation`) implement claim-before-dispatch: a CAS-style UPDATE on +//! `next_run_at` guarantees at-most-once firing per slot across restarts, +//! crashes, and racing ticks. Timestamps use the `db::current_timestamp()` +//! RFC3339-UTC-millis format, which orders chronologically as plain strings. + +use anyhow::{Context, Result}; +use rusqlite::params; +use serde::Serialize; + +use crate::models::db; + +pub const RUNS_IN_CHAT: &str = "chat"; +pub const RUNS_IN_WORKSPACE: &str = "workspace"; +pub const STATUS_ACTIVE: &str = "active"; +pub const STATUS_PAUSED: &str = "paused"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutomationRecord { + pub id: String, + pub title: String, + pub prompt: String, + /// `chat` (append runs to the bound session) or `workspace` (create a new + /// session per run in the bound workspace). + pub runs_in: String, + pub session_id: Option<String>, + pub workspace_id: Option<String>, + /// Schedule spec as JSON (see `automations::schedule::Schedule`). Stored + /// opaque here so the persistence layer stays independent of domain types. + pub schedule: serde_json::Value, + pub status: String, + pub next_run_at: String, + pub last_run_at: Option<String>, + pub created_at: String, + pub updated_at: String, +} + +pub struct NewAutomation<'a> { + pub title: &'a str, + pub prompt: &'a str, + pub runs_in: &'a str, + pub session_id: Option<&'a str>, + pub workspace_id: Option<&'a str>, + pub schedule: &'a serde_json::Value, + pub next_run_at: &'a str, +} + +const SELECT_COLUMNS: &str = "id, title, prompt, runs_in, session_id, workspace_id, schedule, \ + status, next_run_at, last_run_at, created_at, updated_at"; + +fn record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(AutomationRecord, String)> { + let schedule_raw: String = row.get(6)?; + Ok(( + AutomationRecord { + id: row.get(0)?, + title: row.get(1)?, + prompt: row.get(2)?, + runs_in: row.get(3)?, + session_id: row.get(4)?, + workspace_id: row.get(5)?, + schedule: serde_json::Value::Null, + status: row.get(7)?, + next_run_at: row.get(8)?, + last_run_at: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }, + schedule_raw, + )) +} + +fn parse_schedule( + (mut record, schedule_raw): (AutomationRecord, String), +) -> Result<AutomationRecord> { + record.schedule = serde_json::from_str(&schedule_raw).with_context(|| { + format!( + "automation {} has unparseable schedule JSON: {schedule_raw}", + record.id + ) + })?; + Ok(record) +} + +/// List all automations, newest first. +pub fn list_automations() -> Result<Vec<AutomationRecord>> { + let conn = db::read_conn()?; + let mut stmt = conn.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM automations ORDER BY created_at DESC" + ))?; + let rows = stmt + .query_map([], record_from_row)? + .collect::<rusqlite::Result<Vec<_>>>()?; + rows.into_iter().map(parse_schedule).collect() +} + +pub fn get_automation(id: &str) -> Result<Option<AutomationRecord>> { + let conn = db::read_conn()?; + let mut stmt = conn.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM automations WHERE id = ?1" + ))?; + let mut rows = stmt + .query_map(params![id], record_from_row)? + .collect::<rusqlite::Result<Vec<_>>>()?; + match rows.pop() { + Some(raw) => Ok(Some(parse_schedule(raw)?)), + None => Ok(None), + } +} + +pub fn insert_automation(new: &NewAutomation<'_>) -> Result<AutomationRecord> { + let id = uuid::Uuid::new_v4().to_string(); + let now = db::current_timestamp()?; + let schedule_raw = new.schedule.to_string(); + let conn = db::write_conn()?; + conn.execute( + "INSERT INTO automations \ + (id, title, prompt, runs_in, session_id, workspace_id, schedule, status, next_run_at, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)", + params![ + id, + new.title, + new.prompt, + new.runs_in, + new.session_id, + new.workspace_id, + schedule_raw, + STATUS_ACTIVE, + new.next_run_at, + now, + ], + )?; + Ok(AutomationRecord { + id, + title: new.title.to_string(), + prompt: new.prompt.to_string(), + runs_in: new.runs_in.to_string(), + session_id: new.session_id.map(str::to_string), + workspace_id: new.workspace_id.map(str::to_string), + schedule: new.schedule.clone(), + status: STATUS_ACTIVE.to_string(), + next_run_at: new.next_run_at.to_string(), + last_run_at: None, + created_at: now.clone(), + updated_at: now, + }) +} + +/// Write back every editable field of a (read-modify-write) record. +/// Callers recompute `next_run_at` before saving when the schedule changed. +pub fn update_automation_record(record: &AutomationRecord) -> Result<()> { + let now = db::current_timestamp()?; + let conn = db::write_conn()?; + conn.execute( + "UPDATE automations SET title = ?2, prompt = ?3, runs_in = ?4, session_id = ?5, \ + workspace_id = ?6, schedule = ?7, status = ?8, next_run_at = ?9, updated_at = ?10 \ + WHERE id = ?1", + params![ + record.id, + record.title, + record.prompt, + record.runs_in, + record.session_id, + record.workspace_id, + record.schedule.to_string(), + record.status, + record.next_run_at, + now, + ], + )?; + Ok(()) +} + +/// Pause/resume. Resume passes a freshly computed `next_run_at` (from now) so +/// a long-paused automation never fires immediately on resume. +pub fn set_automation_status(id: &str, status: &str, next_run_at: Option<&str>) -> Result<()> { + let now = db::current_timestamp()?; + let conn = db::write_conn()?; + match next_run_at { + Some(next) => conn.execute( + "UPDATE automations SET status = ?2, next_run_at = ?3, updated_at = ?4 WHERE id = ?1", + params![id, status, next, now], + )?, + None => conn.execute( + "UPDATE automations SET status = ?2, updated_at = ?3 WHERE id = ?1", + params![id, status, now], + )?, + }; + Ok(()) +} + +/// Record a manual "Run now" without touching the schedule. +pub fn set_last_run_at(id: &str, last_run_at: &str) -> Result<()> { + let conn = db::write_conn()?; + conn.execute( + "UPDATE automations SET last_run_at = ?2, updated_at = ?2 WHERE id = ?1", + params![id, last_run_at], + )?; + Ok(()) +} + +pub fn delete_automation(id: &str) -> Result<()> { + let conn = db::write_conn()?; + conn.execute("DELETE FROM automations WHERE id = ?1", params![id])?; + Ok(()) +} + +/// Cascade-delete automations bound to a session. Runs on the caller's +/// connection/transaction (the writer pool is single-slot, so a session-delete +/// transaction must drop its automations inline, not via a nested `write_conn`). +/// `automations` has no FK because `foreign_keys` is OFF app-wide. +pub fn delete_automations_for_session( + conn: &rusqlite::Connection, + session_id: &str, +) -> rusqlite::Result<usize> { + conn.execute( + "DELETE FROM automations WHERE session_id = ?1", + params![session_id], + ) +} + +/// Cascade-delete automations bound to a workspace — both `workspace`-mode rows +/// and `chat`-mode rows whose session lives in that workspace. Must run BEFORE +/// the workspace's `sessions` rows are deleted (the subquery needs them). +pub fn delete_automations_for_workspace( + conn: &rusqlite::Connection, + workspace_id: &str, +) -> rusqlite::Result<usize> { + conn.execute( + "DELETE FROM automations \ + WHERE workspace_id = ?1 \ + OR session_id IN (SELECT id FROM sessions WHERE workspace_id = ?1)", + params![workspace_id], + ) +} + +// ── Scheduler primitives ──────────────────────────────────────────────────── + +/// Active automations whose `next_run_at` is due at `now`, oldest first. +pub fn due_automations(now: &str) -> Result<Vec<AutomationRecord>> { + let conn = db::read_conn()?; + let mut stmt = conn.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM automations \ + WHERE status = ?1 AND next_run_at <= ?2 ORDER BY next_run_at ASC" + ))?; + let rows = stmt + .query_map(params![STATUS_ACTIVE, now], record_from_row)? + .collect::<rusqlite::Result<Vec<_>>>()?; + rows.into_iter().map(parse_schedule).collect() +} + +/// Claim a due slot: CAS on `next_run_at` so exactly one claimer wins, even +/// across racing ticks or instances. Sets `last_run_at = now` as part of the +/// claim. Returns false when someone else already claimed (or the automation +/// was edited/paused since it was read). +pub fn claim_automation( + id: &str, + old_next_run_at: &str, + new_next_run_at: &str, + now: &str, +) -> Result<bool> { + let conn = db::write_conn()?; + let changed = conn.execute( + "UPDATE automations SET next_run_at = ?3, last_run_at = ?4, updated_at = ?4 \ + WHERE id = ?1 AND next_run_at = ?2 AND status = ?5", + params![id, old_next_run_at, new_next_run_at, now, STATUS_ACTIVE], + )?; + Ok(changed == 1) +} + +/// Roll back a claim whose dispatch was rejected (e.g. the bound session was +/// concurrently busy). CAS-guarded on the claimed value so a concurrent edit +/// is never stomped; `last_run_at` is restored because the run never happened. +pub fn unclaim_automation( + id: &str, + claimed_next_run_at: &str, + previous_next_run_at: &str, + previous_last_run_at: Option<&str>, +) -> Result<()> { + let now = db::current_timestamp()?; + let conn = db::write_conn()?; + conn.execute( + "UPDATE automations SET next_run_at = ?3, last_run_at = ?4, updated_at = ?5 \ + WHERE id = ?1 AND next_run_at = ?2", + params![ + id, + claimed_next_run_at, + previous_next_run_at, + previous_last_run_at, + now + ], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn insert_sample(title: &str, next_run_at: &str) -> AutomationRecord { + insert_automation(&NewAutomation { + title, + prompt: "check the thing", + runs_in: RUNS_IN_CHAT, + session_id: Some("session-1"), + workspace_id: None, + schedule: &serde_json::json!({"kind": "hourly"}), + next_run_at, + }) + .unwrap() + } + + #[test] + fn crud_roundtrip() { + let _env = crate::testkit::TestEnv::new("automations-crud"); + + let created = insert_sample("Order monitor", "2026-01-01T00:00:00.000Z"); + let listed = list_automations().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, created.id); + assert_eq!(listed[0].schedule, serde_json::json!({"kind": "hourly"})); + assert_eq!(listed[0].status, STATUS_ACTIVE); + + let mut record = get_automation(&created.id).unwrap().unwrap(); + record.title = "Renamed".into(); + record.schedule = serde_json::json!({"kind": "daily", "time": "09:00"}); + update_automation_record(&record).unwrap(); + let reloaded = get_automation(&created.id).unwrap().unwrap(); + assert_eq!(reloaded.title, "Renamed"); + assert_eq!(reloaded.schedule["kind"], "daily"); + + delete_automation(&created.id).unwrap(); + assert!(get_automation(&created.id).unwrap().is_none()); + } + + #[test] + fn due_query_excludes_paused_and_future() { + let _env = crate::testkit::TestEnv::new("automations-due"); + + let due = insert_sample("due", "2020-01-01T00:00:00.000Z"); + let paused = insert_sample("paused", "2020-01-01T00:00:00.000Z"); + set_automation_status(&paused.id, STATUS_PAUSED, None).unwrap(); + insert_sample("future", "2999-01-01T00:00:00.000Z"); + + let now = db::current_timestamp().unwrap(); + let found = due_automations(&now).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, due.id); + } + + #[test] + fn claim_is_exactly_once_and_unclaim_restores() { + let _env = crate::testkit::TestEnv::new("automations-claim"); + + let old_next = "2020-01-01T00:00:00.000Z"; + let record = insert_sample("claimable", old_next); + let now = db::current_timestamp().unwrap(); + let new_next = "2999-01-01T00:00:00.000Z"; + + // Two racing claims with the same observed value: exactly one wins. + assert!(claim_automation(&record.id, old_next, new_next, &now).unwrap()); + assert!(!claim_automation(&record.id, old_next, new_next, &now).unwrap()); + + let claimed = get_automation(&record.id).unwrap().unwrap(); + assert_eq!(claimed.next_run_at, new_next); + assert_eq!(claimed.last_run_at.as_deref(), Some(now.as_str())); + + // Rolling back a rejected dispatch restores both fields. + unclaim_automation(&record.id, new_next, old_next, None).unwrap(); + let restored = get_automation(&record.id).unwrap().unwrap(); + assert_eq!(restored.next_run_at, old_next); + assert_eq!(restored.last_run_at, None); + + // Unclaim is CAS-guarded: a stale rollback never stomps a newer value. + unclaim_automation(&record.id, new_next, "1999-01-01T00:00:00.000Z", None).unwrap(); + let unchanged = get_automation(&record.id).unwrap().unwrap(); + assert_eq!(unchanged.next_run_at, old_next); + } + + #[test] + fn paused_claim_is_rejected() { + let _env = crate::testkit::TestEnv::new("automations-claim-paused"); + + let old_next = "2020-01-01T00:00:00.000Z"; + let record = insert_sample("paused-claim", old_next); + set_automation_status(&record.id, STATUS_PAUSED, None).unwrap(); + let now = db::current_timestamp().unwrap(); + assert!(!claim_automation(&record.id, old_next, "2999-01-01T00:00:00.000Z", &now).unwrap()); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 4f241b92b..1291b9473 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod automations; pub mod db; pub mod paired_devices; pub mod repos; diff --git a/src-tauri/src/models/sessions.rs b/src-tauri/src/models/sessions.rs index 5b7374793..1d2de9890 100644 --- a/src-tauri/src/models/sessions.rs +++ b/src-tauri/src/models/sessions.rs @@ -549,6 +549,10 @@ pub struct CreateSessionOverrides<'a> { /// Pin `agent_type` at creation. Terminal sessions store their preset CLI /// here; GUI sessions leave it null until the first send sets it. pub agent_type: Option<&'a str>, + /// Skip repointing the workspace's `active_session_id` to the new session. + /// Background creators (automation `workspace`-mode runs) set this so a + /// scheduled run never steals the focus the user left on another session. + pub skip_active_session: bool, } pub fn create_session( @@ -623,16 +627,21 @@ pub fn create_session( ) .context("Failed to create session")?; - // Set as active session on the workspace - let updated_rows = transaction - .execute( - "UPDATE workspaces SET active_session_id = ?1 WHERE id = ?2", - (&session_id, workspace_id), - ) - .context("Failed to set active session")?; + // Set as active session on the workspace — unless the caller opted out + // (background automation runs keep the user's current selection). + if !overrides.skip_active_session { + let updated_rows = transaction + .execute( + "UPDATE workspaces SET active_session_id = ?1 WHERE id = ?2", + (&session_id, workspace_id), + ) + .context("Failed to set active session")?; - if updated_rows != 1 { - bail!("Active session update affected {updated_rows} rows for workspace {workspace_id}"); + if updated_rows != 1 { + bail!( + "Active session update affected {updated_rows} rows for workspace {workspace_id}" + ); + } } transaction @@ -655,6 +664,28 @@ pub fn get_session_model(session_id: &str) -> Result<Option<String>> { Ok(model.filter(|s| !s.is_empty())) } +/// (workspace_id, permission_mode) for dispatching a background turn into an +/// existing session (automations). `Ok(None)` when the session row is gone — +/// callers treat that as "target missing", not an error. +pub fn get_session_workspace_and_permission( + session_id: &str, +) -> Result<Option<(Option<String>, String)>> { + let conn = db::read_conn()?; + conn.query_row( + "SELECT workspace_id, permission_mode FROM sessions WHERE id = ?1", + [session_id], + |row| { + Ok(( + row.get::<_, Option<String>>(0)?, + row.get::<_, Option<String>>(1)? + .unwrap_or_else(|| "default".to_string()), + )) + }, + ) + .optional() + .with_context(|| format!("Failed to read workspace+permission for session {session_id}")) +} + /// (model, agent_type) for a session — provider hint for `resolve_model` /// when ids like `"default"` are ambiguous. pub fn get_session_model_and_provider( @@ -906,6 +937,10 @@ pub fn delete_session(session_id: &str) -> Result<()> { [session_id], ) .context("Failed to delete session plan state")?; + // Drop chat automations bound to this session (no FK; single-writer pool + // means we cascade inline within this transaction). + crate::models::automations::delete_automations_for_session(&transaction, session_id) + .context("Failed to delete session automations")?; transaction .execute("DELETE FROM sessions WHERE id = ?1", [session_id]) .context("Failed to delete session")?; diff --git a/src-tauri/src/models/workspaces.rs b/src-tauri/src/models/workspaces.rs index 20664817b..0a8c844b5 100644 --- a/src-tauri/src/models/workspaces.rs +++ b/src-tauri/src/models/workspaces.rs @@ -574,6 +574,9 @@ pub(crate) fn delete_workspace_and_session_rows(workspace_id: &str) -> Result<() [workspace_id], ) .context("Failed to delete create-flow session plan state")?; + // Cascade automations before sessions (the cascade subquery reads them). + crate::models::automations::delete_automations_for_workspace(&transaction, workspace_id) + .context("Failed to delete create-flow workspace automations")?; transaction .execute( "DELETE FROM sessions WHERE workspace_id = ?1", diff --git a/src-tauri/src/pipeline/adapter/codex_items.rs b/src-tauri/src/pipeline/adapter/codex_items.rs index 6fc148edd..9653b25c6 100644 --- a/src-tauri/src/pipeline/adapter/codex_items.rs +++ b/src-tauri/src/pipeline/adapter/codex_items.rs @@ -49,6 +49,7 @@ pub(super) fn render_item_completed( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } } @@ -109,6 +110,7 @@ fn render_command_execution( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -136,6 +138,7 @@ fn render_context_compaction( })], status: None, streaming: None, + source: None, }); } @@ -154,6 +157,7 @@ fn render_todo_list(msg: &IntermediateMessage, item: &Value, result: &mut Vec<Th reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } } @@ -176,6 +180,7 @@ fn render_reasoning(msg: &IntermediateMessage, item: &Value, result: &mut Vec<Th reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } } @@ -221,6 +226,7 @@ fn render_file_change( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -270,6 +276,7 @@ fn render_web_search(msg: &IntermediateMessage, item: &Value, result: &mut Vec<T reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -318,6 +325,7 @@ fn render_mcp_tool_call( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -342,6 +350,7 @@ fn render_plan(msg: &IntermediateMessage, item: &Value, result: &mut Vec<ThreadM reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -388,6 +397,7 @@ fn render_collab_agent_tool_call( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } @@ -442,5 +452,6 @@ fn render_image_generation( reason: Some("stop".to_string()), }), streaming: None, + source: None, }); } diff --git a/src-tauri/src/pipeline/adapter/grouping.rs b/src-tauri/src/pipeline/adapter/grouping.rs index 8d880a80e..52ff19f22 100644 --- a/src-tauri/src/pipeline/adapter/grouping.rs +++ b/src-tauri/src/pipeline/adapter/grouping.rs @@ -78,6 +78,7 @@ pub(super) fn convert_user_message( content: parts.into_iter().map(ExtendedMessagePart::Basic).collect(), status: None, streaming: None, + source: None, } } diff --git a/src-tauri/src/pipeline/adapter/labels.rs b/src-tauri/src/pipeline/adapter/labels.rs index a77ca18dc..9f9c29c88 100644 --- a/src-tauri/src/pipeline/adapter/labels.rs +++ b/src-tauri/src/pipeline/adapter/labels.rs @@ -23,6 +23,7 @@ pub(super) fn make_system(msg: &IntermediateMessage, text: &str) -> ThreadMessag })], status: None, streaming: None, + source: None, } } @@ -40,6 +41,7 @@ pub(super) fn make_turn_result_system(msg: &IntermediateMessage, text: &str) -> })], status: None, streaming: None, + source: None, } } @@ -54,6 +56,7 @@ pub(super) fn make_system_notice( content: vec![ExtendedMessagePart::Basic(part)], status: None, streaming: None, + source: None, } } diff --git a/src-tauri/src/pipeline/adapter/mod.rs b/src-tauri/src/pipeline/adapter/mod.rs index 33876204e..91073b67c 100644 --- a/src-tauri/src/pipeline/adapter/mod.rs +++ b/src-tauri/src/pipeline/adapter/mod.rs @@ -277,6 +277,7 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo })], status: None, streaming: None, + source: None, }); } i += 1; @@ -320,6 +321,7 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo content, status: None, streaming: if msg.is_streaming { Some(true) } else { None }, + source: None, }); } i += 1; @@ -341,6 +343,7 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo content, status: None, streaming: if msg.is_streaming { Some(true) } else { None }, + source: None, }); } i += 1; @@ -424,6 +427,7 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo content: parts.into_iter().map(ExtendedMessagePart::Basic).collect(), status: Some(map_stop_reason(parsed)), streaming: if is_streaming { Some(true) } else { None }, + source: None, }); // Re-emit any system messages we skipped over so they still @@ -473,6 +477,10 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo }; let files = extract_strs("files"); let images = extract_strs("images"); + let source = parsed + .and_then(|p| p.get("source")) + .and_then(Value::as_str) + .map(str::to_string); let pasted_texts: Vec<crate::pipeline::types::PastedTextRange> = parsed .and_then(|p| p.get("pastedTexts")) .and_then(|v| serde_json::from_value(v.clone()).ok()) @@ -491,6 +499,7 @@ fn convert_flat(messages: &[IntermediateMessage]) -> (Vec<ThreadMessageLike>, Wo content: parts.into_iter().map(ExtendedMessagePart::Basic).collect(), status: None, streaming: None, + source, }); i += 1; continue; @@ -670,6 +679,7 @@ fn convert_user_type_msg( })], status: None, streaming: None, + source: None, }); } return; @@ -829,6 +839,7 @@ fn convert_user_question_msg( })], status: None, streaming: None, + source: None, }) } @@ -885,5 +896,6 @@ fn convert_exit_plan_mode_msg( })], status: None, streaming: None, + source: None, } } diff --git a/src-tauri/src/pipeline/collapse.rs b/src-tauri/src/pipeline/collapse.rs index 4e64d9c75..c2891777b 100644 --- a/src-tauri/src/pipeline/collapse.rs +++ b/src-tauri/src/pipeline/collapse.rs @@ -908,6 +908,7 @@ mod tests { ], status: None, streaming: None, + source: None, }]; collapse_pass(&mut messages); assert_eq!(messages[0].content.len(), 3); // text + Agent + text diff --git a/src-tauri/src/pipeline/types.rs b/src-tauri/src/pipeline/types.rs index d30ac33c9..8787e4a98 100644 --- a/src-tauri/src/pipeline/types.rs +++ b/src-tauri/src/pipeline/types.rs @@ -536,6 +536,11 @@ pub struct ThreadMessageLike { /// True when this message is still being streamed from an agent. #[serde(skip_serializing_if = "Option::is_none")] pub streaming: Option<bool>, + /// Who initiated a user message: `None` = human, `Some("automation")` = + /// the automations scheduler ("Sent via automation" badge). Absent from + /// the wire when unset so historical snapshots stay byte-identical. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub source: Option<String>, } // --------------------------------------------------------------------------- diff --git a/src-tauri/src/schema.rs b/src-tauri/src/schema.rs index 87a5cf174..0db7b6438 100644 --- a/src-tauri/src/schema.rs +++ b/src-tauri/src/schema.rs @@ -1219,11 +1219,35 @@ CREATE TABLE IF NOT EXISTS paired_devices ( revoked_at TEXT ); +-- Scheduled automations: periodically inject a fixed prompt into a session +-- and run a normal agent turn. SQLite is the single source of truth for the +-- schedule — the in-process scheduler is a stateless poll loop over +-- `next_run_at`, so restarts/sleep just mean a late tick sees overdue rows. +-- Timestamps use the db::current_timestamp() RFC3339-UTC-millis format, +-- which compares chronologically as plain strings. +CREATE TABLE IF NOT EXISTS automations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + prompt TEXT NOT NULL, + runs_in TEXT NOT NULL CHECK (runs_in IN ('chat', 'workspace')), + session_id TEXT, + workspace_id TEXT, + schedule TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused')), + next_run_at TEXT NOT NULL, + last_run_at TEXT, + -- DEFAULTs match db::current_timestamp() (RFC3339 UTC millis) so a row that + -- ever relied on them still string-orders with app-written timestamps. + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + -- Indexes CREATE INDEX IF NOT EXISTS idx_session_messages_sent_at ON session_messages(session_id, sent_at); CREATE INDEX IF NOT EXISTS idx_sessions_workspace_id ON sessions(workspace_id); CREATE INDEX IF NOT EXISTS idx_workspaces_repository_id ON workspaces(repository_id); CREATE INDEX IF NOT EXISTS idx_runtime_processes_ended_at ON runtime_processes(ended_at); +CREATE INDEX IF NOT EXISTS idx_automations_due ON automations(status, next_run_at); -- idx_workspaces_kind + idx_workspaces_triage_source are created in -- `run_migrations` (after the ALTERs on upgraded DBs). diff --git a/src-tauri/src/ui_sync/events.rs b/src-tauri/src/ui_sync/events.rs index cb978c277..e5414392e 100644 --- a/src-tauri/src/ui_sync/events.rs +++ b/src-tauri/src/ui_sync/events.rs @@ -125,6 +125,10 @@ pub enum UiMutationEvent { /// The mobile-companion paired-device list changed (paired or revoked). /// Frontends invalidate the `pairedDevices` query. PairedDevicesChanged, + /// An automation was created/edited/deleted/paused, or the scheduler + /// fired a run (shifting next/last run). Frontends invalidate the + /// `automations` query. + AutomationsChanged, /// "Open in Helmor" from the quick panel. Only the MAIN window acts on /// this (navigates to the workspace/session); the quick panel ignores it. WorkspaceRevealRequested { @@ -290,6 +294,7 @@ mod tests { UiMutationEvent::ActiveStreamsChanged, "activeStreamsChanged", ), + (UiMutationEvent::AutomationsChanged, "automationsChanged"), ]; for (event, expected) in cases { let json = serde_json::to_value(&event).unwrap(); diff --git a/src-tauri/src/workspace/ship_actions/mod.rs b/src-tauri/src/workspace/ship_actions/mod.rs index 0183f51bc..cb18fae95 100644 --- a/src-tauri/src/workspace/ship_actions/mod.rs +++ b/src-tauri/src/workspace/ship_actions/mod.rs @@ -117,6 +117,7 @@ impl OwnedSessionOverrides { seed_session_id: None, session_kind: None, agent_type: self.agent_type.as_deref(), + ..Default::default() } } } diff --git a/src-tauri/src/workspace/workspaces.rs b/src-tauri/src/workspace/workspaces.rs index 1548a293f..1f53e8fba 100644 --- a/src-tauri/src/workspace/workspaces.rs +++ b/src-tauri/src/workspace/workspaces.rs @@ -1650,6 +1650,9 @@ pub fn permanently_delete_workspace(workspace_id: &str) -> Result<()> { [workspace_id], ) .context("Failed to delete workspace session plan state")?; + // Cascade automations before sessions (the cascade subquery reads them). + crate::models::automations::delete_automations_for_workspace(&transaction, workspace_id) + .context("Failed to delete workspace automations")?; transaction .execute( "DELETE FROM sessions WHERE workspace_id = ?1", diff --git a/src-tauri/tests/common/builders.rs b/src-tauri/tests/common/builders.rs index 79a8c3c87..a9fea683f 100644 --- a/src-tauri/tests/common/builders.rs +++ b/src-tauri/tests/common/builders.rs @@ -75,6 +75,17 @@ pub fn user_prompt_with_files_and_images( make_record(id, "user", &serde_json::to_string(&parsed).unwrap()) } +/// Automation-initiated prompt. Same shape as `user_prompt` but with the +/// `source` marker written by `persist_user_message` for scheduler turns. +pub fn user_prompt_from_automation(id: &str, text: &str) -> HistoricalRecord { + let parsed = json!({ + "type": "user_prompt", + "text": text, + "source": "automation", + }); + make_record(id, "user", &serde_json::to_string(&parsed).unwrap()) +} + /// Post-migration user prompt with pasted-text tag ranges (UTF-16 code-unit /// offsets into `text`, as the composer computes them). pub fn user_prompt_with_pasted_texts( diff --git a/src-tauri/tests/common/normalize.rs b/src-tauri/tests/common/normalize.rs index 238ef720c..62477fdf5 100644 --- a/src-tauri/tests/common/normalize.rs +++ b/src-tauri/tests/common/normalize.rs @@ -18,6 +18,10 @@ pub struct NormThreadMessage { pub content: Vec<NormPart>, pub status: Option<NormStatus>, pub streaming: Option<bool>, + /// Initiator marker (`"automation"`). Skipped when absent so the + /// pre-existing snapshot corpus stays byte-identical. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option<String>, } #[derive(Debug, Serialize)] @@ -349,6 +353,7 @@ pub fn normalize_message(msg: &ThreadMessageLike) -> NormThreadMessage { reason: s.reason.clone(), }), streaming: msg.streaming, + source: msg.source.clone(), } } diff --git a/src-tauri/tests/pipeline_scenarios.rs b/src-tauri/tests/pipeline_scenarios.rs index cb5b04186..a435d9022 100644 --- a/src-tauri/tests/pipeline_scenarios.rs +++ b/src-tauri/tests/pipeline_scenarios.rs @@ -107,6 +107,18 @@ fn user_prompt_wrapped() { assert_yaml_snapshot!(run_normalized(msgs)); } +#[test] +fn user_prompt_with_automation_source() { + // Scheduler-initiated prompt: persist_user_message adds + // `"source":"automation"`, which must surface as + // `ThreadMessageLike.source` so the chat renders the + // "Sent via automation" badge. Human prompts (no `source` key) must + // keep their exact wire shape — covered by every other snapshot in + // this file staying byte-identical. + let msgs = vec![user_prompt_from_automation("u1", "check the order status")]; + assert_yaml_snapshot!(run_normalized(msgs)); +} + #[test] fn user_prompt_with_brace_content() { // Latent-bug regression: prompts that happened to start with `{` were diff --git a/src-tauri/tests/snapshots/pipeline_scenarios__user_prompt_with_automation_source.snap b/src-tauri/tests/snapshots/pipeline_scenarios__user_prompt_with_automation_source.snap new file mode 100644 index 000000000..0f42e1a3e --- /dev/null +++ b/src-tauri/tests/snapshots/pipeline_scenarios__user_prompt_with_automation_source.snap @@ -0,0 +1,13 @@ +--- +source: tests/pipeline_scenarios.rs +expression: run_normalized(msgs) +--- +- role: user + id: msg-1 + content_length: 1 + content: + - type: text + text: check the order status + status: ~ + streaming: ~ + source: automation diff --git a/src/features/automations/automation-detail.tsx b/src/features/automations/automation-detail.tsx new file mode 100644 index 000000000..bc4ad87fe --- /dev/null +++ b/src/features/automations/automation-detail.tsx @@ -0,0 +1,263 @@ +import { ChevronRight, Pause, Play, Trash2 } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Textarea } from "@/components/ui/textarea"; +import type { Automation } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { IntervalPicker } from "./interval-picker"; +import { formatRunTime, statusDotClass, statusLabel } from "./schedule"; +import { useAutomationMutations } from "./use-automation-mutations"; +import { + useSessionOptions, + useWorkspaceOptions, +} from "./use-automation-targets"; + +function SidebarGroup({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + <div> + <h3 className="text-mini font-medium tracking-wide text-muted-foreground uppercase"> + {title} + </h3> + <div className="mt-2 flex flex-col gap-1.5">{children}</div> + </div> + ); +} + +function SidebarRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + <div className="flex min-h-7 items-center justify-between gap-3"> + <span className="shrink-0 text-ui text-muted-foreground">{label}</span> + <span className="flex min-w-0 items-center gap-1.5 text-ui text-foreground"> + {children} + </span> + </div> + ); +} + +/** In-page detail view (no route). Mounted with `key={automation.id}` so the + * title/prompt drafts reset whenever a different automation opens. */ +export function AutomationDetail({ + automation, + onBack, + onOpenSession, +}: { + automation: Automation; + onBack: () => void; + onOpenSession: (workspaceId: string, sessionId: string) => void; +}) { + const [titleDraft, setTitleDraft] = useState(automation.title); + const [promptDraft, setPromptDraft] = useState(automation.prompt); + const [confirmDelete, setConfirmDelete] = useState(false); + + const { update, remove, setStatus, runNow } = useAutomationMutations(); + const workspaces = useWorkspaceOptions(); + const sessions = useSessionOptions( + automation.runsIn === "chat" ? automation.workspaceId : null, + ); + + const workspaceName = automation.workspaceId + ? (workspaces.find((workspace) => workspace.id === automation.workspaceId) + ?.title ?? "Unknown workspace") + : "—"; + const sessionName = automation.sessionId + ? (sessions.find((session) => session.id === automation.sessionId)?.title ?? + "Unknown chat") + : "—"; + + const promptDirty = promptDraft !== automation.prompt; + const paused = automation.status === "paused"; + + const saveTitle = () => { + const next = titleDraft.trim(); + if (next === "" || next === automation.title) { + setTitleDraft(automation.title); + return; + } + update.mutate({ id: automation.id, title: next }); + }; + + const savePrompt = () => { + if (!promptDirty || promptDraft.trim() === "") return; + update.mutate({ id: automation.id, prompt: promptDraft.trim() }); + }; + + const handleRunNow = () => { + runNow.mutate(automation.id, { + onSuccess: (sessionId) => { + // Workspace-mode runs land in a fresh session we can jump to; + // chat-mode runs append to a chat elsewhere, so just confirm. + if (automation.workspaceId) { + onOpenSession(automation.workspaceId, sessionId); + } else { + toast.success("Automation started", { + description: "Running in its chat.", + }); + } + }, + }); + }; + + return ( + <div className="h-full overflow-y-auto bg-background"> + <div className="mx-auto w-full max-w-4xl px-8 py-8"> + <div className="flex items-center justify-between gap-4"> + <nav className="flex min-w-0 items-center gap-1 text-ui"> + <button + type="button" + onClick={onBack} + className="shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground" + > + Automations + </button> + <ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" /> + <span className="truncate font-medium text-foreground"> + {automation.title} + </span> + </nav> + <div className="flex shrink-0 items-center gap-1"> + <Button + variant="ghost" + size="icon-sm" + aria-label={paused ? "Resume automation" : "Pause automation"} + onClick={() => + setStatus.mutate({ + automationId: automation.id, + status: paused ? "active" : "paused", + }) + } + > + {paused ? <Play /> : <Pause />} + </Button> + <Button + variant="ghost" + size="icon-sm" + aria-label="Delete automation" + className="text-muted-foreground hover:text-destructive" + onClick={() => setConfirmDelete(true)} + > + <Trash2 /> + </Button> + <Button + size="sm" + className="ml-1" + disabled={runNow.isPending} + onClick={handleRunNow} + > + <Play data-icon="inline-start" /> + Run now + </Button> + </div> + </div> + + <div className="mt-8 flex flex-col gap-10 md:flex-row"> + <div className="min-w-0 flex-1"> + <input + value={titleDraft} + onChange={(event) => setTitleDraft(event.target.value)} + onBlur={saveTitle} + aria-label="Automation title" + className="w-full bg-transparent text-heading font-semibold text-foreground outline-none placeholder:text-muted-foreground/60" + /> + <Textarea + value={promptDraft} + onChange={(event) => setPromptDraft(event.target.value)} + onBlur={savePrompt} + aria-label="Automation prompt" + placeholder="Add prompt e.g. look for crashes in $sentry" + className="mt-4 min-h-48 resize-none border-0 px-0 py-0 text-body leading-relaxed shadow-none focus-visible:ring-0 dark:bg-transparent" + /> + {promptDirty ? ( + <Button + size="sm" + variant="outline" + className="mt-2" + disabled={update.isPending || promptDraft.trim() === ""} + onClick={savePrompt} + > + Save + </Button> + ) : null} + </div> + + <aside className="w-full shrink-0 md:w-64"> + <div className="flex flex-col gap-6 rounded-xl border border-border p-4"> + <SidebarGroup title="Status"> + <SidebarRow label="Status"> + <span + className={cn( + "size-2 rounded-full", + statusDotClass(automation.status), + )} + /> + {statusLabel(automation.status)} + </SidebarRow> + <SidebarRow label="Next run"> + {formatRunTime(automation.nextRunAt)} + </SidebarRow> + <SidebarRow label="Last ran"> + {automation.lastRunAt + ? formatRunTime(automation.lastRunAt) + : "Never"} + </SidebarRow> + </SidebarGroup> + <SidebarGroup title="Details"> + <SidebarRow label="Runs in"> + {automation.runsIn === "chat" ? "Chat" : "Workspace"} + </SidebarRow> + {automation.runsIn === "chat" ? ( + <SidebarRow label="Chat"> + <span className="truncate">{sessionName}</span> + </SidebarRow> + ) : ( + <SidebarRow label="Workspace"> + <span className="truncate">{workspaceName}</span> + </SidebarRow> + )} + <SidebarRow label="Interval"> + <IntervalPicker + value={automation.schedule} + align="end" + onChange={(schedule) => + update.mutate({ id: automation.id, schedule }) + } + /> + </SidebarRow> + </SidebarGroup> + </div> + </aside> + </div> + </div> + + <ConfirmDialog + open={confirmDelete} + onOpenChange={setConfirmDelete} + title="Delete automation?" + description={`"${automation.title}" will stop running and be removed. This cannot be undone.`} + confirmLabel="Delete" + loading={remove.isPending} + onConfirm={() => + remove.mutate(automation.id, { + onSuccess: () => { + setConfirmDelete(false); + onBack(); + }, + }) + } + /> + </div> + ); +} diff --git a/src/features/automations/create-automation-dialog.tsx b/src/features/automations/create-automation-dialog.tsx new file mode 100644 index 000000000..342619880 --- /dev/null +++ b/src/features/automations/create-automation-dialog.tsx @@ -0,0 +1,216 @@ +import { ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Textarea } from "@/components/ui/textarea"; +import type { AutomationRunsIn, AutomationSchedule } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { IntervalPicker } from "./interval-picker"; +import { DEFAULT_SCHEDULE } from "./schedule"; +import { useAutomationMutations } from "./use-automation-mutations"; +import { + useSessionOptions, + useWorkspaceOptions, +} from "./use-automation-targets"; + +/** Compact footer select shared by the mode / workspace / session pickers. */ +function TargetSelect({ + label, + value, + options, + onChange, + disabled = false, + className, +}: { + /** Placeholder when nothing is selected yet. */ + label: string; + value: string | null; + options: { value: string; label: string }[]; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; +}) { + const active = options.find((option) => option.value === value); + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + type="button" + variant="outline" + size="sm" + disabled={disabled} + className={cn( + "max-w-44 justify-between gap-1.5 font-normal", + className, + )} + > + <span className={cn("truncate", !active && "text-muted-foreground")}> + {active?.label ?? label} + </span> + <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="start" + className="max-h-64 w-56 overflow-y-auto" + > + {options.length === 0 ? ( + <div className="px-2 py-1.5 text-mini text-muted-foreground"> + Nothing available + </div> + ) : ( + <DropdownMenuRadioGroup value={value ?? ""} onValueChange={onChange}> + {options.map((option) => ( + <DropdownMenuRadioItem key={option.value} value={option.value}> + <span className="truncate">{option.label}</span> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + )} + </DropdownMenuContent> + </DropdownMenu> + ); +} + +export function CreateAutomationDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [title, setTitle] = useState(""); + const [prompt, setPrompt] = useState(""); + const [runsIn, setRunsIn] = useState<AutomationRunsIn>("chat"); + const [workspaceId, setWorkspaceId] = useState<string | null>(null); + const [sessionId, setSessionId] = useState<string | null>(null); + const [schedule, setSchedule] = + useState<AutomationSchedule>(DEFAULT_SCHEDULE); + + const { create } = useAutomationMutations(); + const workspaces = useWorkspaceOptions(open); + const sessions = useSessionOptions( + open && runsIn === "chat" ? workspaceId : null, + ); + + // Fresh form every time the dialog opens. + useEffect(() => { + if (!open) return; + setTitle(""); + setPrompt(""); + setRunsIn("chat"); + setWorkspaceId(null); + setSessionId(null); + setSchedule(DEFAULT_SCHEDULE); + }, [open]); + + const targetValid = + workspaceId !== null && (runsIn === "workspace" || sessionId !== null); + const valid = title.trim() !== "" && prompt.trim() !== "" && targetValid; + + const submit = () => { + if (!valid || !workspaceId || create.isPending) return; + create.mutate( + { + title: title.trim(), + prompt: prompt.trim(), + runsIn, + workspaceId, + sessionId: runsIn === "chat" ? (sessionId ?? undefined) : undefined, + schedule, + }, + { onSuccess: () => onOpenChange(false) }, + ); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent + className="gap-0 p-0 sm:max-w-[560px]" + showCloseButton={false} + > + <DialogTitle className="sr-only">Create automation</DialogTitle> + <div className="px-5 pt-5"> + <input + value={title} + onChange={(event) => setTitle(event.target.value)} + placeholder="Automation title" + aria-label="Automation title" + className="w-full bg-transparent text-title font-semibold text-foreground outline-none placeholder:text-muted-foreground/60" + /> + <Textarea + value={prompt} + onChange={(event) => setPrompt(event.target.value)} + placeholder="Add prompt e.g. look for crashes in $sentry" + aria-label="Automation prompt" + className="mt-2 min-h-28 resize-none border-0 px-0 py-0 text-body shadow-none focus-visible:ring-0 dark:bg-transparent" + /> + </div> + <div className="flex flex-wrap items-center gap-1.5 border-t border-border px-5 py-3"> + <TargetSelect + label="Target" + value={runsIn} + options={[ + { value: "chat", label: "Chat" }, + { value: "workspace", label: "Workspace" }, + ]} + onChange={(value) => { + setRunsIn(value === "workspace" ? "workspace" : "chat"); + setSessionId(null); + }} + /> + <TargetSelect + label="Select workspace" + value={workspaceId} + options={workspaces.map((workspace) => ({ + value: workspace.id, + label: workspace.title, + }))} + onChange={(value) => { + setWorkspaceId(value); + setSessionId(null); + }} + /> + {runsIn === "chat" ? ( + <TargetSelect + label="Select chat" + value={sessionId} + disabled={workspaceId === null} + options={sessions.map((session) => ({ + value: session.id, + label: session.title, + }))} + onChange={setSessionId} + /> + ) : null} + <IntervalPicker value={schedule} onChange={setSchedule} /> + <div className="ml-auto flex items-center gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button + type="button" + size="sm" + disabled={!valid || create.isPending} + onClick={submit} + > + Create + </Button> + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/features/automations/index.test.tsx b/src/features/automations/index.test.tsx new file mode 100644 index 000000000..4601b11bc --- /dev/null +++ b/src/features/automations/index.test.tsx @@ -0,0 +1,93 @@ +/** + * Smoke test for the Automations surface. The IPC layer is mocked via + * `vi.mock("@/lib/api", ...)` (same pattern as the panel/banner tests) so + * no Tauri runtime is needed. Guards the list rendering contract: a row + * shows the automation title plus its schedule labels. + */ + +import { QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Automation } from "@/lib/api"; +import { createHelmorQueryClient } from "@/lib/query-client"; + +const apiMocks = vi.hoisted(() => ({ + listAutomations: vi.fn(), + loadWorkspaceGroups: vi.fn(), + loadWorkspaceSessions: vi.fn(), +})); + +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api"); + return { + ...actual, + listAutomations: apiMocks.listAutomations, + loadWorkspaceGroups: apiMocks.loadWorkspaceGroups, + loadWorkspaceSessions: apiMocks.loadWorkspaceSessions, + }; +}); + +import { AutomationsSurface } from "./index"; + +function sampleAutomation(overrides: Partial<Automation> = {}): Automation { + return { + id: "auto-1", + title: "Nightly crash sweep", + prompt: "Look for new crashes in Sentry and triage them", + runsIn: "chat", + sessionId: "session-1", + workspaceId: "ws-1", + schedule: { kind: "hourly" }, + status: "active", + nextRunAt: "2026-06-11T12:00:00Z", + lastRunAt: null, + createdAt: "2026-06-01T00:00:00Z", + updatedAt: "2026-06-01T00:00:00Z", + ...overrides, + }; +} + +function renderSurface() { + return render( + <QueryClientProvider client={createHelmorQueryClient()}> + <AutomationsSurface onOpenSession={vi.fn()} onCreateViaChat={vi.fn()} /> + </QueryClientProvider>, + ); +} + +describe("AutomationsSurface", () => { + beforeEach(() => { + apiMocks.listAutomations.mockReset(); + apiMocks.loadWorkspaceGroups.mockResolvedValue([]); + apiMocks.loadWorkspaceSessions.mockResolvedValue([]); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders a row with title, schedule summary and interval label", async () => { + apiMocks.listAutomations.mockResolvedValue([sampleAutomation()]); + + renderSurface(); + + expect(await screen.findByText("Nightly crash sweep")).toBeInTheDocument(); + // "Hourly" appears twice: in the muted "<summary> · <prompt>" line and + // as the right-aligned interval label. + expect(screen.getAllByText(/Hourly/).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText(/Look for new crashes/)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create via chat" }), + ).toBeInTheDocument(); + }); + + it("shows the empty state when there are no automations", async () => { + apiMocks.listAutomations.mockResolvedValue([]); + + renderSurface(); + + expect( + await screen.findByRole("button", { name: "Create automation" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/automations/index.tsx b/src/features/automations/index.tsx new file mode 100644 index 000000000..439562148 --- /dev/null +++ b/src/features/automations/index.tsx @@ -0,0 +1,298 @@ +import { useQuery } from "@tanstack/react-query"; +import { + ChevronDown, + MessageCircle, + MoreHorizontal, + Pause, + Pencil, + Play, + Trash2, +} from "lucide-react"; +import { type ReactElement, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Automation } from "@/lib/api"; +import { automationsQueryOptions } from "@/lib/query-client"; +import { cn } from "@/lib/utils"; +import { AutomationDetail } from "./automation-detail"; +import { CreateAutomationDialog } from "./create-automation-dialog"; +import { + scheduleShortLabel, + scheduleSummary, + statusDotClass, +} from "./schedule"; +import { useAutomationMutations } from "./use-automation-mutations"; + +function CreateSplitButton({ + onCreateViaChat, + onCreateManually, +}: { + onCreateViaChat: () => void; + onCreateManually: () => void; +}) { + return ( + <ButtonGroup> + <Button size="sm" onClick={onCreateViaChat}> + Create via chat + </Button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button size="sm" aria-label="More create options" className="px-1.5"> + <ChevronDown /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onSelect={onCreateViaChat}> + <MessageCircle /> + Create via chat + </DropdownMenuItem> + <DropdownMenuItem onSelect={onCreateManually}> + <Pencil /> + Create manually + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </ButtonGroup> + ); +} + +function AutomationRow({ + automation, + onOpen, + onRunNow, + onToggleStatus, + onDelete, +}: { + automation: Automation; + onOpen: () => void; + onRunNow: () => void; + onToggleStatus: () => void; + onDelete: () => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const paused = automation.status === "paused"; + const subtitle = `${scheduleSummary(automation.schedule)} · ${automation.prompt.replace(/\s+/g, " ")}`; + + return ( + <div + role="button" + tabIndex={0} + onClick={onOpen} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + className="group flex cursor-pointer items-center gap-3 border-b border-border/60 px-1 py-3.5 transition-colors hover:bg-muted/30" + > + <span + aria-hidden + className={cn( + "size-2 shrink-0 rounded-full", + statusDotClass(automation.status), + )} + /> + <div className="min-w-0 flex-1"> + <div className="truncate text-body font-semibold text-foreground"> + {automation.title} + </div> + <div className="truncate text-ui text-muted-foreground">{subtitle}</div> + </div> + <span className="shrink-0 text-ui text-muted-foreground"> + {scheduleShortLabel(automation.schedule)} + </span> + <div + className={cn( + "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100", + menuOpen && "opacity-100", + )} + > + <Button + variant="ghost" + size="icon-sm" + aria-label="Run now" + onClick={(event) => { + event.stopPropagation(); + onRunNow(); + }} + > + <Play /> + </Button> + <Button + variant="ghost" + size="icon-sm" + aria-label="Edit automation" + onClick={(event) => { + event.stopPropagation(); + onOpen(); + }} + > + <Pencil /> + </Button> + <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-sm" + aria-label="More actions" + onClick={(event) => event.stopPropagation()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onClick={(event) => event.stopPropagation()} + > + <DropdownMenuItem onSelect={onToggleStatus}> + {paused ? <Play /> : <Pause />} + {paused ? "Resume" : "Pause"} + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem variant="destructive" onSelect={onDelete}> + <Trash2 /> + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + ); +} + +export function AutomationsSurface({ + onOpenSession, + onCreateViaChat, +}: { + /** Navigate the app to a chat (used after Run now). */ + onOpenSession: (workspaceId: string, sessionId: string) => void; + /** "Create via chat" — shell opens a new chat with a prefilled prompt. */ + onCreateViaChat: () => void; +}): ReactElement { + const [detailId, setDetailId] = useState<string | null>(null); + const [createOpen, setCreateOpen] = useState(false); + const [pendingDelete, setPendingDelete] = useState<Automation | null>(null); + + const automationsQuery = useQuery(automationsQueryOptions()); + const automations = automationsQuery.data ?? []; + const { remove, setStatus, runNow } = useAutomationMutations(); + + const runAndOpen = (automation: Automation) => { + runNow.mutate(automation.id, { + onSuccess: (sessionId) => { + // Workspace-mode runs open the fresh session; chat-mode runs + // append to a chat elsewhere, so confirm instead of jumping. + if (automation.workspaceId) { + onOpenSession(automation.workspaceId, sessionId); + } else { + toast.success("Automation started", { + description: "Running in its chat.", + }); + } + }, + }); + }; + + const detail = + detailId !== null + ? automations.find((automation) => automation.id === detailId) + : undefined; + if (detail) { + return ( + <AutomationDetail + key={detail.id} + automation={detail} + onBack={() => setDetailId(null)} + onOpenSession={onOpenSession} + /> + ); + } + + return ( + <div className="h-full overflow-y-auto bg-background"> + <div className="mx-auto w-full max-w-3xl px-8 py-10"> + <div className="flex items-start justify-between gap-4"> + <h1 className="text-heading font-semibold text-foreground"> + Automations + </h1> + <CreateSplitButton + onCreateViaChat={onCreateViaChat} + onCreateManually={() => setCreateOpen(true)} + /> + </div> + + <div className="mt-10"> + <h2 className="border-b border-border pb-2 text-ui font-medium text-muted-foreground"> + Current + </h2> + {automationsQuery.isPending ? ( + <p className="py-6 text-ui text-muted-foreground">Loading…</p> + ) : automationsQuery.isError ? ( + <p className="py-6 text-ui text-muted-foreground"> + Unable to load automations. + </p> + ) : automations.length === 0 ? ( + <div className="flex flex-col items-start gap-3 py-8"> + <p className="text-ui text-muted-foreground"> + No automations yet. Schedule a recurring prompt and it will run + on its own. + </p> + <Button size="sm" onClick={() => setCreateOpen(true)}> + Create automation + </Button> + </div> + ) : ( + automations.map((automation) => ( + <AutomationRow + key={automation.id} + automation={automation} + onOpen={() => setDetailId(automation.id)} + onRunNow={() => runAndOpen(automation)} + onToggleStatus={() => + setStatus.mutate({ + automationId: automation.id, + status: + automation.status === "paused" ? "active" : "paused", + }) + } + onDelete={() => setPendingDelete(automation)} + /> + )) + )} + </div> + </div> + + <CreateAutomationDialog open={createOpen} onOpenChange={setCreateOpen} /> + <ConfirmDialog + open={pendingDelete !== null} + onOpenChange={(open) => { + if (!open) setPendingDelete(null); + }} + title="Delete automation?" + description={ + pendingDelete + ? `"${pendingDelete.title}" will stop running and be removed. This cannot be undone.` + : "" + } + confirmLabel="Delete" + loading={remove.isPending} + onConfirm={() => { + if (!pendingDelete) return; + remove.mutate(pendingDelete.id, { + onSuccess: () => setPendingDelete(null), + }); + }} + /> + </div> + ); +} diff --git a/src/features/automations/interval-picker.test.tsx b/src/features/automations/interval-picker.test.tsx new file mode 100644 index 000000000..a9b7d0664 --- /dev/null +++ b/src/features/automations/interval-picker.test.tsx @@ -0,0 +1,60 @@ +/** + * Guards the "Every N" amount input against the controlled-input trap: a plain + * controlled number input that only accepts valid values snaps back to the old + * value, so the user can't clear-and-retype. The local draft must allow the + * empty/intermediate state and clamp up to 1 on blur. + */ + +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { IntervalPicker } from "./interval-picker"; + +afterEach(cleanup); + +async function openEveryPicker(amount = 15) { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + <IntervalPicker + value={{ kind: "every", amount, unit: "minutes" }} + onChange={onChange} + />, + ); + await user.click(screen.getByRole("button", { name: /Every/ })); + const input = (await screen.findByLabelText( + "Interval amount", + )) as HTMLInputElement; + return { onChange, user, input }; +} + +describe("IntervalPicker amount input", () => { + it("allows clearing the field and clamps up to 1 on blur", async () => { + const { onChange, user, input } = await openEveryPicker(15); + + await user.clear(input); + // Empty stays empty instead of snapping back to 15. + expect(input).toHaveValue(null); + + await user.tab(); + expect(input).toHaveValue(1); + expect(onChange).toHaveBeenLastCalledWith({ + kind: "every", + amount: 1, + unit: "minutes", + }); + }); + + it("commits a valid typed amount", async () => { + const { onChange, user, input } = await openEveryPicker(15); + + await user.clear(input); + await user.type(input, "5"); + + expect(onChange).toHaveBeenLastCalledWith({ + kind: "every", + amount: 5, + unit: "minutes", + }); + }); +}); diff --git a/src/features/automations/interval-picker.tsx b/src/features/automations/interval-picker.tsx new file mode 100644 index 000000000..9bd074dc4 --- /dev/null +++ b/src/features/automations/interval-picker.tsx @@ -0,0 +1,277 @@ +import { Check, ChevronDown } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { AutomationSchedule } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { DEFAULT_TIME, scheduleSummary, WEEKDAY_NAMES } from "./schedule"; + +function timeOf(value: AutomationSchedule): string { + if (value.kind === "daily" || value.kind === "weekly") return value.time; + return DEFAULT_TIME; +} + +/** Number field for the "Every N" amount. Holds a local string draft so the + * user can clear it and retype mid-edit; commits a valid amount live and + * clamps empty/invalid input up to 1 on blur (a plain controlled input would + * snap back to the old value and feel frozen). */ +function AmountInput({ + amount, + onCommit, +}: { + amount: number; + onCommit: (amount: number) => void; +}) { + const [draft, setDraft] = useState(String(amount)); + useEffect(() => { + setDraft(String(amount)); + }, [amount]); + return ( + <Input + type="number" + min={1} + aria-label="Interval amount" + value={draft} + onChange={(event) => { + setDraft(event.target.value); + const parsed = Number.parseInt(event.target.value, 10); + if (Number.isFinite(parsed) && parsed >= 1) onCommit(parsed); + }} + onBlur={() => { + const parsed = Number.parseInt(draft, 10); + const clamped = Number.isFinite(parsed) && parsed >= 1 ? parsed : 1; + setDraft(String(clamped)); + onCommit(clamped); + }} + className="h-6 w-14 rounded-md px-1.5 text-small" + /> + ); +} + +/** Compact dropdown control inside the picker (weekday / unit). */ +function InlineSelect<T extends string | number>({ + value, + options, + onChange, +}: { + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { + const active = options.find((option) => option.value === value); + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + type="button" + variant="outline" + size="xs" + className="gap-1 font-normal" + > + {active?.label ?? String(value)} + <ChevronDown className="size-3 text-muted-foreground" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="max-h-64 overflow-y-auto"> + <DropdownMenuRadioGroup + value={String(value)} + onValueChange={(next) => { + const match = options.find( + (option) => String(option.value) === next, + ); + if (match) onChange(match.value); + }} + > + {options.map((option) => ( + <DropdownMenuRadioItem + key={String(option.value)} + value={String(option.value)} + > + {option.label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + ); +} + +function IntervalOption({ + label, + active, + onSelect, + children, +}: { + label: string; + active: boolean; + onSelect: () => void; + children?: ReactNode; +}) { + return ( + <div + className={cn( + "flex min-h-8 items-center gap-1.5 rounded-md px-2 py-1", + active && "bg-muted/60", + )} + > + <button + type="button" + onClick={onSelect} + className="flex shrink-0 cursor-pointer items-center gap-2 text-left text-ui text-foreground" + > + <Check + className={cn( + "size-3.5 shrink-0", + active ? "opacity-100" : "opacity-0", + )} + /> + <span>{label}</span> + </button> + {children} + </div> + ); +} + +const TIME_INPUT_CLASSES = "h-6 w-fit rounded-md px-1.5 text-small"; + +/** Shared interval picker — used by the create dialog footer and the detail + * sidebar. Renders the current schedule as a compact trigger; the popover + * offers the four supported cadences with inline parameter controls. */ +export function IntervalPicker({ + value, + onChange, + className, + align = "start", +}: { + value: AutomationSchedule; + onChange: (schedule: AutomationSchedule) => void; + className?: string; + align?: "start" | "end"; +}) { + const setKind = (kind: AutomationSchedule["kind"]) => { + if (kind === value.kind) return; + switch (kind) { + case "hourly": + onChange({ kind: "hourly" }); + break; + case "daily": + onChange({ kind: "daily", time: timeOf(value) }); + break; + case "weekly": + onChange({ kind: "weekly", weekday: 1, time: timeOf(value) }); + break; + case "every": + onChange({ kind: "every", amount: 15, unit: "minutes" }); + break; + } + }; + + return ( + <Popover> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + size="sm" + className={cn( + "max-w-56 justify-between gap-1.5 font-normal", + className, + )} + > + <span className="truncate">{scheduleSummary(value)}</span> + <ChevronDown className="size-3.5 shrink-0 text-muted-foreground" /> + </Button> + </PopoverTrigger> + <PopoverContent align={align} className="w-80 p-1"> + <IntervalOption + label="Hourly" + active={value.kind === "hourly"} + onSelect={() => setKind("hourly")} + /> + <IntervalOption + label="Daily at" + active={value.kind === "daily"} + onSelect={() => setKind("daily")} + > + {value.kind === "daily" ? ( + <Input + type="time" + aria-label="Daily run time" + value={value.time} + onChange={(event) => { + if (event.target.value) { + onChange({ kind: "daily", time: event.target.value }); + } + }} + className={TIME_INPUT_CLASSES} + /> + ) : null} + </IntervalOption> + <IntervalOption + label="Weekly on" + active={value.kind === "weekly"} + onSelect={() => setKind("weekly")} + > + {value.kind === "weekly" ? ( + <> + <InlineSelect + value={value.weekday} + options={WEEKDAY_NAMES.map((name, weekday) => ({ + value: weekday, + label: name, + }))} + onChange={(weekday) => onChange({ ...value, weekday })} + /> + <span className="text-ui text-muted-foreground">at</span> + <Input + type="time" + aria-label="Weekly run time" + value={value.time} + onChange={(event) => { + if (event.target.value) { + onChange({ ...value, time: event.target.value }); + } + }} + className={TIME_INPUT_CLASSES} + /> + </> + ) : null} + </IntervalOption> + <IntervalOption + label="Every" + active={value.kind === "every"} + onSelect={() => setKind("every")} + > + {value.kind === "every" ? ( + <> + <AmountInput + amount={value.amount} + onCommit={(amount) => onChange({ ...value, amount })} + /> + <InlineSelect + value={value.unit} + options={[ + { value: "minutes" as const, label: "minutes" }, + { value: "hours" as const, label: "hours" }, + ]} + onChange={(unit) => onChange({ ...value, unit })} + /> + </> + ) : null} + </IntervalOption> + </PopoverContent> + </Popover> + ); +} diff --git a/src/features/automations/schedule.test.ts b/src/features/automations/schedule.test.ts new file mode 100644 index 000000000..97732b928 --- /dev/null +++ b/src/features/automations/schedule.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatRunTime, + scheduleShortLabel, + scheduleSummary, + statusDotClass, +} from "./schedule"; + +describe("scheduleSummary", () => { + it("formats hourly", () => { + expect(scheduleSummary({ kind: "hourly" })).toBe("Hourly"); + }); + + it("formats daily with the raw HH:MM time", () => { + expect(scheduleSummary({ kind: "daily", time: "09:00" })).toBe( + "Daily at 09:00", + ); + }); + + it("formats weekly with weekday name", () => { + expect(scheduleSummary({ kind: "weekly", weekday: 1, time: "09:00" })).toBe( + "Weekly on Monday at 09:00", + ); + }); + + it("formats custom minute intervals", () => { + expect( + scheduleSummary({ kind: "every", amount: 15, unit: "minutes" }), + ).toBe("Every 15m"); + }); + + it("formats custom hour intervals", () => { + expect(scheduleSummary({ kind: "every", amount: 2, unit: "hours" })).toBe( + "Every 2h", + ); + }); +}); + +describe("scheduleShortLabel", () => { + it("uses one-word labels for fixed cadences", () => { + expect(scheduleShortLabel({ kind: "hourly" })).toBe("Hourly"); + expect(scheduleShortLabel({ kind: "daily", time: "09:00" })).toBe("Daily"); + expect( + scheduleShortLabel({ kind: "weekly", weekday: 3, time: "12:30" }), + ).toBe("Weekly"); + }); + + it("keeps the full summary for custom intervals", () => { + expect( + scheduleShortLabel({ kind: "every", amount: 45, unit: "minutes" }), + ).toBe("Every 45m"); + }); +}); + +describe("formatRunTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Local-time constructor (no trailing Z) so the today/yesterday + // boundaries are deterministic regardless of the host timezone. + vi.setSystemTime(new Date(2026, 5, 11, 15, 0, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats a same-day timestamp as Today", () => { + const iso = new Date(2026, 5, 11, 11, 37, 0).toISOString(); + expect(formatRunTime(iso)).toBe("Today at 11:37 AM"); + }); + + it("formats afternoon times with PM", () => { + const iso = new Date(2026, 5, 11, 23, 5, 0).toISOString(); + expect(formatRunTime(iso)).toBe("Today at 11:05 PM"); + }); + + it("formats midnight as 12:00 AM", () => { + const iso = new Date(2026, 5, 11, 0, 0, 0).toISOString(); + expect(formatRunTime(iso)).toBe("Today at 12:00 AM"); + }); + + it("formats the previous local day as Yesterday", () => { + const iso = new Date(2026, 5, 10, 21, 0, 0).toISOString(); + expect(formatRunTime(iso)).toBe("Yesterday at 9:00 PM"); + }); + + it("formats other days as 'Mon D at …'", () => { + const iso = new Date(2026, 5, 12, 8, 15, 0).toISOString(); + expect(formatRunTime(iso)).toBe("Jun 12 at 8:15 AM"); + }); + + it("falls back to the raw input when unparseable", () => { + expect(formatRunTime("not-a-date")).toBe("not-a-date"); + }); +}); + +describe("statusDotClass", () => { + it("maps active to green and paused to gray", () => { + expect(statusDotClass("active")).toContain("emerald"); + expect(statusDotClass("paused")).toContain("muted"); + }); +}); diff --git a/src/features/automations/schedule.ts b/src/features/automations/schedule.ts new file mode 100644 index 000000000..0067ee5dc --- /dev/null +++ b/src/features/automations/schedule.ts @@ -0,0 +1,102 @@ +import type { AutomationSchedule, AutomationStatus } from "@/lib/api"; + +export const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +/** Wall-clock time used whenever a daily/weekly schedule needs a default. */ +export const DEFAULT_TIME = "09:00"; + +/** Default interval for newly created automations: "Daily at 9:00 AM". */ +export const DEFAULT_SCHEDULE: AutomationSchedule = { + kind: "daily", + time: DEFAULT_TIME, +}; + +/** "Hourly" / "Daily at 09:00" / "Weekly on Monday at 09:00" / "Every 15m". */ +export function scheduleSummary(schedule: AutomationSchedule): string { + switch (schedule.kind) { + case "hourly": + return "Hourly"; + case "daily": + return `Daily at ${schedule.time}`; + case "weekly": + return `Weekly on ${WEEKDAY_NAMES[schedule.weekday] ?? "Sunday"} at ${schedule.time}`; + case "every": + return `Every ${schedule.amount}${schedule.unit === "minutes" ? "m" : "h"}`; + } +} + +/** Right-aligned list-row label: "Hourly" / "Daily" / "Weekly" / "Every 15m". */ +export function scheduleShortLabel(schedule: AutomationSchedule): string { + switch (schedule.kind) { + case "hourly": + return "Hourly"; + case "daily": + return "Daily"; + case "weekly": + return "Weekly"; + case "every": + return scheduleSummary(schedule); + } +} + +function formatClockTime(date: Date): string { + const hours24 = date.getHours(); + const hour12 = ((hours24 + 11) % 12) + 1; + const minutes = String(date.getMinutes()).padStart(2, "0"); + const meridiem = hours24 < 12 ? "AM" : "PM"; + return `${hour12}:${minutes} ${meridiem}`; +} + +function isSameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +/** "Today at 11:37 AM" / "Yesterday at 9:00 PM" / "Jun 12 at 8:15 AM" — all + * in the user's local timezone. Falls back to the raw input when the + * timestamp doesn't parse. */ +export function formatRunTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + const clock = formatClockTime(date); + const now = new Date(); + if (isSameLocalDay(date, now)) return `Today at ${clock}`; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (isSameLocalDay(date, yesterday)) return `Yesterday at ${clock}`; + return `${MONTH_NAMES[date.getMonth()]} ${date.getDate()} at ${clock}`; +} + +export function statusDotClass(status: AutomationStatus): string { + return status === "active" ? "bg-emerald-500" : "bg-muted-foreground/40"; +} + +export function statusLabel(status: AutomationStatus): string { + return status === "active" ? "Active" : "Paused"; +} diff --git a/src/features/automations/use-automation-mutations.ts b/src/features/automations/use-automation-mutations.ts new file mode 100644 index 000000000..3b799a373 --- /dev/null +++ b/src/features/automations/use-automation-mutations.ts @@ -0,0 +1,96 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + type Automation, + type AutomationStatus, + type CreateAutomationRequest, + createAutomation, + deleteAutomation, + runAutomationNow, + setAutomationStatus, + type UpdateAutomationRequest, + updateAutomation, +} from "@/lib/api"; +import { helmorQueryKeys } from "@/lib/query-client"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Mutations shared by the list rows, the detail view and the create + * dialog. The backend also pushes `automationsChanged` ui-sync events + * that invalidate the query globally; we still invalidate locally so + * the UI snaps without waiting for the broadcast round-trip. */ +export function useAutomationMutations() { + const queryClient = useQueryClient(); + + const invalidate = () => { + void queryClient.invalidateQueries({ + queryKey: helmorQueryKeys.automations, + }); + }; + + const replaceInCache = (updated: Automation) => { + queryClient.setQueryData<Automation[]>( + helmorQueryKeys.automations, + (prev) => + prev?.map((automation) => + automation.id === updated.id ? updated : automation, + ), + ); + }; + + const create = useMutation({ + mutationFn: (request: CreateAutomationRequest) => createAutomation(request), + onSuccess: invalidate, + onError: (error) => + toast.error("Unable to create automation", { + description: errorMessage(error), + }), + }); + + const update = useMutation({ + mutationFn: (request: UpdateAutomationRequest) => updateAutomation(request), + onSuccess: (updated) => { + replaceInCache(updated); + invalidate(); + }, + onError: (error) => + toast.error("Unable to update automation", { + description: errorMessage(error), + }), + }); + + const remove = useMutation({ + mutationFn: (automationId: string) => deleteAutomation(automationId), + onSuccess: invalidate, + onError: (error) => + toast.error("Unable to delete automation", { + description: errorMessage(error), + }), + }); + + const setStatus = useMutation({ + mutationFn: (input: { automationId: string; status: AutomationStatus }) => + setAutomationStatus(input.automationId, input.status), + onSuccess: (updated) => { + replaceInCache(updated); + invalidate(); + }, + onError: (error) => + toast.error("Unable to change automation status", { + description: errorMessage(error), + }), + }); + + const runNow = useMutation({ + mutationFn: (automationId: string) => runAutomationNow(automationId), + onSuccess: invalidate, + onError: (error) => + toast.error("Unable to run automation", { + description: errorMessage(error), + }), + }); + + return { create, update, remove, setStatus, runNow }; +} diff --git a/src/features/automations/use-automation-targets.ts b/src/features/automations/use-automation-targets.ts new file mode 100644 index 000000000..b38405ff6 --- /dev/null +++ b/src/features/automations/use-automation-targets.ts @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { WorkspaceSessionSummary } from "@/lib/api"; +import { + workspaceGroupsQueryOptions, + workspaceSessionsQueryOptions, +} from "@/lib/query-client"; + +export type WorkspaceOption = { id: string; title: string }; + +/** Flattened workspace list for the target pickers — reuses the sidebar's + * `workspaceGroups` query (no new IPC). Deduped by id since a workspace + * can only appear once but groups are a projection we don't control. */ +export function useWorkspaceOptions(enabled = true): WorkspaceOption[] { + const groupsQuery = useQuery({ + ...workspaceGroupsQueryOptions(), + enabled, + }); + const groups = groupsQuery.data; + return useMemo(() => { + const seen = new Set<string>(); + const options: WorkspaceOption[] = []; + for (const group of groups ?? []) { + for (const row of group.rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + options.push({ id: row.id, title: row.title }); + } + } + return options; + }, [groups]); +} + +/** Visible chat sessions of a workspace — reuses the panel's + * `workspaceSessions` query. Hidden one-off action sessions and terminal + * sessions are not valid automation targets. */ +export function useSessionOptions( + workspaceId: string | null, +): WorkspaceSessionSummary[] { + const sessionsQuery = useQuery({ + ...workspaceSessionsQueryOptions(workspaceId ?? "__none__"), + enabled: workspaceId !== null, + }); + const sessions = sessionsQuery.data; + return useMemo( + () => + (sessions ?? []).filter( + (session) => + !session.isHidden && (session.sessionKind ?? "gui") !== "terminal", + ), + [sessions], + ); +} diff --git a/src/features/panel/message-components/user-message.tsx b/src/features/panel/message-components/user-message.tsx index 6f2940e25..70ea535a1 100644 --- a/src/features/panel/message-components/user-message.tsx +++ b/src/features/panel/message-components/user-message.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Tag } from "lucide-react"; +import { ChevronDown, Clock, Tag } from "lucide-react"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { FileMentionBadge } from "@/components/file-mention-badge"; import { InlineBadge } from "@/components/inline-badge"; @@ -154,6 +154,12 @@ export function ChatUserMessage({ message }: { message: RenderedMessage }) { className="group/user flex min-w-0 justify-end" > <div className="relative flex max-w-[75%] min-w-0 flex-col items-end pb-5"> + {message.source === "automation" && ( + <div className="mb-1 flex items-center gap-1 text-[11px] leading-none text-muted-foreground/70"> + <Clock className="size-3" strokeWidth={1.8} /> + <span>Sent via automation</span> + </div> + )} <div className="conversation-body-text w-full overflow-hidden rounded-md bg-accent/55 px-3 py-2 leading-7" style={{ fontSize: `${settings.chatFontSize}px` }} diff --git a/src/lib/api.ts b/src/lib/api.ts index 1e49f42ca..f7036a527 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2310,6 +2310,7 @@ export type UiMutationEvent = | { type: "slackTokenInvalidated"; teamId: string } | { type: "fastModeUnavailable"; sessionId: string; reason: string } | { type: "pairedDevicesChanged" } + | { type: "automationsChanged" } | { type: "terminalSessionIdle"; sessionId: string; workspaceId: string } | { type: "terminalPromptCaptured"; @@ -3430,6 +3431,8 @@ export type ThreadMessageLike = { content: ExtendedMessagePart[]; status?: { type: string; reason?: string }; streaming?: boolean; + /** Initiator of a user message: absent = human, "automation" = scheduler. */ + source?: string; }; // --------------------------------------------------------------------------- @@ -3815,6 +3818,89 @@ export async function listActiveStreams(): Promise<ActiveStreamSummary[]> { return await invoke<ActiveStreamSummary[]>("list_active_streams"); } +// --------------------------------------------------------------------------- +// Automations — scheduled recurring prompts +// --------------------------------------------------------------------------- + +export type AutomationSchedule = + | { kind: "hourly" } + | { kind: "daily"; time: string } + | { kind: "weekly"; weekday: number; time: string } + | { kind: "every"; amount: number; unit: "minutes" | "hours" }; + +export type AutomationRunsIn = "chat" | "workspace"; +export type AutomationStatus = "active" | "paused"; + +export type Automation = { + id: string; + title: string; + prompt: string; + runsIn: AutomationRunsIn; + sessionId: string | null; + workspaceId: string | null; + schedule: AutomationSchedule; + status: AutomationStatus; + nextRunAt: string; + lastRunAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type CreateAutomationRequest = { + title: string; + prompt: string; + runsIn: AutomationRunsIn; + sessionId?: string; + workspaceId?: string; + schedule: AutomationSchedule; +}; + +export type UpdateAutomationRequest = { + id: string; + title?: string; + prompt?: string; + runsIn?: AutomationRunsIn; + sessionId?: string; + workspaceId?: string; + schedule?: AutomationSchedule; +}; + +export async function listAutomations(): Promise<Automation[]> { + return await invoke<Automation[]>("list_automations"); +} + +export async function createAutomation( + request: CreateAutomationRequest, +): Promise<Automation> { + return await invoke<Automation>("create_automation", { request }); +} + +export async function updateAutomation( + request: UpdateAutomationRequest, +): Promise<Automation> { + return await invoke<Automation>("update_automation", { request }); +} + +export async function deleteAutomation(automationId: string): Promise<void> { + await invoke<void>("delete_automation", { automationId }); +} + +/** Pause/resume. Resume recomputes nextRunAt from now (no immediate fire). */ +export async function setAutomationStatus( + automationId: string, + status: AutomationStatus, +): Promise<Automation> { + return await invoke<Automation>("set_automation_status", { + automationId, + status, + }); +} + +/** Dispatch immediately. Returns the session id the run landed in. */ +export async function runAutomationNow(automationId: string): Promise<string> { + return await invoke<string>("run_automation_now", { automationId }); +} + export type AgentSteerRequest = { sessionId: string; provider?: string; diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 18e9d3cf2..9843315d4 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -4,6 +4,7 @@ import type { ThreadMessageLike } from "./api"; import { type ActionKind, type AgentProvider, + type Automation, type ChangeRequestInfo, DEFAULT_PROVIDER_CAPABILITIES, DEFAULT_WORKSPACE_GROUPS, @@ -23,6 +24,7 @@ import { getWorkspaceAccountProfile, getWorkspaceForge, listActiveStreams, + listAutomations, listForgeAccounts, listForgeLabels, listInboxKindLabels, @@ -164,6 +166,7 @@ export const helmorQueryKeys = { ["slackThread", teamId, channelId, anchorTs] as const, slackEmojiMap: (teamId: string) => ["slackEmojiMap", teamId] as const, pairedDevices: ["pairedDevices"] as const, + automations: ["automations"] as const, }; /** Persistence is opt-in per `queryOptions` via `meta: { persist: true }`. @@ -327,6 +330,14 @@ export function workspaceGroupsQueryOptions() { }); } +export function automationsQueryOptions() { + return queryOptions<Automation[]>({ + queryKey: helmorQueryKeys.automations, + queryFn: listAutomations, + staleTime: 0, + }); +} + export function archivedWorkspacesQueryOptions() { return queryOptions({ queryKey: helmorQueryKeys.archivedWorkspaces, diff --git a/src/router/index.tsx b/src/router/index.tsx index 5ef9e9373..b5cb6aaeb 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -104,6 +104,12 @@ const startRoute = createRoute({ component: () => null, }); +const automationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/automations", + component: () => null, +}); + const workspaceRoute = createRoute({ getParentRoute: () => rootRoute, path: "/w/$workspaceId", @@ -123,6 +129,7 @@ const workspaceSessionRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, startRoute, + automationsRoute, workspaceRoute, workspaceSessionRoute, ]); diff --git a/src/router/location-mapping.test.ts b/src/router/location-mapping.test.ts index 173367726..0a0379f50 100644 --- a/src/router/location-mapping.test.ts +++ b/src/router/location-mapping.test.ts @@ -7,6 +7,8 @@ import { } from "@tanstack/react-router"; import { describe, expect, it } from "vitest"; import { + locationToSelection, + locationToSettingsPatch, locationToViewInfo, pathToSelection, selectionToLocation, @@ -240,26 +242,48 @@ describe("locationToViewInfo", () => { expect(locationToViewInfo({ pathname: "/start", search: {} })).toEqual({ isStart: true, isEditor: false, + isAutomations: false, }); expect(locationToViewInfo({ pathname: "/", search: {} })).toEqual({ isStart: false, isEditor: false, + isAutomations: false, }); expect( locationToViewInfo({ pathname: "/w/ws1/s/sess1", search: {} }), - ).toEqual({ isStart: false, isEditor: false }); + ).toEqual({ isStart: false, isEditor: false, isAutomations: false }); }); it("flags ?view=editor as editor", () => { expect( locationToViewInfo({ pathname: "/w/ws1", search: { view: "editor" } }), - ).toEqual({ isStart: false, isEditor: true }); + ).toEqual({ isStart: false, isEditor: true, isAutomations: false }); expect( locationToViewInfo({ pathname: "/w/ws1/s/sess1", search: { view: "editor" }, }), - ).toEqual({ isStart: false, isEditor: true }); + ).toEqual({ isStart: false, isEditor: true, isAutomations: false }); + }); + + it("flags /automations and round-trips it through the selection mapping", () => { + expect( + locationToViewInfo({ pathname: "/automations", search: {} }), + ).toEqual({ isStart: false, isEditor: false, isAutomations: true }); + expect( + selectionToLocation({ + viewMode: "automations", + workspaceId: "ws1", // ignored — automations is a global page + sessionId: null, + }), + ).toEqual({ to: "/automations" }); + expect( + locationToSelection({ pathname: "/automations", search: {} }), + ).toEqual({ workspaceId: null, sessionId: null, viewMode: "automations" }); + // Never persisted — relaunch restores the last real surface. + expect( + locationToSettingsPatch({ pathname: "/automations", search: {} }), + ).toEqual({}); }); it("treats an absent or conversation view as conversation (not editor)", () => { @@ -410,7 +434,7 @@ describe("router location round-trips through the mapping", () => { pathname: router.state.location.pathname, search: router.state.location.search, }), - ).toEqual({ isStart: false, isEditor: false }); + ).toEqual({ isStart: false, isEditor: false, isAutomations: false }); }); it("keeps ?view=editor in the stored search", async () => { @@ -428,7 +452,7 @@ describe("router location round-trips through the mapping", () => { pathname: router.state.location.pathname, search: router.state.location.search, }), - ).toEqual({ isStart: false, isEditor: true }); + ).toEqual({ isStart: false, isEditor: true, isAutomations: false }); }); it("navigates to the distinct /start route", async () => { @@ -441,7 +465,7 @@ describe("router location round-trips through the mapping", () => { pathname: router.state.location.pathname, search: router.state.location.search, }), - ).toEqual({ isStart: true, isEditor: false }); + ).toEqual({ isStart: true, isEditor: false, isAutomations: false }); }); it("a bogus ?view never throws and falls back to conversation", async () => { diff --git a/src/router/location-mapping.ts b/src/router/location-mapping.ts index 532da6538..85de02688 100644 --- a/src/router/location-mapping.ts +++ b/src/router/location-mapping.ts @@ -52,6 +52,7 @@ export type PathSelection = { export type SelectionLocation = | { to: "/" } | { to: "/start" } + | { to: "/automations" } | { to: "/w/$workspaceId"; params: { workspaceId: string }; @@ -83,6 +84,7 @@ export function selectionToLocation({ sessionId, }: SelectionLocationInput): SelectionLocation { if (viewMode === "start") return { to: "/start" }; + if (viewMode === "automations") return { to: "/automations" }; if (!workspaceId) return { to: "/" }; if (sessionId) { return { @@ -111,6 +113,7 @@ export function selectionToPath({ sessionId, }: SelectionLocationInput): string { if (viewMode === "start") return "/start"; + if (viewMode === "automations") return "/automations"; if (!workspaceId) return "/"; const encodedWorkspace = encodeURIComponent(workspaceId); if (sessionId) { @@ -146,6 +149,7 @@ export function pathToSelection(pathname: string): PathSelection { export type LocationViewInfo = { isStart: boolean; isEditor: boolean; + isAutomations: boolean; }; /** @@ -170,6 +174,7 @@ export function locationToViewInfo({ return { isStart: pathname === "/start", isEditor: (search as { view?: string }).view === "editor", + isAutomations: pathname === "/automations", }; } @@ -203,13 +208,18 @@ export function locationToSelection({ pathname: string; search: { view?: string } | Record<string, unknown>; }): LocationSelection { - const { isStart, isEditor } = locationToViewInfo({ pathname, search }); + const { isStart, isEditor, isAutomations } = locationToViewInfo({ + pathname, + search, + }); const { workspaceId, sessionId } = pathToSelection(pathname); const viewMode: ShellViewMode = isStart ? "start" - : isEditor - ? "editor" - : "conversation"; + : isAutomations + ? "automations" + : isEditor + ? "editor" + : "conversation"; return { workspaceId, sessionId, viewMode }; } @@ -236,10 +246,15 @@ export function locationToSettingsPatch({ pathname: string; search: { view?: string } | Record<string, unknown>; }): Partial<AppSettings> { - const { isStart } = locationToViewInfo({ pathname, search }); + const { isStart, isAutomations } = locationToViewInfo({ pathname, search }); if (isStart) { return { lastSurface: "workspace-start" }; } + if (isAutomations) { + // Like the boot index: never persisted. Relaunch restores the last + // real workspace/start surface, not the Automations page. + return {}; + } const { workspaceId, sessionId } = pathToSelection(pathname); if (!workspaceId) { // Boot index `/` — never persisted historically. diff --git a/src/router/navigate-selection.ts b/src/router/navigate-selection.ts index e061d3daf..5c1e22d63 100644 --- a/src/router/navigate-selection.ts +++ b/src/router/navigate-selection.ts @@ -27,6 +27,9 @@ function dispatch(location: SelectionLocation): void { case "/start": void router.navigate({ to: "/start", replace: true }); return; + case "/automations": + void router.navigate({ to: "/automations", replace: true }); + return; case "/w/$workspaceId": void router.navigate({ to: "/w/$workspaceId", @@ -69,10 +72,16 @@ function alreadyAtTarget(input: SelectionLocationInput): boolean { if (!samePath) return false; const targetIsStart = input.viewMode === "start"; const targetIsEditor = input.viewMode === "editor"; + const targetIsAutomations = input.viewMode === "automations"; const currentIsStart = current.pathname === "/start"; const currentIsEditor = (current.search as { view?: string }).view === "editor"; - return currentIsStart === targetIsStart && currentIsEditor === targetIsEditor; + const currentIsAutomations = current.pathname === "/automations"; + return ( + currentIsStart === targetIsStart && + currentIsEditor === targetIsEditor && + currentIsAutomations === targetIsAutomations + ); } /** diff --git a/src/shell/components/app-shell.tsx b/src/shell/components/app-shell.tsx index 71dfa8132..9e27f54e6 100644 --- a/src/shell/components/app-shell.tsx +++ b/src/shell/components/app-shell.tsx @@ -120,6 +120,8 @@ export function AppShell({ onCollapseSidebar: () => panels.setSidebarCollapsed(true), onOpenFeedback: () => s.setFeedbackOpen(true), onOpenSettings: data.handleOpenSettings, + onOpenAutomations: () => + sel.selectionActions.setViewMode("automations"), pushWorkspaceToast: s.pushWorkspaceToast, }} sidebarCollapsed={panels.sidebarCollapsed} diff --git a/src/shell/components/shell-sidebar-pane.tsx b/src/shell/components/shell-sidebar-pane.tsx index c25362d84..617fdfd11 100644 --- a/src/shell/components/shell-sidebar-pane.tsx +++ b/src/shell/components/shell-sidebar-pane.tsx @@ -1,6 +1,6 @@ // Left workspace sidebar — workspaces list, app-update button, sidebar // collapse, and the settings entry button at the bottom. -import { PanelLeftClose } from "lucide-react"; +import { Clock, PanelLeftClose } from "lucide-react"; import { useLayoutEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { @@ -51,9 +51,35 @@ type Props = { onCollapseSidebar: () => void; onOpenFeedback: () => void; onOpenSettings: () => void; + onOpenAutomations: () => void; pushWorkspaceToast: PushWorkspaceToast; }; +function AutomationsButton({ onClick }: { onClick: () => void }) { + return ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + aria-label="Automations" + onClick={onClick} + className="text-muted-foreground hover:text-foreground" + > + <Clock className="size-[15px]" strokeWidth={1.8} /> + </Button> + </TooltipTrigger> + <TooltipContent + side="top" + sideOffset={4} + className="flex h-[24px] items-center rounded-md px-2 text-small leading-none" + > + <span className="leading-none">Automations</span> + </TooltipContent> + </Tooltip> + ); +} + export function ShellSidebarPane({ collapsed, resizing, @@ -74,6 +100,7 @@ export function ShellSidebarPane({ onCollapseSidebar, onOpenFeedback, onOpenSettings, + onOpenAutomations, pushWorkspaceToast, }: Props) { const { t } = useI18n(); @@ -206,6 +233,7 @@ export function ShellSidebarPane({ onClick={onOpenSettings} shortcut={getShortcut(appSettings.shortcuts, "settings.open")} /> + <AutomationsButton onClick={onOpenAutomations} /> <FeedbackButton onClick={onOpenFeedback} /> </div> <AppUpdateButton status={appUpdateStatus} /> diff --git a/src/shell/components/workspace-pane-surface.tsx b/src/shell/components/workspace-pane-surface.tsx index eb9dfb9f5..d69e97c55 100644 --- a/src/shell/components/workspace-pane-surface.tsx +++ b/src/shell/components/workspace-pane-surface.tsx @@ -4,6 +4,8 @@ // branch and renders either <StartSurfacePane> (start) or // <ShellWorkspaceConversation> (chat) below the editor. Selection-track reads // stay inside ShellWorkspaceConversation; only the delivery channel moved. + +import { AutomationsSurface } from "@/features/automations"; import type { ComposerCreateContext, WorkspaceConversationContainerProps, @@ -28,6 +30,11 @@ import type { StartSurfaceActions } from "@/shell/controllers/use-start-surface- import { ShellWorkspaceConversation } from "./shell-workspace-conversation"; import { StartSurfacePane } from "./start-surface-pane"; +// Prefilled prompt for "Create via chat" (mirrors Codex's flow): the agent +// explains automations and creates one via the `helmor automation` CLI. +const CREATE_AUTOMATION_VIA_CHAT_PROMPT = + "I want to set up an automation. Briefly explain how automations work in Helmor (run `helmor automation create --help` to see the options), then ask me a few questions to figure out what I'd like it to do and when it should run. Once we've agreed, create it with the helmor CLI."; + type ConversationProps = WorkspaceConversationContainerProps; type StartPageProps = Parameters<typeof WorkspaceStartPage>[0]; @@ -174,7 +181,26 @@ export function WorkspacePaneSurface({ : "flex min-h-0 flex-1 flex-col" } > - {workspaceViewMode === "start" ? ( + {workspaceViewMode === "automations" ? ( + <AutomationsSurface + onOpenSession={(workspaceId, sessionId) => { + selectionActions.selectWorkspace(workspaceId); + selectionActions.selectSession(sessionId); + }} + onCreateViaChat={() => { + selectionActions.openStart(); + pendingQueueActions.insertIntoComposer({ + target: { contextKey: startComposerContextKey }, + items: [ + { + kind: "text", + text: CREATE_AUTOMATION_VIA_CHAT_PROMPT, + }, + ], + }); + }} + /> + ) : workspaceViewMode === "start" ? ( <StartSurfacePane repositories={repositories} startRepository={startRepository} diff --git a/src/shell/controllers/use-selection-controller.ts b/src/shell/controllers/use-selection-controller.ts index 97592abe3..1f80987c1 100644 --- a/src/shell/controllers/use-selection-controller.ts +++ b/src/shell/controllers/use-selection-controller.ts @@ -56,7 +56,7 @@ import { flattenWorkspaceRows, } from "@/shell/layout"; -export type ShellViewMode = "conversation" | "editor" | "start"; +export type ShellViewMode = "conversation" | "editor" | "start" | "automations"; // Trailing window before the per-switch fire-and-forget IPC (git fetch + // slash-command prewarm) runs. Matches the inspector settle window so a held diff --git a/src/shell/hooks/use-ui-sync-bridge.ts b/src/shell/hooks/use-ui-sync-bridge.ts index 571812a55..d1175bca7 100644 --- a/src/shell/hooks/use-ui-sync-bridge.ts +++ b/src/shell/hooks/use-ui-sync-bridge.ts @@ -278,6 +278,11 @@ function handleUiMutation( queryKey: helmorQueryKeys.pairedDevices, }); return; + case "automationsChanged": + void queryClient.invalidateQueries({ + queryKey: helmorQueryKeys.automations, + }); + return; case "terminalSessionIdle": // Terminal turn finished (agent Stop hook). Re-dispatch as the // window event the read-state controller already listens on, so