Add automation precheck gating
This commit is contained in:
parent
129b47ca10
commit
a796d7952a
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'}.`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue