Add automation CLI commands (#2315)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 03:41:49 -04:00 committed by GitHub
parent cf614b5f4f
commit 140ceb7fda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1657 additions and 12 deletions

View File

@ -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:<number>`
- `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd`
### Automations
```bash
orca automations list --json
orca automations show <automationId> --json
orca automations create --name "Daily review" --trigger daily --time 09:00 --prompt "Review open changes" --provider codex --repo id:<repoId> --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 <automationId> --name "Weekday review" --trigger weekdays --time 09:30 --json
orca automations run <automationId> --json
orca automations runs --id <automationId> --json
orca automations remove <automationId> --json
```
Automation schedules accept `hourly`, `daily`, `weekdays`, `weekly`, a 5-field cron expression, or an RRULE string. Use `--time <HH:MM>` with `daily`, `weekdays`, or `weekly`; use `--day <0-6>` only with `weekly`, where Sunday is `0`.
Use `--repo <selector>` for a new worktree per run, or `--workspace <selector>` / `--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.

View File

@ -3,6 +3,7 @@ import { RuntimeClientError } from './runtime-client'
export type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
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) &&

View File

@ -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<string, CommandHandler> {
const table = new Map<string, CommandHandler>()
const groups = [
CORE_HANDLERS,
AUTOMATION_HANDLERS,
REPO_HANDLERS,
WORKTREE_HANDLERS,
TERMINAL_HANDLERS,

View File

@ -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

View File

@ -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<AutomationCreateInput, 'projectId' | 'timezone'> & {
repo?: string
timezone?: string
workspace?: string
}
type AutomationUpdateParams = AutomationUpdateInput & {
repo?: string
workspace?: string
}
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
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<string, string | boolean>): 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<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getOptionalProviderFlag(flags: Map<string, string | boolean>): 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<string, string | boolean>): { 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<string, string | boolean>,
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 <id>` 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<AutomationSchedulePreset, 'custom'>,
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<string, string | boolean>): 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<string, string | boolean>
): '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<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[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<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[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<string, CommandHandler> = {
'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)
}
}

View File

@ -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> Key or combo to press, e.g. Escape or CmdOrCtrl+L',
limit: '--limit <n> Maximum number of rows to return',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
name: '--name <name> Name for the new worktree',
name: '--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 <n> Number of scroll pages',
@ -367,6 +376,18 @@ export function formatFlagHelp(flag: string): string {
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
prompt: '--prompt <text> Automation prompt to pass to the agent',
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
schedule: '--schedule <schedule> Alias for --trigger',
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
day: '--day <0-6> Day used with weekly preset, Sunday=0',
timezone: '--timezone <tz> IANA timezone for the automation',
enabled: '--enabled Enable the automation',
disabled: '--disabled Disable the automation',
'workspace-mode': '--workspace-mode <mode> existing or new-per-run',
'missed-run-grace-minutes': '--missed-run-grace-minutes <n> Missed-run grace window',
'value-stdin': '--value-stdin Read set-value payload from stdin',
'window-id': '--window-id <id> Target a window id from list-windows',
'window-index': '--window-index <n> Target a window index from list-windows',

View File

@ -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
})
})

View File

@ -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<void> {
const parsed = parseArgs(argv)
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(COMMAND_SPECS, helpPath)

View File

@ -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 <id> [--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 <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--repo <selector>|--workspace <selector>] [--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 <id> [--name <name>] [--trigger <preset|cron|rrule>] [--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 <id> [--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 <id> [--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 <automation-id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id'],
examples: ['orca automations runs', 'orca automations runs --id 2f9e... --json']
}
]

View File

@ -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,

View File

@ -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()

View File

@ -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<AutomationRun> {
@ -214,25 +213,28 @@ export class AutomationService {
this.store.advanceAutomationNextRun(automation.id, now)
}
private async requestDispatch(automation: Automation, run: AutomationRun): Promise<void> {
private async requestDispatch(
automation: Automation,
run: AutomationRun
): Promise<AutomationRun> {
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
}
}

View File

@ -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: () =>

View File

@ -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()
})
})

View File

@ -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<GlobalSettings>) => 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<K extends keyof RuntimeAutomationUpdateInput>(
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<Automation> {
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<Automation> {
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<AutomationRun> {
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
}

View File

@ -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 })
})
})

View File

@ -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)
})
})
]

View File

@ -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,