Add automation CLI commands (#2315)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cf614b5f4f
commit
140ceb7fda
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue