From a796d7952a1344a7e6e955e5df7d49fce446828e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 29 May 2026 11:01:48 -0700 Subject: [PATCH] Add automation precheck gating --- src/cli/format.ts | 25 +- src/cli/handlers/automations.ts | 38 +++ src/cli/specs/automations.ts | 9 +- src/main/automations/precheck-runner.test.ts | 59 +++++ src/main/automations/precheck-runner.ts | 219 ++++++++++++++++++ src/main/automations/service-precheck.test.ts | 132 +++++++++++ src/main/automations/service.ts | 51 ++++ src/main/ipc/automations.ts | 9 + src/main/persistence.test.ts | 50 ++++ src/main/persistence.ts | 53 ++++- .../runtime/orca-runtime-automations.test.ts | 3 + src/main/runtime/orca-runtime.ts | 4 + .../runtime/rpc/methods/automations.test.ts | 2 + src/main/runtime/rpc/methods/automations.ts | 18 ++ src/preload/api-types.ts | 5 + src/preload/index.ts | 6 + .../automations/AutomationDetail.tsx | 10 + .../automations/AutomationEditorDialog.tsx | 38 +++ .../automations/AutomationsPage.tsx | 32 +++ .../automations/automation-page-parts.tsx | 2 + .../automation-run-view-state.test.ts | 28 ++- .../automation-usage-model.test.ts | 1 + .../src/hooks/useAutomationDispatchEvents.ts | 44 +++- .../src/lib/automation-session-reuse.test.ts | 1 + src/shared/automation-precheck.ts | 47 ++++ src/shared/automations-types.ts | 25 ++ 26 files changed, 893 insertions(+), 18 deletions(-) create mode 100644 src/main/automations/precheck-runner.test.ts create mode 100644 src/main/automations/precheck-runner.ts create mode 100644 src/main/automations/service-precheck.test.ts create mode 100644 src/shared/automation-precheck.ts diff --git a/src/cli/format.ts b/src/cli/format.ts index c77548c19..d05b6eef5 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -34,6 +34,7 @@ import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { Automation, AutomationRun } from '../shared/automations-types' +import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck' import { formatAutomationSchedule } from '../shared/automation-schedules' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' @@ -340,6 +341,13 @@ export function formatAutomationShow(result: { automation: Automation }): string `enabled: ${automation.enabled}`, `schedule: ${formatAutomationSchedule(automation.rrule)}`, `rrule: ${automation.rrule}`, + `precheck: ${ + automation.precheck + ? `${automation.precheck.command} (timeout ${formatAutomationPrecheckTimeout( + automation.precheck.timeoutSeconds + )})` + : 'none' + }`, `nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`, `projectId: ${automation.projectId}`, `workspaceMode: ${automation.workspaceMode}`, @@ -366,10 +374,25 @@ export function formatAutomationRun(result: { run: AutomationRun }): string { `trigger: ${result.run.trigger}`, `scheduledFor: ${new Date(result.run.scheduledFor).toISOString()}`, `workspaceId: ${result.run.workspaceId ?? 'null'}`, + `precheck: ${formatAutomationRunPrecheck(result.run)}`, `error: ${result.run.error ?? 'null'}` ].join('\n') } +function formatAutomationRunPrecheck(run: AutomationRun): string { + const result = run.precheckResult + if (!result) { + return 'none' + } + const outcome = result.timedOut + ? 'timed out' + : result.error + ? 'error' + : `exit ${result.exitCode ?? 'unknown'}` + const output = result.stderr.trim() || result.stdout.trim() + return output ? `${outcome}; ${output}` : outcome +} + export function formatAutomationRuns(result: { runs: AutomationRun[] }): string { if (result.runs.length === 0) { return 'No automation runs found.' @@ -377,7 +400,7 @@ export function formatAutomationRuns(result: { runs: AutomationRun[] }): string 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}` : ''}` + `${run.id} ${run.automationId} ${run.status} ${run.trigger} ${new Date(run.scheduledFor).toISOString()}\n${run.title}${run.precheckResult ? `\nprecheck: ${formatAutomationRunPrecheck(run)}` : ''}${run.error ? `\nerror: ${run.error}` : ''}` ) .join('\n\n') } diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index 8c7ec60d3..be09b4060 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -2,11 +2,16 @@ import type { Automation, AutomationCreateInput, + AutomationPrecheck, AutomationRun, AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' import type { TuiAgent } from '../../shared/types' +import { + DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS +} from '../../shared/automation-precheck' import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' import { isTuiAgent } from '../../shared/tui-agent-config' import type { CommandHandler } from '../dispatch' @@ -234,6 +239,37 @@ function getReuseSessionFlag(flags: Map): boolean | un return undefined } +function getPrecheckFlag( + flags: Map +): AutomationPrecheck | null | undefined { + const hasPrecheck = flags.has('precheck') + const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') + if (!hasPrecheck) { + if (timeoutSeconds !== undefined) { + throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') + } + return undefined + } + const value = flags.get('precheck') + if (typeof value !== 'string') { + throw new RuntimeClientError('invalid_argument', '--precheck requires a command') + } + const command = value.trim() + if (!command) { + return null + } + if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { + throw new RuntimeClientError( + 'invalid_argument', + `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` + ) + } + return { + command, + timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS + } +} + function getWorkspaceModeFlag( flags: Map ): 'existing' | 'new_per_run' | undefined { @@ -311,6 +347,7 @@ export const AUTOMATION_HANDLERS: Record = { const result = await client.call<{ automation: Automation }>('automation.create', { name: getRequiredStringFlag(flags, 'name'), prompt: getRequiredStringFlag(flags, 'prompt'), + precheck: getPrecheckFlag(flags), agentId: getProviderFlag(flags), repo: target.repo, workspace: target.workspace, @@ -332,6 +369,7 @@ export const AUTOMATION_HANDLERS: Record = { updates: { name: getOptionalStringFlag(flags, 'name'), prompt: getOptionalStringFlag(flags, 'prompt'), + precheck: getPrecheckFlag(flags), agentId: getOptionalProviderFlag(flags), repo: target.repo, workspace: target.workspace, diff --git a/src/cli/specs/automations.ts b/src/cli/specs/automations.ts index 250329427..ff3071a93 100644 --- a/src/cli/specs/automations.ts +++ b/src/cli/specs/automations.ts @@ -3,6 +3,7 @@ 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_PRECHECK_FLAGS = ['precheck', 'precheck-timeout'] const AUTOMATION_STATE_FLAGS = [ 'enabled', 'disabled', @@ -31,12 +32,13 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ path: ['automations', 'create'], summary: 'Create a scheduled Orca automation', usage: - 'orca automations create --name --trigger --prompt --provider [--repo |--workspace ] [--json]', + 'orca automations create --name --trigger --prompt --provider [--precheck ] [--repo |--workspace ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'name', 'prompt', 'provider', + ...AUTOMATION_PRECHECK_FLAGS, ...AUTOMATION_TARGET_FLAGS, ...AUTOMATION_SCHEDULE_FLAGS, ...AUTOMATION_STATE_FLAGS @@ -45,11 +47,13 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ '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.', + 'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.', 'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.' ], 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' + 'orca automations create --name "Weekday triage" --trigger "0 9 * * 1-5" --prompt "Triage issues" --provider claude --repo my-repo', + 'orca automations create --name "PR review" --trigger hourly --precheck "gh pr list --json number -q .[0].number" --prompt "Review requested PRs" --provider codex' ] }, { @@ -62,6 +66,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [ 'name', 'prompt', 'provider', + ...AUTOMATION_PRECHECK_FLAGS, ...AUTOMATION_TARGET_FLAGS, ...AUTOMATION_SCHEDULE_FLAGS, ...AUTOMATION_STATE_FLAGS diff --git a/src/main/automations/precheck-runner.test.ts b/src/main/automations/precheck-runner.test.ts new file mode 100644 index 000000000..5e14e83b9 --- /dev/null +++ b/src/main/automations/precheck-runner.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runAutomationPrecheck } from './precheck-runner' + +vi.mock('../ipc/ssh', () => ({ + getSshConnectionManager: () => null +})) + +const node = JSON.stringify(process.execPath) + +function nodeCommand(script: string): string { + return `${node} -e ${JSON.stringify(script)}` +} + +describe('runAutomationPrecheck', () => { + let cwd = '' + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'orca-precheck-test-')) + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('captures exit code and output for a non-zero local precheck', async () => { + const result = await runAutomationPrecheck({ + precheck: { + command: nodeCommand( + "console.log('stdout text'); console.error('stderr text'); process.exit(7)" + ), + timeoutSeconds: 5 + }, + target: { type: 'local', cwd } + }) + + expect(result.exitCode).toBe(7) + expect(result.timedOut).toBe(false) + expect(result.stdout).toContain('stdout text') + expect(result.stderr).toContain('stderr text') + expect(result.error).toBeNull() + }) + + it('marks a local precheck as timed out', async () => { + const result = await runAutomationPrecheck({ + precheck: { + command: nodeCommand('setTimeout(() => {}, 5000)'), + timeoutSeconds: 1 + }, + target: { type: 'local', cwd } + }) + + expect(result.exitCode).toBeNull() + expect(result.timedOut).toBe(true) + expect(result.error).toBe('Precheck timed out after 1s.') + }) +}) diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts new file mode 100644 index 000000000..1eaaf2a30 --- /dev/null +++ b/src/main/automations/precheck-runner.ts @@ -0,0 +1,219 @@ +import { spawn } from 'node:child_process' +import type { ClientChannel } from 'ssh2' +import type { AutomationPrecheck, AutomationPrecheckResult } from '../../shared/automations-types' +import { MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS } from '../../shared/automation-precheck' +import { getSshConnectionManager } from '../ipc/ssh' +import { shellEscape } from '../ssh/ssh-connection-utils' + +type AutomationPrecheckExecutionTarget = + | { + type: 'local' + cwd: string + } + | { + type: 'ssh' + cwd: string + connectionId: string + } + +type TailBuffer = { + content: string + truncated: boolean +} + +function appendTail(buffer: TailBuffer, chunk: string): TailBuffer { + const content = `${buffer.content}${chunk}` + if (content.length <= MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS) { + return { ...buffer, content } + } + return { + content: content.slice(-MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS), + truncated: true + } +} + +function createPrecheckResult(args: { + precheck: AutomationPrecheck + startedAt: number + stdout: TailBuffer + stderr: TailBuffer + exitCode: number | null + timedOut: boolean + error: string | null +}): AutomationPrecheckResult { + const completedAt = Date.now() + return { + command: args.precheck.command, + exitCode: args.exitCode, + timedOut: args.timedOut, + durationMs: Math.max(0, completedAt - args.startedAt), + stdout: args.stdout.content, + stderr: args.stderr.content, + stdoutTruncated: args.stdout.truncated, + stderrTruncated: args.stderr.truncated, + error: args.error, + startedAt: args.startedAt, + completedAt + } +} + +function failedPrecheckResult( + precheck: AutomationPrecheck, + startedAt: number, + error: string +): AutomationPrecheckResult { + return createPrecheckResult({ + precheck, + startedAt, + stdout: { content: '', truncated: false }, + stderr: { content: '', truncated: false }, + exitCode: null, + timedOut: false, + error + }) +} + +function runLocalPrecheck( + precheck: AutomationPrecheck, + target: Extract +): Promise { + const startedAt = Date.now() + const timeoutMs = precheck.timeoutSeconds * 1000 + return new Promise((resolve) => { + let stdout: TailBuffer = { content: '', truncated: false } + let stderr: TailBuffer = { content: '', truncated: false } + let timedOut = false + let settled = false + let timeout: ReturnType | null = null + + const child = spawn(precheck.command, { + cwd: target.cwd, + env: process.env, + shell: true, + windowsHide: true + }) + + const settle = (exitCode: number | null, error: string | null): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + resolve( + createPrecheckResult({ precheck, startedAt, stdout, stderr, exitCode, timedOut, error }) + ) + } + + timeout = setTimeout(() => { + timedOut = true + child.kill() + }, timeoutMs) + + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout = appendTail(stdout, chunk) + }) + child.stderr?.on('data', (chunk: string) => { + stderr = appendTail(stderr, chunk) + }) + child.on('error', (error) => { + settle(null, error.message) + }) + child.on('close', (code) => { + settle( + typeof code === 'number' ? code : null, + timedOut ? `Precheck timed out after ${precheck.timeoutSeconds}s.` : null + ) + }) + }) +} + +function runSshChannelPrecheck(args: { + precheck: AutomationPrecheck + channel: ClientChannel + startedAt: number +}): Promise { + const { precheck, channel, startedAt } = args + const timeoutMs = precheck.timeoutSeconds * 1000 + return new Promise((resolve) => { + let stdout: TailBuffer = { content: '', truncated: false } + let stderr: TailBuffer = { content: '', truncated: false } + let timedOut = false + let settled = false + let timeout: ReturnType | null = null + + const settle = (exitCode: number | null, error: string | null): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + timeout = null + } + resolve( + createPrecheckResult({ precheck, startedAt, stdout, stderr, exitCode, timedOut, error }) + ) + } + + timeout = setTimeout(() => { + timedOut = true + channel.close() + }, timeoutMs) + + const fail = (error: Error): void => { + settle(null, error.message) + } + channel.on('error', fail) + channel.stderr.on('error', fail) + channel.on('data', (data: Buffer | string) => { + stdout = appendTail(stdout, data.toString()) + }) + channel.stderr.on('data', (data: Buffer | string) => { + stderr = appendTail(stderr, data.toString()) + }) + channel.on('close', (code: number | null | undefined) => { + settle( + typeof code === 'number' ? code : null, + timedOut ? `Precheck timed out after ${precheck.timeoutSeconds}s.` : null + ) + }) + }) +} + +async function runSshPrecheck( + precheck: AutomationPrecheck, + target: Extract +): Promise { + const startedAt = Date.now() + const manager = getSshConnectionManager() + const connection = manager?.getConnection(target.connectionId) + if (!connection || connection.getState().status !== 'connected') { + return failedPrecheckResult(precheck, startedAt, 'SSH target is not connected.') + } + try { + const remoteCommand = `cd ${shellEscape(target.cwd)} && ${precheck.command}` + const channel = await connection.exec(remoteCommand) + return await runSshChannelPrecheck({ precheck, channel, startedAt }) + } catch (error) { + return failedPrecheckResult( + precheck, + startedAt, + error instanceof Error ? error.message : String(error) + ) + } +} + +export async function runAutomationPrecheck(args: { + precheck: AutomationPrecheck + target: AutomationPrecheckExecutionTarget +}): Promise { + if (args.target.type === 'ssh') { + return await runSshPrecheck(args.precheck, args.target) + } + return await runLocalPrecheck(args.precheck, args.target) +} diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts new file mode 100644 index 000000000..553674a9c --- /dev/null +++ b/src/main/automations/service-precheck.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import type { Repo } from '../../shared/types' +import { AutomationService } from './service' + +const runAutomationPrecheckMock = vi.hoisted(() => vi.fn()) +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('../git/repo', () => ({ + getGitUsername: vi.fn().mockReturnValue('testuser') +})) + +vi.mock('./precheck-runner', () => ({ + runAutomationPrecheck: runAutomationPrecheckMock +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('../persistence') + initDataPath() + return new Store() +} + +const makeRepo = (overrides: Partial = {}): Repo => ({ + id: 'r1', + path: '/repo', + displayName: 'test', + badgeColor: '#fff', + addedAt: 1, + ...overrides +}) + +describe('AutomationService prechecks', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-')) + runAutomationPrecheckMock.mockReset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('runs scheduled prechecks in the target repo before dispatch', async () => { + vi.setSystemTime(new Date('2026-05-13T08:00:00Z')) + const store = await createStore() + store.addRepo(makeRepo({ path: '/repo/path' })) + const automation = store.createAutomation({ + name: 'Conditional check', + prompt: 'Check the repo', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'scheduled') + const precheckResult = { + command: 'test -f ready', + exitCode: 0, + timedOut: false, + durationMs: 5, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: Date.now(), + completedAt: Date.now() + } + runAutomationPrecheckMock.mockResolvedValue(precheckResult) + const service = new AutomationService(store, { tickMs: 60_000 }) + + const result = await service.runPrecheck(automation.id, run.id) + + expect(result).toEqual(precheckResult) + expect(runAutomationPrecheckMock).toHaveBeenCalledWith({ + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + target: { + type: 'local', + cwd: '/repo/path' + } + }) + }) + + it('does not run prechecks for manual dispatches', 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', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'new_per_run', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-14T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + const service = new AutomationService(store, { tickMs: 60_000 }) + + await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull() + expect(runAutomationPrecheckMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 655dc8d5d..d2386b0de 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -4,12 +4,15 @@ import type { Automation, AutomationDispatchRequest, AutomationDispatchResult, + AutomationPrecheckResult, AutomationRun, AutomationRunStatus, AutomationRunUsage } from '../../shared/automations-types' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { runAutomationPrecheck } from './precheck-runner' const DEFAULT_TICK_MS = 60 * 1000 @@ -72,6 +75,43 @@ export class AutomationService { return await this.requestDispatch(automation, run) } + async runPrecheck(automationId: string, runId: string): Promise { + const automation = this.store.listAutomations().find((entry) => entry.id === automationId) + if (!automation) { + throw new Error('Automation not found.') + } + const run = this.store.listAutomationRuns(automationId).find((entry) => entry.id === runId) + if (!run) { + throw new Error('Automation run not found.') + } + if (run.trigger !== 'scheduled' || !automation.precheck) { + return null + } + const cwd = this.getPrecheckCwd(automation) + if (!cwd) { + return { + command: automation.precheck.command, + exitCode: null, + timedOut: false, + durationMs: 0, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + error: 'Automation precheck target is no longer available.', + startedAt: Date.now(), + completedAt: Date.now() + } + } + return await runAutomationPrecheck({ + precheck: automation.precheck, + target: + automation.executionTargetType === 'ssh' + ? { type: 'ssh', cwd, connectionId: automation.executionTargetId } + : { type: 'local', cwd } + }) + } + async markDispatchResult(result: AutomationDispatchResult): Promise { const run = this.store.updateAutomationRun(result) if (!isFinalRunStatus(run.status)) { @@ -190,6 +230,16 @@ export class AutomationService { } } + private getPrecheckCwd(automation: Automation): string | null { + if (automation.workspaceMode === 'existing') { + const parsed = automation.workspaceId + ? splitWorktreeIdForFilesystem(automation.workspaceId) + : null + return parsed?.worktreePath ?? null + } + return this.store.getRepo(automation.projectId)?.path ?? null + } + private async evaluateAutomation(automation: Automation, now: number): Promise { const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now) if (scheduledFor === null) { @@ -242,6 +292,7 @@ function isFinalRunStatus(status: AutomationRunStatus): boolean { return ( status === 'completed' || status === 'dispatch_failed' || + status === 'skipped_precheck' || status === 'skipped_missed' || status === 'skipped_unavailable' || status === 'skipped_needs_interactive_auth' diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index ec3ddbcf7..7a523adde 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -5,6 +5,7 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + AutomationPrecheckResult, ExternalAutomationCreateInput, ExternalAutomationActionInput, ExternalAutomationManager, @@ -66,6 +67,14 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ 'automations:runNow', (_event, args: { id: string }): Promise => service.runNow(args.id) ) + ipcMain.handle( + 'automations:runPrecheck', + ( + _event, + args: { automationId: string; runId: string } + ): Promise => + service.runPrecheck(args.automationId, args.runId) + ) ipcMain.handle( 'automations:markDispatchResult', (_event, result: AutomationDispatchResult): Promise => diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index e023d6096..f051a8546 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -621,6 +621,56 @@ describe('Store', () => { expect(reloaded.listAutomations()[0].reuseSession).toBe(false) }) + it('persists automation precheck config and run results', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Conditional', + prompt: 'Run checks', + precheck: { + command: 'test -f ready', + timeoutSeconds: 30 + }, + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime()) + + store.updateAutomationRun({ + runId: run.id, + status: 'skipped_precheck', + precheckResult: { + command: 'test -f ready', + exitCode: 1, + timedOut: false, + durationMs: 12, + stdout: '', + stderr: 'missing', + stdoutTruncated: false, + stderrTruncated: false, + error: null, + startedAt: 10, + completedAt: 22 + }, + error: 'Precheck exited with code 1.' + }) + + expect(store.listAutomations()[0].precheck).toEqual({ + command: 'test -f ready', + timeoutSeconds: 30 + }) + expect(store.listAutomationRuns(automation.id)[0].precheckResult).toMatchObject({ + exitCode: 1, + stderr: 'missing' + }) + expect(store.updateAutomation(automation.id, { precheck: null }).precheck).toBeNull() + }) + it('numbers automation run titles per automation', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 727f7e230..5c0fbdfe7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -21,6 +21,7 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + AutomationPrecheckResult, AutomationRunOutputSnapshot, AutomationRun, AutomationRunTrigger, @@ -30,6 +31,7 @@ import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../shared/automation-schedules' +import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import type { PersistedState, Repo, @@ -368,9 +370,43 @@ function normalizeAutomationRunOutputSnapshot( } } +function normalizeAutomationPrecheckResult( + value: AutomationPrecheckResult | null | undefined +): AutomationPrecheckResult | null { + if (!value || typeof value.command !== 'string' || !value.command.trim()) { + return null + } + const startedAt = + typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) + ? value.startedAt + : Date.now() + const completedAt = + typeof value.completedAt === 'number' && Number.isFinite(value.completedAt) + ? value.completedAt + : startedAt + return { + command: value.command.trim(), + exitCode: + typeof value.exitCode === 'number' && Number.isFinite(value.exitCode) ? value.exitCode : null, + timedOut: value.timedOut === true, + durationMs: + typeof value.durationMs === 'number' && Number.isFinite(value.durationMs) + ? Math.max(0, value.durationMs) + : Math.max(0, completedAt - startedAt), + stdout: typeof value.stdout === 'string' ? value.stdout : '', + stderr: typeof value.stderr === 'string' ? value.stderr : '', + stdoutTruncated: value.stdoutTruncated === true, + stderrTruncated: value.stderrTruncated === true, + error: typeof value.error === 'string' && value.error.trim() ? value.error : null, + startedAt, + completedAt + } +} + function normalizeAutomationSessionReuse(automation: Automation): Automation { return { ...automation, + precheck: normalizeAutomationPrecheck(automation.precheck), reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true } } @@ -2420,9 +2456,12 @@ export class Store { listAutomationRuns(automationId?: string): AutomationRun[] { const runs = this.state.automationRuns ?? [] - return [ - ...(automationId ? runs.filter((run) => run.automationId === automationId) : runs) - ].sort((left, right) => right.createdAt - left.createdAt) + return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] + .map((run) => ({ + ...run, + precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) + })) + .sort((left, right) => right.createdAt - left.createdAt) } createAutomation(input: AutomationCreateInput): Automation { @@ -2433,6 +2472,7 @@ export class Store { id: randomUUID(), name: input.name.trim() || 'Untitled automation', prompt: input.prompt, + precheck: normalizeAutomationPrecheck(input.precheck), agentId: input.agentId, projectId: input.projectId, executionTargetType, @@ -2476,6 +2516,9 @@ export class Store { ...updates, name: updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name, + precheck: Object.hasOwn(updates, 'precheck') + ? normalizeAutomationPrecheck(updates.precheck) + : normalizeAutomationPrecheck(current.precheck), projectId: repoId, executionTargetType, executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local', @@ -2545,6 +2588,7 @@ export class Store { chatSessionId: null, terminalSessionId: null, outputSnapshot: null, + precheckResult: null, usage: null, error: null, startedAt: null, @@ -2582,6 +2626,9 @@ export class Store { outputSnapshot: Object.hasOwn(result, 'outputSnapshot') ? normalizeAutomationRunOutputSnapshot(result.outputSnapshot) : normalizeAutomationRunOutputSnapshot(current.outputSnapshot), + precheckResult: Object.hasOwn(result, 'precheckResult') + ? normalizeAutomationPrecheckResult(result.precheckResult) + : normalizeAutomationPrecheckResult(current.precheckResult), usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts index 28746f115..9920e5c12 100644 --- a/src/main/runtime/orca-runtime-automations.test.ts +++ b/src/main/runtime/orca-runtime-automations.test.ts @@ -49,6 +49,7 @@ const existingAutomation = { id: 'auto-1', name: 'Daily review', prompt: 'Review changes', + precheck: null, agentId: 'codex', projectId: 'repo-1', executionTargetType: 'local', @@ -77,6 +78,7 @@ describe('OrcaRuntimeService automation methods', () => { const automation = await runtime.createAutomation({ name: 'Daily review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', workspaceMode: 'new_per_run', @@ -88,6 +90,7 @@ describe('OrcaRuntimeService automation methods', () => { expect.objectContaining({ name: 'Daily review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', projectId: 'repo-1', workspaceMode: 'new_per_run', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7e388bba5..399d14fed 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1571,6 +1571,7 @@ export class OrcaRuntimeService { return this.store.createAutomation({ name: input.name, prompt: input.prompt, + precheck: input.precheck, agentId: input.agentId, projectId: target.projectId, workspaceMode: target.workspaceMode, @@ -1597,6 +1598,9 @@ export class OrcaRuntimeService { if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { patch.prompt = updates.prompt } + if (hasRuntimeAutomationUpdateValue(updates, 'precheck')) { + patch.precheck = updates.precheck + } if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { patch.agentId = updates.agentId } diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index b0bb15a7f..c4143fb32 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -28,6 +28,7 @@ describe('automation RPC methods', () => { makeRequest('automation.create', { name: 'New review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', reuseSession: true, @@ -56,6 +57,7 @@ describe('automation RPC methods', () => { expect.objectContaining({ name: 'New review', prompt: 'Review changes', + precheck: { command: 'test -f ready', timeoutSeconds: 30 }, agentId: 'codex', repo: 'repo-1', reuseSession: true diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index f2d13e04b..20824d8f2 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,5 +1,9 @@ import { z } from 'zod' import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../../../../shared/automation-precheck' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { defineMethod, type RpcMethod } from '../core' import { @@ -21,6 +25,18 @@ const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutom message: 'Invalid automation trigger' }) +const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + const OptionalNullablePlainString = z .unknown() .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) @@ -38,6 +54,7 @@ const AutomationRuns = z.object({ const AutomationCreate = z.object({ name: requiredString('Missing automation name'), prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, agentId: TuiAgent, repo: OptionalString, workspace: OptionalString, @@ -54,6 +71,7 @@ const AutomationCreate = z.object({ const AutomationUpdateFields = z.object({ name: OptionalString, prompt: OptionalString, + precheck: AutomationPrecheck, agentId: TuiAgent.optional(), repo: OptionalString, workspace: OptionalString, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 390165036..7b2142bc3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -310,6 +310,7 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput, AutomationRun, + AutomationPrecheckResult, AutomationUpdateInput } from '../shared/automations-types' import type { @@ -2233,6 +2234,10 @@ export type PreloadApi = { update: (args: { id: string; updates: AutomationUpdateInput }) => Promise delete: (args: { id: string }) => Promise runNow: (args: { id: string }) => Promise + runPrecheck: (args: { + automationId: string + runId: string + }) => Promise markDispatchResult: (result: AutomationDispatchResult) => Promise snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise rendererReady: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d10c2dbf1..7be44b6ee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -125,6 +125,7 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput, AutomationRun, + AutomationPrecheckResult, AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' @@ -3222,6 +3223,11 @@ const api = { delete: (args: { id: string }): Promise => ipcRenderer.invoke('automations:delete', args), runNow: (args: { id: string }): Promise => ipcRenderer.invoke('automations:runNow', args), + runPrecheck: (args: { + automationId: string + runId: string + }): Promise => + ipcRenderer.invoke('automations:runPrecheck', args), markDispatchResult: (result: AutomationDispatchResult): Promise => ipcRenderer.invoke('automations:markDispatchResult', result), snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index ae4e37381..adc68dfc2 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import { formatAutomationPrecheckTimeout } from '../../../../shared/automation-precheck' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' import { formatAutomationCost, @@ -175,6 +176,14 @@ export function AutomationDetail({ value={automation.reuseSession ? 'Reuse live session' : 'Fresh each run'} /> +
Agent
@@ -201,6 +210,7 @@ export function AutomationDetail({
Prompt
+
Prompt

{automation.prompt}

diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index ceba02ad0..e43234d56 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -49,6 +49,8 @@ export type AutomationDraft = { workspaceId: string baseBranch: string reuseSession: boolean + precheckCommand: string + precheckTimeoutSeconds: string preset: AutomationSchedulePreset time: string dayOfWeek: string @@ -231,6 +233,42 @@ export function AutomationEditorDialog({ className="min-h-[260px] w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30" /> + {isHermesCreate ? null : ( +
+ +