Add automation precheck gating

This commit is contained in:
Neil 2026-05-29 11:01:48 -07:00
parent 129b47ca10
commit a796d7952a
26 changed files with 893 additions and 18 deletions

View File

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

View File

@ -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<string, string | boolean>): boolean | un
return undefined
}
function getPrecheckFlag(
flags: Map<string, string | boolean>
): 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<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
@ -311,6 +347,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
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<string, CommandHandler> = {
updates: {
name: getOptionalStringFlag(flags, 'name'),
prompt: getOptionalStringFlag(flags, 'prompt'),
precheck: getPrecheckFlag(flags),
agentId: getOptionalProviderFlag(flags),
repo: target.repo,
workspace: target.workspace,

View File

@ -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 <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--repo <selector>|--workspace <selector>] [--json]',
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--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

View File

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

View File

@ -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<AutomationPrecheckExecutionTarget, { type: 'local' }>
): Promise<AutomationPrecheckResult> {
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<typeof setTimeout> | 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<AutomationPrecheckResult> {
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<typeof setTimeout> | 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<AutomationPrecheckExecutionTarget, { type: 'ssh' }>
): Promise<AutomationPrecheckResult> {
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<AutomationPrecheckResult> {
if (args.target.type === 'ssh') {
return await runSshPrecheck(args.precheck, args.target)
}
return await runLocalPrecheck(args.precheck, args.target)
}

View File

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

View File

@ -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<AutomationPrecheckResult | null> {
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<AutomationRun> {
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<void> {
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'

View File

@ -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<AutomationRun> => service.runNow(args.id)
)
ipcMain.handle(
'automations:runPrecheck',
(
_event,
args: { automationId: string; runId: string }
): Promise<AutomationPrecheckResult | null> =>
service.runPrecheck(args.automationId, args.runId)
)
ipcMain.handle(
'automations:markDispatchResult',
(_event, result: AutomationDispatchResult): Promise<AutomationRun> =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Automation>
delete: (args: { id: string }) => Promise<void>
runNow: (args: { id: string }) => Promise<AutomationRun>
runPrecheck: (args: {
automationId: string
runId: string
}) => Promise<AutomationPrecheckResult | null>
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }) => Promise<number>
rendererReady: () => Promise<void>

View File

@ -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<void> => ipcRenderer.invoke('automations:delete', args),
runNow: (args: { id: string }): Promise<AutomationRun> =>
ipcRenderer.invoke('automations:runNow', args),
runPrecheck: (args: {
automationId: string
runId: string
}): Promise<AutomationPrecheckResult | null> =>
ipcRenderer.invoke('automations:runPrecheck', args),
markDispatchResult: (result: AutomationDispatchResult): Promise<AutomationRun> =>
ipcRenderer.invoke('automations:markDispatchResult', result),
snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise<number> =>

View File

@ -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'}
/>
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
<DetailMetric
label="Precheck"
value={
automation.precheck
? `Enabled, ${formatAutomationPrecheckTimeout(automation.precheck.timeoutSeconds)}`
: 'None'
}
/>
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase text-muted-foreground">Agent</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-sm font-medium">
@ -201,6 +210,7 @@ export function AutomationDetail({
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">Prompt</div>
<div className="px-3 py-3">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase text-muted-foreground">Prompt</div>
<p className="mt-1 line-clamp-4 whitespace-pre-wrap text-sm text-foreground">
{automation.prompt}
</p>

View File

@ -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"
/>
</Field>
{isHermesCreate ? null : (
<div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Field label="Precheck">
<textarea
value={draft.precheckCommand}
placeholder="gh pr list --json number -q '.[0].number'"
onChange={(event) =>
onDraftChange((current) => ({
...current,
precheckCommand: event.target.value
}))
}
className="min-h-[68px] w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 font-mono 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"
/>
</Field>
<Field label="Timeout">
<Select
value={draft.precheckTimeoutSeconds}
onValueChange={(precheckTimeoutSeconds) =>
onDraftChange((current) => ({ ...current, precheckTimeoutSeconds }))
}
>
<SelectTrigger className={`w-full ${PICKER_TRIGGER_CLASS}`}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start" sideOffset={4}>
<SelectItem value="30">30 sec</SelectItem>
<SelectItem value="60">1 min</SelectItem>
<SelectItem value="120">2 min</SelectItem>
<SelectItem value="300">5 min</SelectItem>
<SelectItem value="600">10 min</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
</div>
<div className="border-t border-border/50 px-5 py-4">

View File

@ -47,6 +47,7 @@ import type {
ExternalAutomationJob,
ExternalAutomationManager,
ExternalAutomationRun,
AutomationPrecheck,
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
@ -132,6 +133,18 @@ function parseDraftTime(time: string): { hour: number; minute: number } {
}
}
function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null {
const command = draft.precheckCommand.trim()
if (!command) {
return null
}
const rawTimeout = Number(draft.precheckTimeoutSeconds)
return {
command,
timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60
}
}
function buildHermesCronSchedule(draft: AutomationDraft): string {
if (draft.preset === 'custom') {
return draft.customSchedule.trim()
@ -216,6 +229,14 @@ function getAutomationRunContent(run: AutomationRun): string {
if (savedOutput) {
return run.outputSnapshot?.content ?? savedOutput
}
if (run.precheckResult) {
const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()]
.filter(Boolean)
.join('\n\n')
if (output) {
return output
}
}
return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.'
}
@ -322,6 +343,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceId: '',
baseBranch: '',
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
@ -704,6 +727,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceId: target.workspaceId,
baseBranch: '',
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
@ -756,6 +781,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceId: latest.workspaceId ?? '',
baseBranch: latest.baseBranch ?? '',
reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession,
precheckCommand: latest.precheck?.command ?? '',
precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60),
preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'),
time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME,
dayOfWeek: String(schedule?.dayOfWeek ?? 1),
@ -801,6 +828,8 @@ export default function AutomationsPage(): React.JSX.Element {
workspaceId,
baseBranch: '',
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '60',
preset: hasCustomSchedule ? 'custom' : 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
@ -955,6 +984,7 @@ export default function AutomationsPage(): React.JSX.Element {
const missedRunGraceMinutes = Number.isFinite(rawMissedRunGraceMinutes)
? Math.max(0, rawMissedRunGraceMinutes)
: 720
const precheck = buildDraftPrecheck(draft)
let currentAutomation = editingAutomationId
? (automations.find((automation) => automation.id === editingAutomationId) ?? null)
: null
@ -971,6 +1001,7 @@ export default function AutomationsPage(): React.JSX.Element {
const updates: AutomationUpdateInput = {
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
@ -993,6 +1024,7 @@ export default function AutomationsPage(): React.JSX.Element {
: await window.api.automations.create({
name: draft.name,
prompt: draft.prompt,
precheck,
agentId: draft.agentId,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,

View File

@ -78,6 +78,8 @@ export function getAutomationRunStatusLabel(status: AutomationRun['status']): st
return 'Launched'
case 'completed':
return 'Done'
case 'skipped_precheck':
return 'Precheck skipped'
case 'skipped_missed':
return 'Skipped'
case 'skipped_unavailable':

View File

@ -12,6 +12,7 @@ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
id: 'automation-1',
name: 'Automation 1',
prompt: 'Run checks',
precheck: null,
agentId: 'codex',
projectId: 'repo-1',
executionTargetType: 'local',
@ -47,6 +48,7 @@ function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
chatSessionId: null,
terminalSessionId: 'tab-1',
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 1,
@ -151,17 +153,21 @@ describe('canRerunAutomationRun', () => {
}
)
it.each(['pending', 'dispatching', 'dispatched', 'completed', 'skipped_missed'] as const)(
'hides rerun for non-recoverable status %s',
(status) => {
expect(
canRerunAutomationRun({
automation: makeAutomation(),
run: makeRun({ status })
})
).toBe(false)
}
)
it.each([
'pending',
'dispatching',
'dispatched',
'completed',
'skipped_precheck',
'skipped_missed'
] as const)('hides rerun for non-recoverable status %s', (status) => {
expect(
canRerunAutomationRun({
automation: makeAutomation(),
run: makeRun({ status })
})
).toBe(false)
})
it('requires the failed run to belong to the selected automation', () => {
expect(

View File

@ -19,6 +19,7 @@ function makeRun(overrides: Partial<AutomationRun>): AutomationRun {
chatSessionId: null,
terminalSessionId: 'tab-1',
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 1,

View File

@ -7,7 +7,14 @@ import { submitPromptToAgentTab } from '@/lib/agent-paste-draft'
import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
import { useAppStore } from '@/store'
import type { AutomationDispatchResult } from '../../../shared/automations-types'
import type {
AutomationDispatchResult,
AutomationPrecheckResult
} from '../../../shared/automations-types'
import {
didAutomationPrecheckPass,
formatAutomationPrecheckFailure
} from '../../../shared/automation-precheck'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import {
createAutomationRunOutputSnapshotBuffer,
@ -56,6 +63,7 @@ export function useAutomationDispatchEvents(): void {
let dispatchWorkspaceId = automation.workspaceId
let dispatchWorkspaceDisplayName =
automationWorktree?.displayName ?? run.workspaceDisplayName ?? null
let precheckResult: AutomationPrecheckResult | null = null
if (!repo) {
await markDispatchResult({
@ -102,6 +110,35 @@ export function useAutomationDispatchEvents(): void {
}
}
if (automation.workspaceMode === 'existing' && !automationWorktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
error: 'The target workspace is no longer available.'
})
return
}
if (run.trigger === 'scheduled' && automation.precheck) {
precheckResult = await window.api.automations.runPrecheck({
automationId: automation.id,
runId: run.id
})
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
await markDispatchResult({
runId: run.id,
status: 'skipped_precheck',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
precheckResult,
error: formatAutomationPrecheckFailure(precheckResult)
})
return
}
}
try {
const worktree =
automation.workspaceMode === 'new_per_run'
@ -170,6 +207,7 @@ export function useAutomationDispatchEvents(): void {
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult,
error: null
})
}
@ -181,6 +219,7 @@ export function useAutomationDispatchEvents(): void {
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
outputSnapshot: getOutputSnapshot(),
precheckResult,
error: code === 0 ? null : `Automation process exited with code ${code}.`
})
}
@ -291,6 +330,7 @@ export function useAutomationDispatchEvents(): void {
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: reusableSession.tabId,
precheckResult,
error: null
})
dispatchMarked = true
@ -346,6 +386,7 @@ export function useAutomationDispatchEvents(): void {
workspaceId: worktree.id,
workspaceDisplayName: worktree.displayName,
terminalSessionId: result.tabId,
precheckResult,
error: null
})
dispatchMarked = true
@ -378,6 +419,7 @@ export function useAutomationDispatchEvents(): void {
status: 'dispatch_failed',
workspaceId: dispatchWorkspaceId,
workspaceDisplayName: dispatchWorkspaceDisplayName,
precheckResult,
error: error instanceof Error ? error.message : String(error)
})
}

View File

@ -22,6 +22,7 @@ function run(overrides: Partial<AutomationRun>): AutomationRun {
chatSessionId: null,
terminalSessionId: 'tab-1',
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 1,

View File

@ -0,0 +1,47 @@
import type { AutomationPrecheck, AutomationPrecheckResult } from './automations-types'
export const DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS = 60
export const MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS = 600
export const MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS = 4000
export function normalizeAutomationPrecheckTimeoutSeconds(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
}
return Math.min(MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, Math.max(1, Math.floor(value)))
}
export function normalizeAutomationPrecheck(
precheck: AutomationPrecheck | null | undefined
): AutomationPrecheck | null {
const command = typeof precheck?.command === 'string' ? precheck.command.trim() : ''
if (!command) {
return null
}
return {
command,
timeoutSeconds: normalizeAutomationPrecheckTimeoutSeconds(precheck?.timeoutSeconds)
}
}
export function formatAutomationPrecheckTimeout(seconds: number): string {
return `${seconds}s`
}
export function didAutomationPrecheckPass(
result: AutomationPrecheckResult | null | undefined
): boolean {
return Boolean(result && !result.timedOut && !result.error && result.exitCode === 0)
}
export function formatAutomationPrecheckFailure(result: AutomationPrecheckResult): string {
if (result.timedOut) {
return `Precheck timed out after ${formatAutomationPrecheckTimeout(
Math.max(1, Math.round(result.durationMs / 1000))
)}.`
}
if (result.error) {
return `Precheck failed: ${result.error}`
}
return `Precheck exited with code ${result.exitCode ?? 'unknown'}.`
}

View File

@ -9,6 +9,7 @@ export type AutomationRunStatus =
| 'dispatching'
| 'dispatched'
| 'completed'
| 'skipped_precheck'
| 'skipped_missed'
| 'skipped_unavailable'
| 'skipped_needs_interactive_auth'
@ -54,10 +55,30 @@ export type AutomationRunOutputSnapshot = {
truncated: boolean
}
export type AutomationPrecheck = {
command: string
timeoutSeconds: number
}
export type AutomationPrecheckResult = {
command: string
exitCode: number | null
timedOut: boolean
durationMs: number
stdout: string
stderr: string
stdoutTruncated: boolean
stderrTruncated: boolean
error: string | null
startedAt: number
completedAt: number
}
export type Automation = {
id: string
name: string
prompt: string
precheck: AutomationPrecheck | null
agentId: TuiAgent
projectId: string
executionTargetType: AutomationExecutionTargetType
@ -94,6 +115,7 @@ export type AutomationRun = {
chatSessionId: string | null
terminalSessionId: string | null
outputSnapshot: AutomationRunOutputSnapshot | null
precheckResult: AutomationPrecheckResult | null
usage: AutomationRunUsage | null
error: string | null
startedAt: number | null
@ -104,6 +126,7 @@ export type AutomationRun = {
export type AutomationCreateInput = {
name: string
prompt: string
precheck?: AutomationPrecheck | null
agentId: TuiAgent
projectId: string
workspaceMode: AutomationWorkspaceMode
@ -122,6 +145,7 @@ export type AutomationUpdateInput = Partial<
Automation,
| 'name'
| 'prompt'
| 'precheck'
| 'agentId'
| 'projectId'
| 'workspaceMode'
@ -148,6 +172,7 @@ export type AutomationDispatchResult = {
workspaceDisplayName?: string | null
terminalSessionId?: string | null
outputSnapshot?: AutomationRunOutputSnapshot | null
precheckResult?: AutomationPrecheckResult | null
usage?: AutomationRunUsage | null
error?: string | null
}