From 140ceb7fdaed864e29c4c1fc441d4a0724ee2e81 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 19 May 2026 03:41:49 -0400 Subject: [PATCH] Add automation CLI commands (#2315) Co-authored-by: Orca --- skills/orca-cli/SKILL.md | 28 +- src/cli/args.ts | 43 +- src/cli/dispatch.ts | 2 + src/cli/format.ts | 64 +++ src/cli/handlers/automations.ts | 338 +++++++++++++ src/cli/help.ts | 23 +- src/cli/index.test.ts | 444 ++++++++++++++++++ src/cli/index.ts | 3 +- src/cli/specs/automations.ts | 91 ++++ src/cli/specs/index.ts | 2 + src/main/automations/service.test.ts | 35 ++ src/main/automations/service.ts | 16 +- src/main/index.ts | 1 + .../runtime/orca-runtime-automations.test.ts | 141 ++++++ src/main/runtime/orca-runtime.ts | 200 ++++++++ .../runtime/rpc/methods/automations.test.ts | 120 +++++ src/main/runtime/rpc/methods/automations.ts | 116 +++++ src/main/runtime/rpc/methods/index.ts | 2 + 18 files changed, 1657 insertions(+), 12 deletions(-) create mode 100644 src/cli/handlers/automations.ts create mode 100644 src/cli/specs/automations.ts create mode 100644 src/main/runtime/orca-runtime-automations.test.ts create mode 100644 src/main/runtime/rpc/methods/automations.test.ts create mode 100644 src/main/runtime/rpc/methods/automations.ts diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index f44cdae4b..bd640c577 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -2,8 +2,9 @@ name: orca-cli description: >- Use the `orca` CLI to drive a running Orca editor — manage Orca worktrees; - create, read, and run shell commands in Orca-managed terminals; and automate - Orca's built-in browser (snapshot/click/fill/screenshot/tabs). Use this + create and manage scheduled automations; create, read, and run shell commands + in Orca-managed terminals; and automate Orca's built-in browser + (snapshot/click/fill/screenshot/tabs). Use this instead of raw `git worktree`, ad hoc shell PTYs, or Playwright whenever the task touches Orca state. Coding agents inside an Orca worktree should also use it to keep the worktree comment fresh at meaningful checkpoints. Boundary with @@ -27,6 +28,7 @@ Use `orca` for: - updating the current worktree comment with meaningful progress checkpoints - reading Orca-managed terminals and sending input to non-agent terminals - stopping or waiting on Orca-managed terminals +- creating and managing scheduled Orca automations - accessing repos known to Orca Do not use `orca` when plain shell tools are simpler and Orca state does not matter. @@ -36,6 +38,7 @@ Examples: - updating the current worktree comment after a significant checkpoint, such as reproducing a bug, validating a fix, or handing off for review - finding the Claude Code terminal for a worktree and reading its status - checking which Orca worktrees have live terminal activity +- creating a scheduled automation that runs a prompt against a known repo or worktree ## Preconditions @@ -98,6 +101,7 @@ orca terminal list --json 4. Act through Orca: - `worktree create/set/rm` +- `automations list/show/create/edit/remove/run/runs` - `terminal read/send/wait/stop` 5. When the agent reaches a significant checkpoint in the current worktree, update the Orca worktree comment so the UI reflects the latest work-in-progress: @@ -142,6 +146,25 @@ Worktree selectors supported in focused v1: - `issue:` - `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd` +### Automations + +```bash +orca automations list --json +orca automations show --json +orca automations create --name "Daily review" --trigger daily --time 09:00 --prompt "Review open changes" --provider codex --repo id: --json +orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo path:/abs/repo --disabled --json +orca automations edit --name "Weekday review" --trigger weekdays --time 09:30 --json +orca automations run --json +orca automations runs --id --json +orca automations remove --json +``` + +Automation schedules accept `hourly`, `daily`, `weekdays`, `weekly`, a 5-field cron expression, or an RRULE string. Use `--time ` with `daily`, `weekdays`, or `weekly`; use `--day <0-6>` only with `weekly`, where Sunday is `0`. + +Use `--repo ` for a new worktree per run, or `--workspace ` / `--workspace-mode existing` when the automation should run in an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive. + +Why: automations are persisted through the running Orca runtime, so use the CLI instead of editing automation storage files directly. Prefer `--disabled` when creating an automation during tests or setup so it cannot run before the user reviews it. + ### Terminal Use selectors to discover terminals, then use the returned handle for repeated live interaction. @@ -175,6 +198,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea ## Agent Guidance - If the user says to create/manage an Orca worktree, use `orca worktree ...`, not raw `git worktree ...`. +- If the user says to create/manage a scheduled Orca automation, use `orca automations ...`, not direct persistence edits. - Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action. - Prefer `--json` for all machine-driven use. - Use `worktree ps` as the first summary view when many worktrees may exist. diff --git a/src/cli/args.ts b/src/cli/args.ts index 59472dcd2..5b9933063 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -3,6 +3,7 @@ import { RuntimeClientError } from './runtime-client' export type ParsedArgs = { commandPath: string[] flags: Map + positionalFlagConflicts?: string[] } export type CommandSpec = { @@ -10,6 +11,7 @@ export type CommandSpec = { summary: string usage: string allowedFlags: string[] + positionalArgs?: string[] examples?: string[] notes?: string[] } @@ -62,7 +64,9 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['open', 'status'].includes(commandPath[0])) { return false } - if (['repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])) { + if ( + ['automations', 'repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0]) + ) { return false } return ![ @@ -79,6 +83,7 @@ export function isCommandGroup(commandPath: string[]): boolean { return ( (commandPath.length === 1 && [ + 'automations', 'repo', 'worktree', 'terminal', @@ -101,6 +106,33 @@ export function isCommandGroup(commandPath: string[]): boolean { ) } +export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs { + for (const spec of specs) { + const positionalArgs = spec.positionalArgs ?? [] + if (positionalArgs.length === 0) { + continue + } + if (parsed.commandPath.length !== spec.path.length + positionalArgs.length) { + continue + } + if (!matches(parsed.commandPath.slice(0, spec.path.length), spec.path)) { + continue + } + const flags = new Map(parsed.flags) + const values = parsed.commandPath.slice(spec.path.length) + // Why: validation runs inside main's error-reporting path, so normalization + // records ambiguity instead of throwing before CLI errors can be formatted. + const positionalFlagConflicts = positionalArgs.filter((name) => flags.has(name)) + positionalArgs.forEach((name, index) => { + if (!flags.has(name)) { + flags.set(name, values[index]) + } + }) + return { commandPath: spec.path, flags, positionalFlagConflicts } + } + return parsed +} + export function findCommandSpec( specs: CommandSpec[], commandPath: string[] @@ -117,6 +149,15 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs ) } + if (parsed.positionalFlagConflicts && parsed.positionalFlagConflicts.length > 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Pass ${parsed.positionalFlagConflicts + .map((flag) => `--${flag}`) + .join(', ')} either positionally or as a flag, not both.` + ) + } + for (const flag of parsed.flags.keys()) { if ( !spec.allowedFlags.includes(flag) && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a9adbadf5..976987645 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,6 +1,7 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' import { CORE_HANDLERS } from './handlers/core' +import { AUTOMATION_HANDLERS } from './handlers/automations' import { REPO_HANDLERS } from './handlers/repo' import { WORKTREE_HANDLERS } from './handlers/worktree' import { TERMINAL_HANDLERS } from './handlers/terminal' @@ -29,6 +30,7 @@ function buildHandlers(): Map { const table = new Map() const groups = [ CORE_HANDLERS, + AUTOMATION_HANDLERS, REPO_HANDLERS, WORKTREE_HANDLERS, TERMINAL_HANDLERS, diff --git a/src/cli/format.ts b/src/cli/format.ts index 9518dfde6..e40a22906 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -33,6 +33,8 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { Automation, AutomationRun } from '../shared/automations-types' +import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -282,6 +284,68 @@ export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): .join('\n') } +export function formatAutomationList(result: { automations: Automation[] }): string { + if (result.automations.length === 0) { + return 'No automations found.' + } + return result.automations + .map((automation) => { + const status = automation.enabled ? 'enabled' : 'disabled' + return `${automation.id} ${automation.name} ${automation.agentId} ${status}\n${formatAutomationSchedule(automation.rrule)} next: ${new Date(automation.nextRunAt).toISOString()}` + }) + .join('\n\n') +} + +export function formatAutomationShow(result: { automation: Automation }): string { + const automation = result.automation + return [ + `id: ${automation.id}`, + `name: ${automation.name}`, + `provider: ${automation.agentId}`, + `enabled: ${automation.enabled}`, + `schedule: ${formatAutomationSchedule(automation.rrule)}`, + `rrule: ${automation.rrule}`, + `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, + `projectId: ${automation.projectId}`, + `workspaceMode: ${automation.workspaceMode}`, + `workspaceId: ${automation.workspaceId ?? 'null'}`, + `baseBranch: ${automation.baseBranch ?? 'null'}`, + `target: ${automation.executionTargetType}:${automation.executionTargetId}`, + `prompt: ${automation.prompt}` + ].join('\n') +} + +export function formatAutomationRemoved(result: { removed: boolean; id: string }): string { + return result.removed + ? `Removed automation ${result.id}.` + : `Automation ${result.id} not removed.` +} + +export function formatAutomationRun(result: { run: AutomationRun }): string { + return [ + `id: ${result.run.id}`, + `automationId: ${result.run.automationId}`, + `title: ${result.run.title}`, + `status: ${result.run.status}`, + `trigger: ${result.run.trigger}`, + `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, + `workspaceId: ${result.run.workspaceId ?? 'null'}`, + `error: ${result.run.error ?? 'null'}` + ].join('\n') +} + +export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { + if (result.runs.length === 0) { + return 'No automation runs found.' + } + return result.runs + .map( + (run) => + `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.error ? `\nerror: ${run.error}` : ''}` + ) + .join('\n\n') +} + export function formatSnapshot(result: BrowserSnapshotResult): string { const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n` return header + result.snapshot diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts new file mode 100644 index 000000000..f5c0fcfc0 --- /dev/null +++ b/src/cli/handlers/automations.ts @@ -0,0 +1,338 @@ +/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */ +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationSchedulePreset, + AutomationUpdateInput +} from '../../shared/automations-types' +import type { TuiAgent } from '../../shared/types' +import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' +import { isTuiAgent } from '../../shared/tui-agent-config' +import type { CommandHandler } from '../dispatch' +import { + formatAutomationList, + formatAutomationRemoved, + formatAutomationRun, + formatAutomationRuns, + formatAutomationShow, + printResult +} from '../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' +import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors' + +type AutomationCreateParams = Omit & { + repo?: string + timezone?: string + workspace?: string +} + +type AutomationUpdateParams = AutomationUpdateInput & { + repo?: string + workspace?: string +} + +const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) +const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const + +function getScheduleModifierFlag(flags: Map): string | undefined { + return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) +} + +function validateScheduleModifierApplicability( + flags: Map, + raw: string +): void { + const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + if (!isPreset) { + if (flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time can only be used with preset automation triggers' + ) + } + if (flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } + return + } + if (raw === 'hourly' && flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' + ) + } + if (raw !== 'weekly' && flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } +} + +function getOptionalDayFlag(flags: Map): number | undefined { + const raw = flags.get('day') + if (raw === undefined) { + return undefined + } + const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN + if (!Number.isInteger(day) || day < 0 || day > 6) { + throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') + } + return day +} + +function getProviderFlag(flags: Map): TuiAgent { + const provider = getRequiredStringFlag(flags, 'provider') + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getOptionalProviderFlag(flags: Map): TuiAgent | undefined { + const provider = getOptionalStringFlag(flags, 'provider') + if (provider === undefined) { + return undefined + } + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getTimeFlag(flags: Map): { hour: number; minute: number } { + const value = flags.get('time') + if (value === undefined) { + return { hour: 9, minute: 0 } + } + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + return parseTimeFlag(value) +} + +function parseTimeFlag(value: string): { hour: number; minute: number } { + const match = /^(\d{1,2}):(\d{2})$/.exec(value) + if (!match) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + const hour = Number(match[1]) + const minute = Number(match[2]) + if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') + } + return { hour, minute } +} + +function getScheduleFlag( + flags: Map, + required: boolean +): { rrule: string; dtstart: number } | undefined { + const trigger = getOptionalStringFlag(flags, 'trigger') + const schedule = getOptionalStringFlag(flags, 'schedule') + if (trigger && schedule) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --trigger or --schedule, not both.' + ) + } + const raw = trigger ?? schedule + if (!raw) { + const modifier = getScheduleModifierFlag(flags) + if (modifier) { + throw new RuntimeClientError( + 'invalid_argument', + `--${modifier} requires --trigger or --schedule` + ) + } + if (required) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + return undefined + } + if (raw === 'manual') { + throw new RuntimeClientError( + 'invalid_argument', + 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' + ) + } + validateScheduleModifierApplicability(flags, raw) + const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) + const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 + const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + ? buildAutomationRrule({ + preset: raw as Exclude, + hour, + minute, + dayOfWeek + }) + : raw + if (!isValidAutomationSchedule(rrule)) { + throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) + } + return { rrule, dtstart: Date.now() } +} + +function getEnabledFlag(flags: Map): boolean | undefined { + const enabledFlag = flags.get('enabled') + const disabledFlag = flags.get('disabled') + if (typeof enabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') + } + if (typeof disabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') + } + const enabled = enabledFlag === true + const disabled = disabledFlag === true + if (enabled && disabled) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --enabled or --disabled, not both.' + ) + } + if (enabled) { + return true + } + if (disabled) { + return false + } + return undefined +} + +function getWorkspaceModeFlag( + flags: Map +): 'existing' | 'new_per_run' | undefined { + const value = getOptionalStringFlag(flags, 'workspace-mode') + if (value === undefined) { + return undefined + } + if (value === 'existing') { + return 'existing' + } + if (value === 'new-per-run' || value === 'new_per_run') { + return 'new_per_run' + } + throw new RuntimeClientError( + 'invalid_argument', + '--workspace-mode must be existing or new-per-run' + ) +} + +async function resolveDefaultTarget( + flags: Map, + cwd: string, + client: Parameters[0]['client'] +): Promise<{ repo?: string; workspace?: string }> { + const repo = getOptionalStringFlag(flags, 'repo') + if (repo && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') + } + const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) + if (repo || workspace) { + return { repo, workspace } + } + if (client.isRemote) { + return {} + } + try { + return { workspace: await resolveCurrentWorktreeSelector(cwd, client) } + } catch { + return {} + } +} + +async function getExplicitTarget( + flags: Map, + cwd: string, + client: Parameters[0]['client'] +): Promise<{ repo?: string; workspace?: string }> { + const repo = getOptionalStringFlag(flags, 'repo') + if (repo && getOptionalStringFlag(flags, 'workspace')) { + throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.') + } + const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client) + return { repo, workspace } +} + +export const AUTOMATION_HANDLERS: Record = { + 'automations list': async ({ client, json }) => { + const result = await client.call<{ automations: Automation[] }>('automation.list') + printResult(result, json, formatAutomationList) + }, + 'automations show': async ({ flags, client, json }) => { + const result = await client.call<{ automation: Automation }>('automation.show', { + id: getRequiredStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationShow) + }, + 'automations create': async ({ flags, client, cwd, json }) => { + const schedule = getScheduleFlag(flags, true) + if (!schedule) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + const target = await resolveDefaultTarget(flags, cwd, client) + const workspaceMode = + getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run') + const result = await client.call<{ automation: Automation }>('automation.create', { + name: getRequiredStringFlag(flags, 'name'), + prompt: getRequiredStringFlag(flags, 'prompt'), + agentId: getProviderFlag(flags), + repo: target.repo, + workspace: target.workspace, + workspaceMode, + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + timezone: getOptionalStringFlag(flags, 'timezone'), + enabled: getEnabledFlag(flags), + missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), + ...schedule + } satisfies AutomationCreateParams) + printResult(result, json, formatAutomationShow) + }, + 'automations edit': async ({ flags, client, cwd, json }) => { + const target = await getExplicitTarget(flags, cwd, client) + const schedule = getScheduleFlag(flags, false) + const result = await client.call<{ automation: Automation }>('automation.update', { + id: getRequiredStringFlag(flags, 'id'), + updates: { + name: getOptionalStringFlag(flags, 'name'), + prompt: getOptionalStringFlag(flags, 'prompt'), + agentId: getOptionalProviderFlag(flags), + repo: target.repo, + workspace: target.workspace, + workspaceMode: getWorkspaceModeFlag(flags), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + timezone: getOptionalStringFlag(flags, 'timezone'), + enabled: getEnabledFlag(flags), + missedRunGraceMinutes: getOptionalPositiveIntegerFlag(flags, 'missed-run-grace-minutes'), + ...schedule + } satisfies AutomationUpdateParams + }) + printResult(result, json, formatAutomationShow) + }, + 'automations remove': async ({ flags, client, json }) => { + const id = getRequiredStringFlag(flags, 'id') + const result = await client.call<{ removed: boolean; id: string }>('automation.delete', { id }) + printResult(result, json, formatAutomationRemoved) + }, + 'automations run': async ({ flags, client, json }) => { + const result = await client.call<{ run: AutomationRun }>('automation.runNow', { + id: getRequiredStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationRun) + }, + 'automations runs': async ({ flags, client, json }) => { + const result = await client.call<{ runs: AutomationRun[] }>('automation.runs', { + automationId: getOptionalStringFlag(flags, 'id') + }) + printResult(result, json, formatAutomationRuns) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 9c54fed81..f612ded8a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -17,6 +17,15 @@ Environments: environment show Show one saved remote Orca runtime environment rm Remove a saved remote Orca runtime +Automations: + automations list List scheduled Orca automations + automations show Show one Orca automation + automations create Create a scheduled Orca automation + automations edit Edit an Orca automation + automations remove Remove an Orca automation and its run history + automations run Run an Orca automation now + automations runs List automation run history + Repos: repo list List repos registered in Orca repo add Add a project to Orca by filesystem path @@ -345,7 +354,7 @@ export function formatFlagHelp(flag: string): string { key: '--key Key or combo to press, e.g. Escape or CmdOrCtrl+L', limit: '--limit Maximum number of rows to return', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', - name: '--name Name for the new worktree', + name: '--name Name for the new worktree or automation', 'no-parent': '--no-parent Force no parent lineage', 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', @@ -367,6 +376,18 @@ export function formatFlagHelp(flag: string): string { 'to-y': '--to-y Destination window-local y coordinate', worktree: '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current', + workspace: '--workspace Existing worktree selector for automation runs', + prompt: '--prompt Automation prompt to pass to the agent', + provider: '--provider Agent id such as codex, claude, or gemini', + trigger: '--trigger Automation schedule preset, cron, or RRULE', + schedule: '--schedule Alias for --trigger', + time: '--time Time used with daily/weekdays/weekly presets', + day: '--day <0-6> Day used with weekly preset, Sunday=0', + timezone: '--timezone IANA timezone for the automation', + enabled: '--enabled Enable the automation', + disabled: '--disabled Disable the automation', + 'workspace-mode': '--workspace-mode existing or new-per-run', + 'missed-run-grace-minutes': '--missed-run-grace-minutes Missed-run grace window', 'value-stdin': '--value-stdin Read set-value payload from stdin', 'window-id': '--window-id Target a window id from list-windows', 'window-index': '--window-index Target a window index from list-windows', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index addffeb3b..64b01d641 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1439,4 +1439,448 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined }) }) + + it('creates an automation for the enclosing worktree by default', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_automation_create', { + automation: { + id: 'auto-1', + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'repo-1::/tmp/repo/feature', + baseBranch: null, + timezone: 'America/Toronto', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: 'existing', + baseBranch: undefined, + timezone: undefined, + enabled: undefined, + missedRunGraceMinutes: undefined, + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: expect.any(Number) + }) + }) + + it('rejects invalid automation --day values before calling the runtime', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Weekly review', + '--trigger', + 'weekly', + '--day', + '7', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--day must be an integer from 0 to 6' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { + name: 'day on daily preset', + args: ['--trigger', 'daily', '--day', '2'], + message: '--day can only be used with the weekly automation preset' + }, + { + name: 'time on custom cron', + args: ['--trigger', '0 9 * * *', '--time', '10:30'], + message: '--time can only be used with preset automation triggers' + }, + { + name: 'time on hourly preset', + args: ['--trigger', 'hourly', '--time', '10:30'], + message: '--time cannot be used with the hourly automation preset' + } + ])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + ...args, + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { + name: 'create', + args: [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--time', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--json' + ] + }, + { + name: 'edit', + args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json'] + } + ])('rejects bare automation --time on $name', async ({ args }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(args, '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--time must use HH:MM format' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation edit schedule modifiers without a schedule flag', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--day requires --trigger or --schedule' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation create with both repo and workspace targets', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--repo', + 'id:repo-1', + '--workspace', + 'id:repo-1::/tmp/repo/feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --repo or --workspace, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects automation edit with both repo and workspace targets', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'edit', + 'auto-1', + '--repo', + 'id:repo-1', + '--workspace', + 'id:repo-1::/tmp/repo/feature', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Use either --repo or --workspace, not both.' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it.each([ + { flag: 'enabled', value: 'false', message: '--enabled does not take a value' }, + { flag: 'disabled', value: 'false', message: '--disabled does not take a value' } + ])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--repo', + 'id:repo-1', + `--${flag}`, + value, + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('resolves explicit automation create workspace active from cwd', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'automations', + 'create', + '--name', + 'Daily review', + '--trigger', + 'daily', + '--prompt', + 'Review open changes', + '--provider', + 'codex', + '--workspace', + 'active', + '--json' + ], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', { + name: 'Daily review', + prompt: 'Review open changes', + agentId: 'codex', + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: 'existing', + baseBranch: undefined, + timezone: undefined, + enabled: undefined, + missedRunGraceMinutes: undefined, + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: expect.any(Number) + }) + }) + + it('resolves explicit automation edit workspace current from cwd', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]), + okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['automations', 'edit', 'auto-1', '--workspace', 'current', '--enabled', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', { + id: 'auto-1', + updates: { + name: undefined, + prompt: undefined, + agentId: undefined, + repo: undefined, + workspace: `path:${path.resolve('/tmp/repo/feature')}`, + workspaceMode: undefined, + baseBranch: undefined, + timezone: undefined, + enabled: true, + missedRunGraceMinutes: undefined + } + }) + }) + + it('passes positional automation ids to edit, remove, run, and show', async () => { + queueFixtures( + callMock, + okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }), + okFixture('req_remove', { removed: true, id: 'auto-1' }), + okFixture('req_run', { + run: { + id: 'run-1', + automationId: 'auto-1', + title: 'Paused run 1', + status: 'pending', + trigger: 'manual', + scheduledFor: 1, + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + outputSnapshot: null, + usage: null, + error: null, + startedAt: null, + dispatchedAt: null, + createdAt: 1 + } + }), + okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo') + await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo') + await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo') + await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', { + id: 'auto-1', + updates: { + name: undefined, + prompt: undefined, + agentId: undefined, + repo: undefined, + workspace: undefined, + workspaceMode: undefined, + baseBranch: undefined, + timezone: undefined, + enabled: false, + missedRunGraceMinutes: undefined + } + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' }) + expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' }) + }) + + it('rejects ambiguous positional and flag automation ids before dispatch', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: 'Pass --id either positionally or as a flag, not both.' + } + }) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index d80789c47..247b73cea 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { findCommandSpec, isCommandGroup, + normalizeCommandPositionals, parseArgs, resolveHelpPath, validateCommandAndFlags @@ -20,7 +21,7 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { - const parsed = parseArgs(argv) + const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv)) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { printHelp(COMMAND_SPECS, helpPath) diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts new file mode 100644 index 000000000..5f71d137f --- /dev/null +++ b/src/cli/specs/automations.ts @@ -0,0 +1,91 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch'] +const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone'] +const AUTOMATION_STATE_FLAGS = ['enabled', 'disabled', 'missed-run-grace-minutes'] + +export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['automations', 'list'], + summary: 'List scheduled Orca automations', + usage: 'orca automations list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca automations list', 'orca automations list --json'] + }, + { + path: ['automations', 'show'], + summary: 'Show one Orca automation', + usage: 'orca automations show [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations show 2f9e...', 'orca automations show --id 2f9e... --json'] + }, + { + path: ['automations', 'create'], + summary: 'Create a scheduled Orca automation', + usage: + 'orca automations create --name --trigger --prompt --provider [--repo |--workspace ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'name', + 'prompt', + 'provider', + ...AUTOMATION_TARGET_FLAGS, + ...AUTOMATION_SCHEDULE_FLAGS, + ...AUTOMATION_STATE_FLAGS + ], + notes: [ + 'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.', + 'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.', + 'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.' + ], + examples: [ + 'orca automations create --name "Daily review" --trigger daily --prompt "Review open changes" --provider codex', + 'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo' + ] + }, + { + path: ['automations', 'edit'], + summary: 'Edit an Orca automation', + usage: 'orca automations edit [--name ] [--trigger ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'id', + 'name', + 'prompt', + 'provider', + ...AUTOMATION_TARGET_FLAGS, + ...AUTOMATION_SCHEDULE_FLAGS, + ...AUTOMATION_STATE_FLAGS + ], + positionalArgs: ['id'], + examples: [ + 'orca automations edit 2f9e... --disabled', + 'orca automations edit --id 2f9e... --trigger "30 * * * *" --json' + ] + }, + { + path: ['automations', 'remove'], + summary: 'Remove an Orca automation and its run history', + usage: 'orca automations remove [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations remove 2f9e...', 'orca automations remove --id 2f9e... --json'] + }, + { + path: ['automations', 'run'], + summary: 'Run an Orca automation now', + usage: 'orca automations run [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + positionalArgs: ['id'], + examples: ['orca automations run 2f9e...', 'orca automations run --id 2f9e... --json'] + }, + { + path: ['automations', 'runs'], + summary: 'List automation run history', + usage: 'orca automations runs [--id ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'], + examples: ['orca automations runs', 'orca automations runs --id 2f9e... --json'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 48c05a441..4695f831a 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -1,6 +1,7 @@ import type { CommandSpec } from '../args' import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced' import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' +import { AUTOMATION_COMMAND_SPECS } from './automations' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' @@ -8,6 +9,7 @@ import { ENVIRONMENT_COMMAND_SPECS } from './environment' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, + ...AUTOMATION_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 3ea54afd6..58651ceda 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -89,6 +89,41 @@ describe('AutomationService', () => { ) }) + it('returns the persisted status for manual runs after dispatch is requested', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Manual check', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const send = vi.fn() + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ + isDestroyed: () => false, + send + } as never) + service.setRendererReady() + + const run = await service.runNow(automation.id) + + expect(run.status).toBe('dispatching') + expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching') + expect(send).toHaveBeenCalledWith( + 'automations:dispatchRequested', + expect.objectContaining({ + run: expect.objectContaining({ id: run.id, status: 'dispatching' }) + }) + ) + }) + it('attaches provider usage when a completed run can be attributed', async () => { vi.setSystemTime(new Date('2026-05-13T10:00:00')) const store = await createStore() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 678b1ca62..655dc8d5d 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -69,8 +69,7 @@ export class AutomationService { throw new Error('Automation not found.') } const run = this.store.createAutomationRun(automation, Date.now(), 'manual') - await this.requestDispatch(automation, run) - return run + return await this.requestDispatch(automation, run) } async markDispatchResult(result: AutomationDispatchResult): Promise { @@ -214,25 +213,28 @@ export class AutomationService { this.store.advanceAutomationNextRun(automation.id, now) } - private async requestDispatch(automation: Automation, run: AutomationRun): Promise { + private async requestDispatch( + automation: Automation, + run: AutomationRun + ): Promise { const webContents = this.webContents if (!webContents || webContents.isDestroyed() || !this.rendererReady) { - this.store.updateAutomationRun({ + return this.store.updateAutomationRun({ runId: run.id, status: 'skipped_unavailable', workspaceId: automation.workspaceId, error: 'No Orca window was available to launch the automation.' }) - return } - this.store.updateAutomationRun({ + const updated = this.store.updateAutomationRun({ runId: run.id, status: 'dispatching', workspaceId: automation.workspaceId, error: null }) - const payload: AutomationDispatchRequest = { automation, run } + const payload: AutomationDispatchRequest = { automation, run: updated } webContents.send('automations:dispatchRequested', payload) + return updated } } diff --git a/src/main/index.ts b/src/main/index.ts index fa07a5c9f..149008ea1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -789,6 +789,7 @@ app.whenReady().then(async () => { }) runtime = runtimeService automations = new AutomationService(store, { claudeUsage, codexUsage }) + runtimeService.setAutomationService(automations) runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtimeService.setCommitMessageAgentEnvironmentResolvers({ prepareForCodexLaunch: () => diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts new file mode 100644 index 000000000..59435f49e --- /dev/null +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/types' + +const repo: Repo = { + id: 'repo-1', + path: '/tmp/orca', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' +} + +function makeStore(existingAutomations: Automation[] = []) { + return { + getRepos: vi.fn(() => [repo]), + createAutomation: vi.fn((input) => ({ + id: 'auto-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + createdAt: 1, + updatedAt: 1, + ...input + })), + listAutomations: vi.fn(() => existingAutomations), + listAutomationRuns: vi.fn(() => []), + updateAutomation: vi.fn((id, updates) => ({ ...existingAutomations[0], id, ...updates })), + deleteAutomation: vi.fn(), + getSettings: vi.fn(() => ({ + workspaceDir: '/tmp', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: '', + branchPrefixCustom: '' + })), + getAllWorktreeMeta: vi.fn(() => new Map()), + getWorktreeMeta: vi.fn(), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: vi.fn() + } +} + +const existingAutomation = { + id: 'auto-1', + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: 'main', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 +} satisfies Automation + +describe('OrcaRuntimeService automation methods', () => { + it('creates repo-scoped automations through the shared store', async () => { + const store = makeStore() + const runtime = new OrcaRuntimeService(store as never) + + const automation = await runtime.createAutomation({ + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1', + workspaceMode: 'new_per_run', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + + expect(store.createAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Daily review', + prompt: 'Review changes', + agentId: 'codex', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + workspaceId: null + }) + ) + expect(automation.id).toBe('auto-1') + }) + + it('updates and deletes existing automations through the shared store', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + const updated = await runtime.updateAutomation('auto-1', { enabled: false }) + const removed = runtime.deleteAutomation('auto-1') + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { enabled: false }) + expect(updated).toMatchObject({ + prompt: existingAutomation.prompt, + baseBranch: existingAutomation.baseBranch, + workspaceMode: existingAutomation.workspaceMode, + enabled: false + }) + expect(store.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(removed).toEqual({ removed: true, id: 'auto-1' }) + }) + + it('preserves explicit nullable fields in sparse automation updates', async () => { + const store = makeStore([existingAutomation]) + const runtime = new OrcaRuntimeService(store as never) + + await runtime.updateAutomation('auto-1', { baseBranch: null }) + + expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + }) + + it('rejects repo-only updates for existing-workspace automations', async () => { + const existing = { + ...existingAutomation, + workspaceMode: 'existing', + workspaceId: 'repo-1::/tmp/orca-worktree', + baseBranch: null + } satisfies Automation + const store = makeStore([existing]) + const runtime = new OrcaRuntimeService(store as never) + + await expect(runtime.updateAutomation('auto-1', { repo: 'repo-2' })).rejects.toThrow( + 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' + ) + expect(store.updateAutomation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 974f29601..ec76763d9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14,6 +14,13 @@ import { basename, isAbsolute, join } from 'path' import { mkdir, readdir, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' +import type { + Automation, + AutomationCreateInput, + AutomationRun, + AutomationUpdateInput, + AutomationWorkspaceMode +} from '../../shared/automations-types' import type { BaseRefSearchResult, CreateWorktreeResult, @@ -91,6 +98,7 @@ import type { BrowserTabInfo, BrowserScreencastResult } from '../../shared/runtime-types' +import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -323,6 +331,11 @@ type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] + listAutomations?: Store['listAutomations'] + listAutomationRuns?: Store['listAutomationRuns'] + createAutomation?: Store['createAutomation'] + updateAutomation?: Store['updateAutomation'] + deleteAutomation?: Store['deleteAutomation'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -339,6 +352,31 @@ type RuntimeStore = { updateSettings?: (updates: Partial) => unknown } +export type RuntimeAutomationCreateInput = Omit< + AutomationCreateInput, + 'projectId' | 'workspaceId' | 'workspaceMode' | 'timezone' +> & { + repo?: string + workspace?: string + workspaceMode?: AutomationWorkspaceMode + timezone?: string +} + +export type RuntimeAutomationUpdateInput = Omit< + AutomationUpdateInput, + 'projectId' | 'workspaceId' +> & { + repo?: string + workspace?: string +} + +function hasRuntimeAutomationUpdateValue( + updates: RuntimeAutomationUpdateInput, + key: K +): boolean { + return Object.hasOwn(updates, key) && updates[key] !== undefined +} + type RuntimeLeafRecord = RuntimeSyncedLeaf & { ptyGeneration: number connected: boolean @@ -908,6 +946,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null + private automationService: AutomationService | null = null private mobileDictation: { id: string owner: string @@ -961,6 +1000,163 @@ export class OrcaRuntimeService { return this.store.getUI() } + listAutomations(): Automation[] { + if (!this.store?.listAutomations) { + throw new Error('runtime_unavailable') + } + return this.store.listAutomations() + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + if (!this.store?.listAutomationRuns) { + throw new Error('runtime_unavailable') + } + return this.store.listAutomationRuns(automationId) + } + + showAutomation(id: string): Automation { + const automation = this.listAutomations().find((entry) => entry.id === id) + if (!automation) { + throw new Error('Automation not found.') + } + return automation + } + + async createAutomation(input: RuntimeAutomationCreateInput): Promise { + if (!this.store?.createAutomation) { + throw new Error('runtime_unavailable') + } + const target = await this.resolveAutomationTarget(input) + return this.store.createAutomation({ + name: input.name, + prompt: input.prompt, + agentId: input.agentId, + projectId: target.projectId, + workspaceMode: target.workspaceMode, + workspaceId: target.workspaceId, + baseBranch: input.baseBranch, + timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + rrule: input.rrule, + dtstart: input.dtstart, + enabled: input.enabled, + missedRunGraceMinutes: input.missedRunGraceMinutes + }) + } + + async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise { + if (!this.store?.updateAutomation) { + throw new Error('runtime_unavailable') + } + const current = this.showAutomation(id) + const patch: AutomationUpdateInput = {} + if (hasRuntimeAutomationUpdateValue(updates, 'name')) { + patch.name = updates.name + } + if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { + patch.prompt = updates.prompt + } + if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { + patch.agentId = updates.agentId + } + if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { + patch.baseBranch = updates.baseBranch + } + if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) { + patch.timezone = updates.timezone + } + if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) { + patch.rrule = updates.rrule + } + if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) { + patch.dtstart = updates.dtstart + } + if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) { + patch.enabled = updates.enabled + } + if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) { + patch.missedRunGraceMinutes = updates.missedRunGraceMinutes + } + const targetChanged = + hasRuntimeAutomationUpdateValue(updates, 'repo') || + hasRuntimeAutomationUpdateValue(updates, 'workspace') || + hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') + if (targetChanged) { + const target = await this.resolveAutomationTarget(updates, current) + patch.projectId = target.projectId + patch.workspaceMode = target.workspaceMode + patch.workspaceId = target.workspaceId + } + return this.store.updateAutomation(id, patch) + } + + deleteAutomation(id: string): { removed: boolean; id: string } { + if (!this.store?.deleteAutomation) { + throw new Error('runtime_unavailable') + } + this.showAutomation(id) + this.store.deleteAutomation(id) + return { removed: true, id } + } + + async runAutomationNow(id: string): Promise { + if (!this.automationService) { + throw new Error('runtime_unavailable') + } + return await this.automationService.runNow(id) + } + + private async resolveAutomationTarget( + input: { + repo?: string + workspace?: string + workspaceMode?: AutomationWorkspaceMode + baseBranch?: string | null + }, + current?: Automation + ): Promise<{ + projectId: string + workspaceMode: AutomationWorkspaceMode + workspaceId?: string | null + }> { + const hasRepo = input.repo !== undefined + const hasWorkspace = input.workspace !== undefined + if ( + current?.workspaceMode === 'existing' && + hasRepo && + !hasWorkspace && + input.workspaceMode !== 'new_per_run' + ) { + throw new Error( + 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' + ) + } + const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null + const repo = input.repo ? await this.showRepo(input.repo) : null + const workspaceMode = + input.workspaceMode ?? + (workspace + ? 'existing' + : input.repo && !current + ? 'new_per_run' + : (current?.workspaceMode ?? 'new_per_run')) + if (workspaceMode === 'existing') { + const workspaceId = workspace?.id ?? current?.workspaceId + const projectId = workspace?.repoId ?? current?.projectId + if (repo && repo.id !== projectId) { + throw new Error('Selected workspace belongs to a different repo.') + } + if (!workspaceId || !projectId) { + throw new Error('Existing-workspace automation requires --workspace.') + } + return { projectId, workspaceMode, workspaceId } + } + const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId + if (!projectId) { + throw new Error('Automation requires --repo or --workspace.') + } + return { projectId, workspaceMode: 'new_per_run', workspaceId: null } + } + // Why: lazy initialization — the DB path depends on Electron's userData // which may not be finalized until after app.ready. Also allows unit tests // to inject an in-memory DB without touching the filesystem. @@ -977,6 +1173,10 @@ export class OrcaRuntimeService { this._orchestrationDb = db } + setAutomationService(service: AutomationService): void { + this.automationService = service + } + getRuntimeId(): string { return this.runtimeId } diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts new file mode 100644 index 000000000..f2c8dce99 --- /dev/null +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { AUTOMATION_METHODS } from './automations' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('automation RPC methods', () => { + it('routes automation CRUD and run operations to the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listAutomations: vi.fn().mockReturnValue([{ id: 'auto-1', name: 'Daily review' }]), + showAutomation: vi.fn().mockReturnValue({ id: 'auto-1', name: 'Daily review' }), + createAutomation: vi.fn().mockResolvedValue({ id: 'auto-2', name: 'New review' }), + updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', name: 'Paused' }), + deleteAutomation: vi.fn().mockReturnValue({ removed: true, id: 'auto-1' }), + runAutomationNow: vi.fn().mockResolvedValue({ id: 'run-1', automationId: 'auto-1' }), + listAutomationRuns: vi.fn().mockReturnValue([{ id: 'run-1', automationId: 'auto-1' }]) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await dispatcher.dispatch(makeRequest('automation.list')) + await dispatcher.dispatch(makeRequest('automation.show', { id: 'auto-1' })) + await dispatcher.dispatch( + makeRequest('automation.create', { + name: 'New review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + ) + await dispatcher.dispatch( + makeRequest('automation.update', { + id: 'auto-1', + updates: { + enabled: false, + rrule: '0 9 * * 1-5', + dtstart: 2 + } + }) + ) + await dispatcher.dispatch(makeRequest('automation.delete', { id: 'auto-1' })) + await dispatcher.dispatch(makeRequest('automation.runNow', { id: 'auto-1' })) + await dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1' })) + + expect(runtime.listAutomations).toHaveBeenCalled() + expect(runtime.showAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.createAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New review', + prompt: 'Review changes', + agentId: 'codex', + repo: 'repo-1' + }) + ) + expect(runtime.updateAutomation).toHaveBeenCalledWith( + 'auto-1', + expect.objectContaining({ enabled: false, rrule: '0 9 * * 1-5' }) + ) + expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1') + expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1') + expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1') + }) + + it('rejects unknown providers and invalid schedules', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + createAutomation: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await expect( + dispatcher.dispatch( + makeRequest('automation.create', { + name: 'Bad provider', + prompt: 'Run', + agentId: 'not-real', + repo: 'repo-1', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + }) + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + + await expect( + dispatcher.dispatch( + makeRequest('automation.create', { + name: 'Bad schedule', + prompt: 'Run', + agentId: 'codex', + repo: 'repo-1', + rrule: 'not a schedule', + dtstart: 1 + }) + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + }) + + it('preserves null baseBranch update values through the RPC boundary', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateAutomation: vi.fn().mockResolvedValue({ id: 'auto-1', baseBranch: null }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await dispatcher.dispatch( + makeRequest('automation.update', { + id: 'auto-1', + updates: { baseBranch: null } + }) + ) + + expect(runtime.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null }) + }) +}) diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts new file mode 100644 index 000000000..97bd301ea --- /dev/null +++ b/src/main/runtime/rpc/methods/automations.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from '../schemas' + +const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() + +const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { + message: 'Invalid automation trigger' +}) + +const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +const AutomationId = z.object({ + id: requiredString('Missing automation id') +}) + +const AutomationRuns = z.object({ + automationId: OptionalString +}) + +const AutomationCreate = z.object({ + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + agentId: TuiAgent, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + agentId: TuiAgent.optional(), + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields +}) + +export const AUTOMATION_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'automation.list', + params: null, + handler: (_params, { runtime }) => ({ automations: runtime.listAutomations() }) + }), + defineMethod({ + name: 'automation.show', + params: AutomationId, + handler: (params, { runtime }) => ({ automation: runtime.showAutomation(params.id) }) + }), + defineMethod({ + name: 'automation.create', + params: AutomationCreate, + handler: async (params, { runtime }) => ({ + automation: await runtime.createAutomation(params) + }) + }), + defineMethod({ + name: 'automation.update', + params: AutomationUpdate, + handler: async (params, { runtime }) => ({ + automation: await runtime.updateAutomation(params.id, params.updates) + }) + }), + defineMethod({ + name: 'automation.delete', + params: AutomationId, + handler: (params, { runtime }) => runtime.deleteAutomation(params.id) + }), + defineMethod({ + name: 'automation.runNow', + params: AutomationId, + handler: async (params, { runtime }) => ({ run: await runtime.runAutomationNow(params.id) }) + }), + defineMethod({ + name: 'automation.runs', + params: AutomationRuns, + handler: (params, { runtime }) => ({ + runs: runtime.listAutomationRuns(params.automationId) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index b04c4f6ef..de831057f 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,6 @@ import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' +import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' import { TERMINAL_METHODS } from './terminal' @@ -26,6 +27,7 @@ import { CLIENT_UI_METHODS } from './client-ui' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AUTOMATION_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, ...TERMINAL_METHODS,