Add local automations workflow (#1806)

* Add local automations

Co-authored-by: Orca <help@stably.ai>

* Add local automations workflow

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-14 02:31:52 -04:00
committed by GitHub
co-authored by Orca
parent ffec767a4c
commit 1948458fb0
36 changed files with 3414 additions and 118 deletions
+91
View File
@@ -0,0 +1,91 @@
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 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')
}))
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', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-automations-test-'))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('dispatches an enabled automation when its next run is due', async () => {
vi.setSystemTime(new Date('2026-05-13T08:59:00'))
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Morning check',
prompt: 'Check the repo',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'existing',
workspaceId: 'wt1',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-12T00:00:00').getTime()
})
vi.setSystemTime(new Date('2026-05-13T09:01:00'))
const send = vi.fn()
const service = new AutomationService(store, { tickMs: 60_000 })
service.setWebContents({
isDestroyed: () => false,
send
} as never)
service.start()
service.setRendererReady()
await vi.waitFor(() =>
expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.any(Object))
)
service.stop()
const [, payload] = send.mock.calls[0]
expect(payload.automation.id).toBe(automation.id)
expect(payload.run.scheduledFor).toBe(new Date('2026-05-13T09:00:00').getTime())
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching')
expect(store.listAutomations().find((entry) => entry.id === automation.id)?.nextRunAt).toBe(
new Date('2026-05-14T09:00:00').getTime()
)
})
})
+130
View File
@@ -0,0 +1,130 @@
import type { WebContents } from 'electron'
import type { Store } from '../persistence'
import type {
Automation,
AutomationDispatchRequest,
AutomationDispatchResult,
AutomationRun
} from '../../shared/automations-types'
const DEFAULT_TICK_MS = 60 * 1000
export class AutomationService {
private readonly store: Store
private readonly tickMs: number
private timer: ReturnType<typeof setInterval> | null = null
private webContents: WebContents | null = null
private rendererReady = false
private evaluating = false
constructor(store: Store, opts: { tickMs?: number } = {}) {
this.store = store
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
}
setWebContents(webContents: WebContents | null): void {
this.webContents = webContents
this.rendererReady = false
}
setRendererReady(): void {
this.rendererReady = true
void this.evaluateDueRuns()
}
start(): void {
if (this.timer) {
return
}
this.timer = setInterval(() => {
void this.evaluateDueRuns()
}, this.tickMs)
if (this.rendererReady) {
void this.evaluateDueRuns()
}
}
stop(): void {
if (!this.timer) {
return
}
clearInterval(this.timer)
this.timer = null
}
async runNow(automationId: string): Promise<AutomationRun> {
const automation = this.store.listAutomations().find((entry) => entry.id === automationId)
if (!automation) {
throw new Error('Automation not found.')
}
const run = this.store.createAutomationRun(automation, Date.now(), 'manual')
await this.requestDispatch(automation, run)
return run
}
markDispatchResult(result: AutomationDispatchResult): AutomationRun {
return this.store.updateAutomationRun(result)
}
private async evaluateDueRuns(): Promise<void> {
if (this.evaluating) {
return
}
this.evaluating = true
try {
const now = Date.now()
for (const automation of this.store.listAutomations()) {
if (!automation.enabled || automation.nextRunAt > now) {
continue
}
await this.evaluateAutomation(automation, now)
}
} finally {
this.evaluating = false
}
}
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
if (scheduledFor === null) {
this.store.advanceAutomationNextRun(automation.id, now)
return
}
const run = this.store.createAutomationRun(automation, scheduledFor)
const graceMs = automation.missedRunGraceMinutes * 60 * 1000
if (now - scheduledFor > graceMs) {
this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_missed',
workspaceId: automation.workspaceId,
error: 'Orca was unavailable during the missed-run grace window.'
})
this.store.advanceAutomationNextRun(automation.id, now)
return
}
await this.requestDispatch(automation, run)
this.store.advanceAutomationNextRun(automation.id, now)
}
private async requestDispatch(automation: Automation, run: AutomationRun): Promise<void> {
const webContents = this.webContents
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
this.store.updateAutomationRun({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: 'No Orca window was available to launch the automation.'
})
return
}
this.store.updateAutomationRun({
runId: run.id,
status: 'dispatching',
workspaceId: automation.workspaceId,
error: null
})
const payload: AutomationDispatchRequest = { automation, run }
webContents.send('automations:dispatchRequested', payload)
}
}
@@ -89,6 +89,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
+1
View File
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
+12 -1
View File
@@ -54,6 +54,7 @@ import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvide
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@@ -74,6 +75,7 @@ let runtimeRpc: OrcaRuntimeRpcServer | null = null
let starNag: StarNagService | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@@ -188,6 +190,9 @@ function openMainWindow(): BrowserWindow {
if (!rateLimits) {
throw new Error('Rate limit service must be initialized before opening the main window')
}
if (!automations) {
throw new Error('Automation service must be initialized before opening the main window')
}
if (!codexAccounts) {
throw new Error('Codex account service must be initialized before opening the main window')
}
@@ -248,8 +253,11 @@ function openMainWindow(): BrowserWindow {
codexAccounts,
claudeAccounts,
rateLimits,
window.webContents.id
window.webContents.id,
automations
)
automations.setWebContents(window.webContents)
automations.start()
attachMainWindowServices(
window,
store,
@@ -263,6 +271,7 @@ function openMainWindow(): BrowserWindow {
if (mainWindow === window) {
mainWindow = null
}
automations?.setWebContents(null)
// Why: detach the agent hook listener on window close so the server
// never fires into a destroyed webContents during the gap before
// reopen (e.g. macOS dock re-activation). This also ensures the
@@ -511,6 +520,7 @@ app.whenReady().then(async () => {
// and defeat the teardown helper's prefix sweep (design §4.3 wire-up).
getLocalProvider: () => getLocalPtyProvider()
})
automations = new AutomationService(store)
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
starNag = new StarNagService(store, stats)
starNag.start()
@@ -685,6 +695,7 @@ app.on('will-quit', (e) => {
// so without this ordering, running agents would produce orphaned
// agent_start events with no matching stops.
starNag?.stop()
automations?.stop()
setUnreadDockBadgeCount(0)
agentHookServer.stop()
stats?.flush()
+42
View File
@@ -0,0 +1,42 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { AutomationService } from '../automations/service'
import type {
Automation,
AutomationCreateInput,
AutomationDispatchResult,
AutomationRun,
AutomationUpdateInput
} from '../../shared/automations-types'
export function registerAutomationHandlers(store: Store, service: AutomationService): void {
ipcMain.handle('automations:list', (): Automation[] => store.listAutomations())
ipcMain.handle(
'automations:listRuns',
(_event, args?: { automationId?: string }): AutomationRun[] =>
store.listAutomationRuns(args?.automationId)
)
ipcMain.handle(
'automations:create',
(_event, input: AutomationCreateInput): Automation => store.createAutomation(input)
)
ipcMain.handle(
'automations:update',
(_event, args: { id: string; updates: AutomationUpdateInput }): Automation =>
store.updateAutomation(args.id, args.updates)
)
ipcMain.handle('automations:delete', (_event, args: { id: string }): void => {
store.deleteAutomation(args.id)
})
ipcMain.handle(
'automations:runNow',
(_event, args: { id: string }): Promise<AutomationRun> => service.runNow(args.id)
)
ipcMain.handle(
'automations:markDispatchResult',
(_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result)
)
ipcMain.handle('automations:rendererReady', (): void => {
service.setRendererReady()
})
}
+7 -1
View File
@@ -24,6 +24,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerAutomationHandlers } from './automations'
import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
import { browserSessionRegistry } from '../browser/browser-session-registry'
@@ -44,6 +45,7 @@ import type { CodexUsageStore } from '../codex-usage/store'
import type { RateLimitService } from '../rate-limits/service'
import type { CodexAccountService } from '../codex-accounts/service'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { AutomationService } from '../automations/service'
let registered = false
@@ -56,7 +58,8 @@ export function registerCoreHandlers(
codexAccounts: CodexAccountService,
claudeAccounts: ClaudeAccountService,
rateLimits: RateLimitService,
mainWindowWebContentsId: number | null = null
mainWindowWebContentsId: number | null = null,
automations?: AutomationService
): void {
// Why: on macOS the app can stay alive after all windows close, then
// openMainWindow() is called again on 'activate'. ipcMain.handle() throws
@@ -91,6 +94,9 @@ export function registerCoreHandlers(
registerDeveloperPermissionHandlers()
registerComputerUsePermissionHandlers()
registerSettingsHandlers(store)
if (automations) {
registerAutomationHandlers(store, automations)
}
registerTelemetryHandlers(store)
registerBrowserHandlers()
// Why: applyPendingCookieImport MUST run before restorePersistedUserAgent
+51
View File
@@ -179,6 +179,57 @@ describe('Store', () => {
expect(repos[0].gitUsername).toBe('testuser')
})
it('can clear an automation back to the project default branch', async () => {
const store = await createStore()
store.addRepo(makeRepo({ worktreeBaseRef: 'origin/main' }))
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
agentId: 'claude',
projectId: 'r1',
workspaceMode: 'new_per_run',
baseBranch: 'origin/release',
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
})
const updated = store.updateAutomation(automation.id, { baseBranch: null })
expect(updated.baseBranch).toBeNull()
store.flush()
const persisted = readDataFile() as { automations: { baseBranch: string | null }[] }
expect(persisted.automations[0].baseBranch).toBeNull()
})
it('numbers automation run titles per automation', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const automation = store.createAutomation({
name: 'Nightly',
prompt: 'Run checks',
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 first = store.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
const duplicate = store.createAutomationRun(
automation,
new Date('2026-05-13T09:00:00Z').getTime()
)
const second = store.createAutomationRun(automation, new Date('2026-05-14T09:00:00Z').getTime())
expect(first.title).toBe('Nightly run 1')
expect(duplicate.id).toBe(first.id)
expect(duplicate.title).toBe('Nightly run 1')
expect(second.title).toBe('Nightly run 2')
})
// ── 3. Corrupt JSON → falls back to defaults ────────────────────────
it('falls back to defaults when data file contains invalid JSON', async () => {
+194
View File
@@ -7,6 +7,18 @@ import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { randomUUID } from 'node:crypto'
import type {
Automation,
AutomationCreateInput,
AutomationDispatchResult,
AutomationRun,
AutomationRunTrigger,
AutomationUpdateInput
} from '../shared/automations-types'
import {
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter
} from '../shared/automation-schedules'
import type {
PersistedState,
Repo,
@@ -403,6 +415,8 @@ export class Store {
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
.map(normalizeSshRemotePtyLease)
.filter((lease): lease is SshRemotePtyLease => lease !== null),
automations: Array.isArray(parsed.automations) ? parsed.automations : [],
automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [],
onboarding: (() => {
// Why: if we successfully parsed an existing orca-data.json that
// lacks an onboarding block, this is an upgrade-cohort user —
@@ -782,6 +796,186 @@ export class Store {
this.scheduleSave()
}
// ── Automations ───────────────────────────────────────────────────
listAutomations(): Automation[] {
return [...(this.state.automations ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
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)
}
createAutomation(input: AutomationCreateInput): Automation {
const repo = this.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const automation: Automation = {
id: randomUUID(),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
agentId: input.agentId,
projectId: input.projectId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
workspaceMode: input.workspaceMode,
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
timezone: input.timezone,
rrule: input.rrule,
dtstart: input.dtstart,
enabled: input.enabled ?? true,
nextRunAt: nextAutomationOccurrenceAfter(input.rrule, input.dtstart, now),
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: input.missedRunGraceMinutes ?? 720,
createdAt: now,
updatedAt: now
}
this.state.automations = [...(this.state.automations ?? []), automation]
this.flush()
return automation
}
updateAutomation(id: string, updates: AutomationUpdateInput): Automation {
const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = this.state.automations[index]
const repoId = updates.projectId ?? current.projectId
const repo = this.state.repos.find((entry) => entry.id === repoId)
const executionTargetType = repo?.connectionId ? 'ssh' : 'local'
const rrule = updates.rrule ?? current.rrule
const dtstart = updates.dtstart ?? current.dtstart
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
const updated: Automation = {
...current,
...updates,
name:
updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
projectId: repoId,
executionTargetType,
executionTargetId: executionTargetType === 'ssh' ? (repo?.connectionId ?? '') : 'local',
schedulerOwner: executionTargetType === 'ssh' ? 'ssh_bridge' : 'local_host_service',
workspaceMode,
workspaceId:
workspaceMode === 'existing'
? Object.hasOwn(updates, 'workspaceId')
? (updates.workspaceId ?? null)
: current.workspaceId
: null,
baseBranch:
workspaceMode === 'new_per_run'
? Object.hasOwn(updates, 'baseBranch')
? (updates.baseBranch ?? null)
: (current.baseBranch ?? null)
: null,
rrule,
dtstart,
nextRunAt: scheduleChanged
? nextAutomationOccurrenceAfter(rrule, dtstart, Date.now())
: current.nextRunAt,
updatedAt: Date.now()
}
this.state.automations[index] = updated
this.flush()
return updated
}
deleteAutomation(id: string): void {
this.state.automations = (this.state.automations ?? []).filter((entry) => entry.id !== id)
this.state.automationRuns = (this.state.automationRuns ?? []).filter(
(entry) => entry.automationId !== id
)
this.flush()
}
createAutomationRun(
automation: Automation,
scheduledFor: number,
trigger: AutomationRunTrigger = 'scheduled'
): AutomationRun {
const existing = (this.state.automationRuns ?? []).find(
(run) => run.automationId === automation.id && run.scheduledFor === scheduledFor
)
if (existing) {
return existing
}
const now = Date.now()
const runNumber =
(this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id).length +
1
const run: AutomationRun = {
id: randomUUID(),
automationId: automation.id,
title: `${automation.name} run ${runNumber}`,
scheduledFor,
status: 'pending',
trigger,
workspaceId: automation.workspaceId,
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
error: null,
startedAt: null,
dispatchedAt: null,
createdAt: now
}
this.state.automationRuns = [...(this.state.automationRuns ?? []), run]
this.flush()
return run
}
updateAutomationRun(result: AutomationDispatchResult): AutomationRun {
const index = (this.state.automationRuns ?? []).findIndex((entry) => entry.id === result.runId)
if (index === -1) {
throw new Error('Automation run not found.')
}
const now = Date.now()
const current = this.state.automationRuns[index]
const updated: AutomationRun = {
...current,
status: result.status,
workspaceId: result.workspaceId ?? current.workspaceId,
terminalSessionId: result.terminalSessionId ?? current.terminalSessionId,
error: result.error ?? null,
startedAt: current.startedAt ?? now,
dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt
}
this.state.automationRuns[index] = updated
const automation = this.state.automations.find((entry) => entry.id === updated.automationId)
if (automation) {
automation.lastRunAt = now
automation.updatedAt = now
}
this.flush()
return updated
}
advanceAutomationNextRun(id: string, now = Date.now()): Automation {
const index = (this.state.automations ?? []).findIndex((entry) => entry.id === id)
if (index === -1) {
throw new Error('Automation not found.')
}
const current = this.state.automations[index]
const nextRunAt = nextAutomationOccurrenceAfter(current.rrule, current.dtstart, now)
const updated = { ...current, nextRunAt, updatedAt: Date.now() }
this.state.automations[index] = updated
this.flush()
return updated
}
getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null {
return latestAutomationOccurrenceAtOrBefore(automation.rrule, automation.dtstart, now)
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
+19
View File
@@ -170,6 +170,14 @@ import type {
} from '../shared/codex-usage-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import type {
Automation,
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
AutomationRun,
AutomationUpdateInput
} from '../shared/automations-types'
export type BrowserApi = {
registerGuest: (args: {
@@ -1280,6 +1288,17 @@ export type PreloadApi = {
onCredentialResolved: (callback: (data: { requestId: string }) => void) => () => void
submitCredential: (args: { requestId: string; value: string | null }) => Promise<void>
}
automations: {
list: () => Promise<Automation[]>
listRuns: (args?: { automationId?: string }) => Promise<AutomationRun[]>
create: (input: AutomationCreateInput) => Promise<Automation>
update: (args: { id: string; updates: AutomationUpdateInput }) => Promise<Automation>
delete: (args: { id: string }) => Promise<void>
runNow: (args: { id: string }) => Promise<AutomationRun>
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
rendererReady: () => Promise<void>
onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void
}
wsl: {
isAvailable: () => Promise<boolean>
}
+31
View File
@@ -79,6 +79,14 @@ import type { AgentStatusIpcPayload } from '../shared/agent-status-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { RefreshAgentsResult } from './api-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import type {
Automation,
AutomationCreateInput,
AutomationDispatchRequest,
AutomationDispatchResult,
AutomationRun,
AutomationUpdateInput
} from '../shared/automations-types'
import {
ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT,
type EditorSaveDirtyFilesDetail
@@ -2394,6 +2402,29 @@ const api = {
submitCredential: (args: { requestId: string; value: string | null }): Promise<void> =>
ipcRenderer.invoke('ssh:submitCredential', args)
},
automations: {
list: (): Promise<Automation[]> => ipcRenderer.invoke('automations:list'),
listRuns: (args?: { automationId?: string }): Promise<AutomationRun[]> =>
ipcRenderer.invoke('automations:listRuns', args),
create: (input: AutomationCreateInput): Promise<Automation> =>
ipcRenderer.invoke('automations:create', input),
update: (args: { id: string; updates: AutomationUpdateInput }): Promise<Automation> =>
ipcRenderer.invoke('automations:update', args),
delete: (args: { id: string }): Promise<void> => ipcRenderer.invoke('automations:delete', args),
runNow: (args: { id: string }): Promise<AutomationRun> =>
ipcRenderer.invoke('automations:runNow', args),
markDispatchResult: (result: AutomationDispatchResult): Promise<AutomationRun> =>
ipcRenderer.invoke('automations:markDispatchResult', result),
rendererReady: (): Promise<void> => ipcRenderer.invoke('automations:rendererReady'),
onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) =>
callback(request)
ipcRenderer.on('automations:dispatchRequested', listener)
return () => ipcRenderer.removeListener('automations:dispatchRequested', listener)
}
},
e2e: {
getConfig: () => preloadE2EConfig
},
+15 -5
View File
@@ -34,6 +34,7 @@ import {
import { useAppStore } from './store'
import { useShallow } from 'zustand/react/shallow'
import { useIpcEvents } from './hooks/useIpcEvents'
import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents'
import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate'
import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls'
import Sidebar from './components/Sidebar'
@@ -146,6 +147,7 @@ function WindowControls(): React.JSX.Element {
const Landing = lazy(() => import('./components/Landing'))
const TaskPage = lazy(() => import('./components/TaskPage'))
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('./components/settings/Settings'))
const QuickOpen = lazy(() => import('./components/QuickOpen'))
@@ -292,6 +294,7 @@ function App(): React.JSX.Element {
// Subscribe to IPC push events
useIpcEvents()
useAutomationDispatchEvents()
// Why: retention must run at App level so the inline per-card agents list
// always sees retained entries. If retention ran inside the sidebar-card
// subtree, "done" agents would vanish any time the user collapsed a card's
@@ -490,7 +493,7 @@ function App(): React.JSX.Element {
return useAppStore.subscribe((state, previousState) => {
// Why: skip the key build entirely when no input field has changed by
// reference. Mirrors every field used by getRuntimeMobileSessionSyncKey
// so this gate stays a strict superset of "could the key have changed?"
// so this gate covers every "could the key have changed?" case.
// — if any field's reference is unchanged, neither the projection
// serialized from it nor the reference-compared map can have changed.
if (
@@ -683,7 +686,10 @@ function App(): React.JSX.Element {
// Why: suppress right sidebar controls on full-page navigation surfaces
// since those surfaces intentionally own the full content area.
const showRightSidebarControls =
activeView !== 'settings' && activeView !== 'tasks' && activeView !== 'activity'
activeView !== 'settings' &&
activeView !== 'tasks' &&
activeView !== 'activity' &&
activeView !== 'automations'
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
@@ -738,7 +744,7 @@ function App(): React.JSX.Element {
// Why: Back/Forward traverse mixed worktree + Tasks visits, so the
// shortcut is active wherever the titlebar button cluster is (terminal
// or tasks). Still suppressed in Settings to keep that view modal-ish.
if (activeView !== 'terminal' && activeView !== 'tasks') {
if (activeView !== 'terminal' && activeView !== 'tasks' && activeView !== 'automations') {
return
}
e.preventDefault()
@@ -770,7 +776,7 @@ function App(): React.JSX.Element {
// Why: full-page navigation surfaces should not reveal the right sidebar;
// they are designed as distraction-free content areas.
if (activeView === 'tasks' || activeView === 'activity') {
if (activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') {
return
}
@@ -1002,7 +1008,10 @@ function App(): React.JSX.Element {
) : null
useEffect(() => {
if ((activeView === 'tasks' || activeView === 'activity') && rightSidebarOpen) {
if (
(activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') &&
rightSidebarOpen
) {
// Why: hide the right sidebar immediately when entering full-page
// navigation views so previous side-panel state cannot occlude them.
actions.setRightSidebarOpen(false)
@@ -1168,6 +1177,7 @@ function App(): React.JSX.Element {
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
@@ -0,0 +1,290 @@
import React from 'react'
import { Pencil, Pause, Play, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
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 type { Worktree } from '../../../../shared/types'
import { parseAutomationRrule } from '../../../../shared/automation-schedules'
import {
formatAutomationDateTime,
formatAutomationDateTimeWithRelative,
getAutomationRunStatusLabel,
getAutomationRunStatusVariant
} from './automation-page-parts'
type AutomationDetailProps = {
automation: Automation | null
runs: AutomationRun[]
projectName: string
workspaceName: string
projectDefaultBaseRef: string | null
worktreeMap: Map<string, Worktree>
now: number
onRunNow: (automation: Automation) => void
onOpenRunWorkspace: (run: AutomationRun) => void
onEdit: (automation: Automation) => void
onToggle: (automation: Automation) => void
onDelete: (automation: Automation) => void
}
function DetailMetric({ label, value }: { label: string; value: string }): React.JSX.Element {
return (
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium">{value}</div>
</div>
)
}
function formatTime(hour: number, minute: number): string {
const date = new Date()
date.setHours(hour, minute, 0, 0)
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit'
}).format(date)
}
function formatGrace(minutes: number): string {
if (minutes <= 0) {
return 'No grace'
}
if (minutes < 60) {
return `${minutes} minutes`
}
const hours = minutes / 60
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`
}
function formatSchedule(rrule: string): string {
const schedule = parseAutomationRrule(rrule)
if (schedule.preset === 'hourly') {
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
}
const time = formatTime(schedule.hour, schedule.minute)
if (schedule.preset === 'daily') {
return `Daily at ${time}`
}
if (schedule.preset === 'weekdays') {
return `Weekdays at ${time}`
}
const day = new Intl.DateTimeFormat(undefined, { weekday: 'long' }).format(
new Date(2026, 0, 4 + schedule.dayOfWeek)
)
return `${day}s at ${time}`
}
function ToolbarIconButton({
label,
children,
onClick,
className
}: {
label: string
children: React.ReactNode
onClick: () => void
className?: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={label}
onClick={onClick}
className={className}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
export function AutomationDetail({
automation,
runs,
projectName,
workspaceName,
projectDefaultBaseRef,
worktreeMap,
now,
onRunNow,
onOpenRunWorkspace,
onEdit,
onToggle,
onDelete
}: AutomationDetailProps): React.JSX.Element {
if (!automation) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Create an automation to start scheduling agent work.
</div>
)
}
return (
<div className="flex w-full flex-col gap-4">
<div className="flex items-start justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-lg font-semibold">{automation.name}</h2>
<Badge variant={automation.enabled ? 'secondary' : 'outline'}>
{automation.enabled ? 'Enabled' : 'Paused'}
</Badge>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{projectName} / {workspaceName}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="secondary" size="sm" onClick={() => onRunNow(automation)}>
<Play className="size-4" />
Run Now
</Button>
<ToolbarIconButton label="Edit automation" onClick={() => onEdit(automation)}>
<Pencil className="size-4" />
</ToolbarIconButton>
<ToolbarIconButton
label={automation.enabled ? 'Pause automation' : 'Resume automation'}
onClick={() => onToggle(automation)}
>
{automation.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
</ToolbarIconButton>
<ToolbarIconButton
label="Delete automation"
onClick={() => onDelete(automation)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</ToolbarIconButton>
</div>
</div>
{automation.executionTargetType === 'ssh' ? (
<div className="rounded-md border border-border/50 bg-muted/50 p-3 text-sm text-muted-foreground shadow-sm">
This SSH automation runs only while Orca can reach the SSH host. If reconnect needs
interactive credentials or the host is unavailable, the run is recorded as skipped.
</div>
) : null}
<div className="grid grid-cols-4 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
<DetailMetric label="Run location" value={`${projectName} / ${workspaceName}`} />
<DetailMetric
label="Next run"
value={
automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, now)
: 'Paused'
}
/>
<DetailMetric
label="Last run"
value={formatAutomationDateTimeWithRelative(automation.lastRunAt, now)}
/>
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
</div>
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">Configuration</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-6 gap-y-4 px-3 py-3">
<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">
<AgentIcon agent={automation.agentId} size={16} />
<span className="truncate">
{AGENT_CATALOG.find((agent) => agent.id === automation.agentId)?.label ??
automation.agentId}
</span>
</div>
</div>
<DetailMetric label="Schedule" value={formatSchedule(automation.rrule)} />
<DetailMetric
label={automation.workspaceMode === 'new_per_run' ? 'Create from' : 'Workspace'}
value={
automation.workspaceMode === 'new_per_run'
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
: workspaceName
}
/>
<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>
</div>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="text-sm font-medium">Run history</div>
<div className="text-xs text-muted-foreground">{runs.length} runs</div>
</div>
<div className="grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
<div>Run</div>
<div>Workspace</div>
<div>Status</div>
</div>
<div className="divide-y divide-border/50">
{runs.map((run) => {
const runWorktree = run.workspaceId ? (worktreeMap.get(run.workspaceId) ?? null) : null
const workspaceLabel = run.workspaceId
? (runWorktree?.displayName ?? 'Missing workspace')
: 'Not launched'
const rowClassName =
'grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors'
const rowContent = (
<>
<div className="min-w-0">
<div>{formatAutomationDateTime(run.scheduledFor)}</div>
{run.error ? (
<div className="mt-1 truncate text-xs text-muted-foreground">{run.error}</div>
) : null}
</div>
<div
className={
runWorktree
? 'min-w-0 truncate text-foreground'
: 'min-w-0 truncate text-muted-foreground'
}
>
{workspaceLabel}
</div>
<div className="flex justify-start">
<Badge variant={getAutomationRunStatusVariant(run.status)}>
{getAutomationRunStatusLabel(run.status)}
</Badge>
</div>
</>
)
return runWorktree ? (
<button
key={run.id}
type="button"
className={`${rowClassName} w-full cursor-pointer hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-[3px] focus-visible:ring-ring/50`}
onClick={() => onOpenRunWorkspace(run)}
>
{rowContent}
</button>
) : (
<div key={run.id} className={rowClassName}>
{rowContent}
</div>
)
})}
{runs.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">No runs yet.</div>
) : null}
</div>
</div>
</div>
)
}
@@ -0,0 +1,286 @@
import React from 'react'
import { Info, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import AgentCombobox from '@/components/agent/AgentCombobox'
import RepoCombobox from '@/components/repo/RepoCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type {
AutomationSchedulePreset,
AutomationWorkspaceMode
} from '../../../../shared/automations-types'
import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types'
import { Field } from './automation-page-parts'
import { CreateFromPicker } from './CreateFromPicker'
import { WorkspaceCombobox } from './WorkspaceCombobox'
export type AutomationDraft = {
name: string
prompt: string
agentId: TuiAgent
projectId: string
workspaceMode: AutomationWorkspaceMode
workspaceId: string
baseBranch: string
preset: AutomationSchedulePreset
time: string
dayOfWeek: string
missedRunGraceMinutes: string
}
type AutomationEditorDialogProps = {
open: boolean
isEditing: boolean
isSaving: boolean
canSave: boolean
repos: Repo[]
repoMap: Map<string, Repo>
worktrees: Worktree[]
settings: GlobalSettings | null
draft: AutomationDraft
onProjectChange: (projectId: string) => void
onOpenChange: (open: boolean) => void
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
onSave: () => void
}
export function AutomationEditorDialog({
open,
isEditing,
isSaving,
canSave,
repos,
repoMap,
worktrees,
settings,
draft,
onProjectChange,
onOpenChange,
onDraftChange,
onSave
}: AutomationEditorDialogProps): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg"
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">
{isEditing ? 'Edit Automation' : 'Create Automation'}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3">
<Field label="Name">
<Input
value={draft.name}
placeholder="Weekday repo audit"
onChange={(event) =>
onDraftChange((current) => ({ ...current, name: event.target.value }))
}
/>
</Field>
<Field label="Project">
<RepoCombobox
repos={repos}
value={draft.projectId}
onValueChange={onProjectChange}
placeholder="Select project"
triggerClassName="h-9 w-full min-w-0"
showStandaloneAddButton={false}
/>
</Field>
<Field label="Run location">
<ToggleGroup
type="single"
value={draft.workspaceMode}
onValueChange={(workspaceMode) =>
workspaceMode &&
onDraftChange((current) => ({
...current,
workspaceMode: workspaceMode as AutomationWorkspaceMode
}))
}
variant="outline"
size="sm"
className="grid w-full grid-cols-2"
>
<ToggleGroupItem value="existing" className="w-full">
Selected workspace
</ToggleGroupItem>
<ToggleGroupItem value="new_per_run" className="w-full">
New workspace each run
</ToggleGroupItem>
</ToggleGroup>
</Field>
{draft.workspaceMode === 'existing' ? (
<Field label="Workspace">
<WorkspaceCombobox
worktrees={worktrees}
value={draft.workspaceId}
onValueChange={(workspaceId) =>
onDraftChange((current) => ({ ...current, workspaceId }))
}
/>
</Field>
) : (
<Field label="Create from">
<CreateFromPicker
repoId={draft.projectId}
repoMap={repoMap}
worktrees={worktrees}
value={draft.baseBranch}
onValueChange={(baseBranch) =>
onDraftChange((current) => ({ ...current, baseBranch }))
}
/>
</Field>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Agent">
<AgentCombobox
agents={AGENT_CATALOG}
value={draft.agentId}
onValueChange={(agentId) =>
agentId && onDraftChange((current) => ({ ...current, agentId }))
}
defaultAgent={settings?.defaultTuiAgent ?? null}
triggerClassName="h-9 w-full min-w-0"
/>
</Field>
<Field label="Schedule">
<Select
value={draft.preset}
onValueChange={(preset) =>
onDraftChange((current) => ({
...current,
preset: preset as AutomationSchedulePreset
}))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekdays">Weekdays</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Time">
<Input
type="time"
value={draft.time}
disabled={draft.preset === 'hourly'}
onChange={(event) =>
onDraftChange((current) => ({ ...current, time: event.target.value }))
}
/>
</Field>
<Field label="Day">
<Select
value={draft.dayOfWeek}
disabled={draft.preset !== 'weekly'}
onValueChange={(dayOfWeek) =>
onDraftChange((current) => ({ ...current, dayOfWeek }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Sunday</SelectItem>
<SelectItem value="1">Monday</SelectItem>
<SelectItem value="2">Tuesday</SelectItem>
<SelectItem value="3">Wednesday</SelectItem>
<SelectItem value="4">Thursday</SelectItem>
<SelectItem value="5">Friday</SelectItem>
<SelectItem value="6">Saturday</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field
label={
<span className="inline-flex items-center gap-1">
Missed-run grace
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Missed-run grace help"
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-72">
If Orca or the execution host was unavailable at the scheduled time, Orca runs
one missed occurrence when it becomes available within this window. Older missed
runs are skipped.
</TooltipContent>
</Tooltip>
</span>
}
>
<Select
value={draft.missedRunGraceMinutes}
onValueChange={(missedRunGraceMinutes) =>
onDraftChange((current) => ({ ...current, missedRunGraceMinutes }))
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start" sideOffset={4}>
<SelectItem value="0">No grace</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
<SelectItem value="180">3 hours</SelectItem>
<SelectItem value="720">12 hours</SelectItem>
<SelectItem value="1440">24 hours</SelectItem>
<SelectItem value="2880">48 hours</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Prompt">
<textarea
value={draft.prompt}
rows={5}
placeholder="Run the weekly dependency audit and summarize risky changes."
onChange={(event) =>
onDraftChange((current) => ({ ...current, prompt: event.target.value }))
}
className="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>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onSave} disabled={isSaving || repos.length === 0 || !canSave}>
{isEditing ? null : <Plus className="size-4" />}
{isEditing ? 'Save Changes' : 'Save Automation'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,723 @@
/* eslint-disable max-lines -- Why: this page owns the automations list/detail
* orchestration while the form and detail presentation live in sibling files. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CalendarClock, Check, Pause, Pencil, Play, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useRepoMap, useWorktreeMap } from '@/store/selectors'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type {
Automation,
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { Worktree } from '../../../../shared/types'
import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/automation-schedules'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { AutomationDetail } from './AutomationDetail'
import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog'
const AGENTS = AGENT_CATALOG.map((agent) => agent.id)
const DEFAULT_TIME = '09:00'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
}
function formatTimeInput(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
export default function AutomationsPage(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const updateSettings = useAppStore((s) => s.updateSettings)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const settings = useAppStore((s) => s.settings)
const selectedId = useAppStore((s) => s.selectedAutomationId)
const setSelectedId = useAppStore((s) => s.setSelectedAutomationId)
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const defaultAgent =
settings?.defaultTuiAgent && settings.defaultTuiAgent !== 'blank'
? settings.defaultTuiAgent
: AGENTS[0]
const [automations, setAutomations] = useState<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
const [relativeNow, setRelativeNow] = useState(Date.now())
const [draftAtOpen, setDraftAtOpen] = useState<AutomationDraft | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null)
const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false)
const editRequestRef = useRef(0)
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
const [draft, setDraft] = useState<AutomationDraft>({
name: '',
prompt: '',
agentId: defaultAgent,
projectId: '',
workspaceMode: 'existing',
workspaceId: '',
baseBranch: '',
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
missedRunGraceMinutes: '720'
})
const selected =
automations.find((automation) => automation.id === selectedId) ?? automations[0] ?? null
const selectedRuns = runs.filter((run) => run.automationId === selected?.id)
const worktrees = useMemo(
() => worktreesByRepo[draft.projectId] ?? [],
[draft.projectId, worktreesByRepo]
)
const selectedRepo = selected ? (repoMap.get(selected.projectId) ?? null) : null
const selectedWorktree =
selected && selected.workspaceId ? (worktreeMap.get(selected.workspaceId) ?? null) : null
const canSaveDraft =
editingAutomationId === null ||
!draftAtOpen ||
JSON.stringify(draft) !== JSON.stringify(draftAtOpen)
const getDefaultTarget = useCallback(() => {
const activeWorktree = activeWorktreeId ? worktreeMap.get(activeWorktreeId) : null
const activeRepo = activeWorktree ? (repoMap.get(activeWorktree.repoId) ?? null) : null
const fallbackRepo = activeRepo ?? repos[0] ?? null
const fallbackWorktrees = fallbackRepo ? (worktreesByRepo[fallbackRepo.id] ?? []) : []
// Why: automation-created workspaces can be active; new automations should start from
// the repo's stable main worktree unless the user explicitly chooses otherwise.
const targetWorktree = getDefaultWorktree(fallbackWorktrees) ?? activeWorktree
const targetProjectId = fallbackRepo?.id ?? targetWorktree?.repoId ?? ''
return {
projectId: targetProjectId,
workspaceId: targetWorktree?.id ?? ''
}
}, [activeWorktreeId, repoMap, repos, worktreeMap, worktreesByRepo])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const [nextAutomations, nextRuns] = await Promise.all([
window.api.automations.list(),
window.api.automations.listRuns()
])
setAutomations(nextAutomations)
setRuns(nextRuns)
const currentSelectedId = useAppStore.getState().selectedAutomationId
const hasCurrentSelection = nextAutomations.some(
(automation) => automation.id === currentSelectedId
)
if (!hasCurrentSelection) {
setSelectedId(nextAutomations[0]?.id ?? null)
}
} finally {
setIsLoading(false)
}
}, [setSelectedId])
useEffect(() => {
void fetchAllWorktrees()
void refresh()
}, [fetchAllWorktrees, refresh])
useEffect(() => {
const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000)
return () => window.clearInterval(timer)
}, [])
useEffect(() => {
const onAutomationsChanged = (): void => {
void refresh()
}
window.addEventListener(AUTOMATIONS_CHANGED_EVENT, onAutomationsChanged)
return () => window.removeEventListener(AUTOMATIONS_CHANGED_EVENT, onAutomationsChanged)
}, [refresh])
useEffect(() => {
const completedRuns = runs.filter((run) => {
if (run.status !== 'dispatched' || !run.terminalSessionId) {
return false
}
const paneKeyPrefix = `${run.terminalSessionId}:`
const liveDone = Object.entries(agentStatusByPaneKey).some(
([paneKey, entry]) => paneKey.startsWith(paneKeyPrefix) && entry.state === 'done'
)
if (liveDone) {
return true
}
return Object.entries(retainedAgentsByPaneKey).some(
([paneKey, retained]) =>
paneKey.startsWith(paneKeyPrefix) && retained.entry.state === 'done'
)
})
if (completedRuns.length === 0) {
return
}
void Promise.all(
completedRuns.map((run) =>
window.api.automations.markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: run.workspaceId,
terminalSessionId: run.terminalSessionId,
error: null
})
)
).then(() => refresh())
}, [agentStatusByPaneKey, retainedAgentsByPaneKey, refresh, runs])
useEffect(() => {
if (!draft.projectId) {
const target = getDefaultTarget()
if (!target.projectId) {
return
}
setDraft((current) => ({
...current,
projectId: target.projectId,
workspaceId: target.workspaceId
}))
}
}, [draft.projectId, getDefaultTarget])
useEffect(() => {
if (!draft.projectId) {
return
}
const available = worktreesByRepo[draft.projectId] ?? []
const defaultWorktree = getDefaultWorktree(available)
if (!draft.workspaceId && defaultWorktree) {
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
}
}, [draft.projectId, draft.workspaceId, worktreesByRepo])
const openCreateDialog = (): void => {
editRequestRef.current += 1
const target = getDefaultTarget()
setEditingAutomationId(null)
const nextDraft: AutomationDraft = {
name: '',
prompt: '',
agentId: defaultAgent,
projectId: target.projectId,
workspaceMode: 'existing',
workspaceId: target.workspaceId,
baseBranch: '',
preset: 'weekdays',
time: DEFAULT_TIME,
dayOfWeek: '1',
missedRunGraceMinutes: '720'
}
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const openEditDialog = async (automation: Automation): Promise<void> => {
const requestId = (editRequestRef.current += 1)
let latest = automation
try {
latest =
(await window.api.automations.list()).find((entry) => entry.id === automation.id) ??
automation
} catch {
latest = automation
}
if (requestId !== editRequestRef.current) {
return
}
const schedule = parseAutomationRrule(latest.rrule)
setEditingAutomationId(latest.id)
const nextDraft: AutomationDraft = {
name: latest.name,
prompt: latest.prompt,
agentId: latest.agentId,
projectId: latest.projectId,
workspaceMode: latest.workspaceMode,
workspaceId: latest.workspaceId ?? '',
baseBranch: latest.baseBranch ?? '',
preset: schedule.preset,
time: formatTimeInput(schedule.hour, schedule.minute),
dayOfWeek: String(schedule.dayOfWeek),
missedRunGraceMinutes: String(latest.missedRunGraceMinutes)
}
setDraft(nextDraft)
setDraftAtOpen(nextDraft)
setCreateOpen(true)
}
const handleProjectChange = useCallback(
(projectId: string): void => {
const currentWorktrees = worktreesByRepo[projectId] ?? []
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
setDraft((current) => ({
...current,
projectId,
workspaceId: currentDefaultWorktree?.id ?? '',
baseBranch: ''
}))
void fetchWorktrees(projectId).then(() => {
const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? []
const latestWorktree = getDefaultWorktree(latestWorktrees)
if (!latestWorktree) {
return
}
// Why: project worktrees may not be loaded when the repo picker changes.
// Select after fetching so saving does not fail on an empty workspace id.
setDraft((current) =>
current.projectId === projectId && !current.workspaceId
? { ...current, workspaceId: latestWorktree.id }
: current
)
})
},
[fetchWorktrees, worktreesByRepo]
)
const saveAutomation = async (): Promise<void> => {
const [hour, minute] = draft.time.split(':').map((part) => Number(part))
if (
!draft.projectId ||
(draft.workspaceMode === 'existing' && !draft.workspaceId) ||
!draft.prompt.trim()
) {
toast.error('Choose a run location and enter a prompt before saving.')
return
}
setIsSaving(true)
try {
const selectedWorkspaceExists =
draft.workspaceMode !== 'existing' ||
worktrees.some((worktree) => worktree.id === draft.workspaceId)
if (!selectedWorkspaceExists) {
toast.error('Choose an available workspace before saving.')
return
}
const now = Date.now()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const rrule = buildAutomationRrule({
preset: draft.preset,
hour: Number.isFinite(hour) ? hour : 9,
minute: Number.isFinite(minute) ? minute : 0,
dayOfWeek: Number(draft.dayOfWeek)
})
const rawMissedRunGraceMinutes = Number(draft.missedRunGraceMinutes)
const missedRunGraceMinutes = Number.isFinite(rawMissedRunGraceMinutes)
? Math.max(0, rawMissedRunGraceMinutes)
: 720
let currentAutomation = editingAutomationId
? (automations.find((automation) => automation.id === editingAutomationId) ?? null)
: null
if (editingAutomationId) {
try {
currentAutomation =
(await window.api.automations.list()).find(
(automation) => automation.id === editingAutomationId
) ?? currentAutomation
} catch {
// Keep the in-memory automation as a fallback if the refresh fails.
}
}
const updates: AutomationUpdateInput = {
name: draft.name,
prompt: draft.prompt,
agentId: draft.agentId,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
timezone,
missedRunGraceMinutes
}
if (!currentAutomation || currentAutomation.rrule !== rrule) {
// Why: non-schedule edits should not reset dtstart or move nextRunAt.
updates.rrule = rrule
updates.dtstart = now
}
const automation = editingAutomationId
? await window.api.automations.update({
id: editingAutomationId,
updates
})
: await window.api.automations.create({
name: draft.name,
prompt: draft.prompt,
agentId: draft.agentId,
projectId: draft.projectId,
workspaceMode: draft.workspaceMode,
workspaceId: draft.workspaceId,
baseBranch: draft.baseBranch.trim() || null,
timezone,
rrule,
dtstart: now,
missedRunGraceMinutes
})
setAutomations((current) => {
const next = current.filter((entry) => entry.id !== automation.id)
return [...next, automation].sort((left, right) => left.name.localeCompare(right.name))
})
setDraft((current) => ({ ...current, name: '', prompt: '' }))
await refresh()
setSelectedId(automation.id)
setCreateOpen(false)
toast.success(editingAutomationId ? 'Automation updated.' : 'Automation saved.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save automation.')
} finally {
setIsSaving(false)
}
}
const toggleAutomation = async (automation: Automation): Promise<void> => {
await window.api.automations.update({
id: automation.id,
updates: { enabled: !automation.enabled }
})
await refresh()
}
const deleteAutomation = async (automation: Automation): Promise<void> => {
await window.api.automations.delete({ id: automation.id })
if (useAppStore.getState().selectedAutomationId === automation.id) {
setSelectedId(null)
}
await refresh()
}
const persistDeleteAutomationPreference = (): void => {
void updateSettings({ skipDeleteAutomationConfirm: true })
toast.success("We'll skip this confirmation next time.", {
description: 'You can change this in Settings.',
duration: 8000,
action: {
label: 'Open Settings',
onClick: () => {
openSettingsPage()
openSettingsTarget({
pane: 'general',
repoId: null,
sectionId: 'general-skip-delete-automation-confirm'
})
}
}
})
}
const requestDeleteAutomation = (automation: Automation): void => {
if (settings?.skipDeleteAutomationConfirm) {
void deleteAutomation(automation)
return
}
setDontAskDeleteAgain(false)
setDeleteTarget(automation)
}
const confirmDeleteAutomation = async (): Promise<void> => {
if (!deleteTarget) {
return
}
if (dontAskDeleteAgain) {
persistDeleteAutomationPreference()
}
const target = deleteTarget
setDeleteTarget(null)
setDontAskDeleteAgain(false)
await deleteAutomation(target)
}
const runNow = async (automation: Automation): Promise<void> => {
await window.api.automations.runNow({ id: automation.id })
await refresh()
toast.message('Automation run queued.')
}
const openRunWorkspace = (run: AutomationRun): void => {
if (!run.workspaceId || !activateAndRevealWorktree(run.workspaceId)) {
toast.error('Workspace is not available.')
return
}
if (run.terminalSessionId) {
const store = useAppStore.getState()
if (store.getTab(run.terminalSessionId)) {
store.setActiveTab(run.terminalSessionId)
store.setActiveTabType('terminal')
}
}
}
return (
<main className="relative flex h-full min-h-0 flex-col bg-background text-foreground">
<header className="flex shrink-0 items-center justify-between px-5 pb-3 pt-1.5 md:px-8">
<div className="flex items-center gap-2">
<CalendarClock className="size-4 text-muted-foreground" />
<h1 className="text-sm font-semibold">Automations</h1>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Refresh automations"
onClick={refresh}
disabled={isLoading}
className="border border-border/50 bg-transparent hover:bg-muted/50"
>
<RefreshCw className={cn('size-4', isLoading && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh automations
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Add automation"
onClick={openCreateDialog}
className="border border-border/50 bg-transparent hover:bg-muted/50"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Add automation
</TooltipContent>
</Tooltip>
</div>
</header>
<AutomationEditorDialog
open={createOpen}
isEditing={editingAutomationId !== null}
isSaving={isSaving}
canSave={canSaveDraft}
repos={repos}
repoMap={repoMap}
worktrees={worktrees}
settings={settings}
draft={draft}
onProjectChange={handleProjectChange}
onOpenChange={setCreateOpen}
onDraftChange={setDraft}
onSave={() => void saveAutomation()}
/>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (open) {
return
}
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
>
<DialogContent
className="max-w-md"
onOpenAutoFocus={(event) => {
event.preventDefault()
deleteConfirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">Delete Automation</DialogTitle>
<DialogDescription className="text-xs">
Delete{' '}
<span className="break-all font-medium text-foreground">{deleteTarget?.name}</span>{' '}
and its run history. Workspaces created by previous runs are not deleted.
</DialogDescription>
</DialogHeader>
{deleteTarget ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all font-medium text-foreground">{deleteTarget.name}</div>
<div className="mt-1 text-muted-foreground">
{deleteTarget.workspaceMode === 'new_per_run'
? 'New workspace each run'
: 'Selected workspace'}
</div>
</div>
) : null}
<button
type="button"
role="checkbox"
aria-checked={dontAskDeleteAgain}
onClick={() => setDontAskDeleteAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskDeleteAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskDeleteAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteTarget(null)
setDontAskDeleteAgain(false)
}}
>
Cancel
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={() => void confirmDeleteAutomation()}
>
<Trash2 className="size-4" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(280px,360px)_1fr] overflow-hidden border-t border-border/50">
<section className="flex min-h-0 flex-col border-r border-border/50 bg-muted/20">
<div className="min-h-0 flex-1 overflow-auto p-2">
{automations.map((automation) => {
const automationRepo = repoMap.get(automation.projectId)
const automationWorktree = automation.workspaceId
? worktreeMap.get(automation.workspaceId)
: null
const workspaceLabel =
automation.workspaceMode === 'new_per_run'
? 'New workspace each run'
: (automationWorktree?.displayName ?? 'Missing workspace')
return (
<ContextMenu key={automation.id}>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => setSelectedId(automation.id)}
className={cn(
'mb-1 flex w-full flex-col gap-1 rounded-md border px-3 py-2 text-left text-sm transition-colors',
selected?.id === automation.id
? 'border-foreground/30 bg-muted/70 text-foreground shadow-sm'
: 'border-transparent hover:bg-muted/50'
)}
>
<span className="font-medium">{automation.name}</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{automationRepo ? (
<RepoDotLabel
name={automationRepo.displayName}
color={automationRepo.badgeColor}
dotClassName="size-1.5"
/>
) : (
<span>Unknown project</span>
)}
<span className="shrink-0">/</span>
<span className="truncate">{workspaceLabel}</span>
</span>
<span className="text-xs text-muted-foreground">
{automation.enabled
? `Next run ${formatAutomationDateTimeWithRelative(
automation.nextRunAt,
relativeNow
)}`
: 'Paused'}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onSelect={() => void runNow(automation)}>
<Play className="size-3.5" />
Run Now
</ContextMenuItem>
<ContextMenuItem onSelect={() => void openEditDialog(automation)}>
<Pencil className="size-3.5" />
Edit
</ContextMenuItem>
<ContextMenuItem onSelect={() => void toggleAutomation(automation)}>
{automation.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{automation.enabled ? 'Pause' : 'Resume'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => requestDeleteAutomation(automation)}
>
<Trash2 className="size-3.5" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
{automations.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No automations yet.</div>
) : null}
</div>
</section>
<section className="min-h-0 overflow-auto p-5">
<AutomationDetail
automation={selected}
runs={selectedRuns}
projectName={selectedRepo?.displayName ?? 'Unknown project'}
projectDefaultBaseRef={selectedRepo?.worktreeBaseRef ?? null}
workspaceName={
selected?.workspaceMode === 'new_per_run'
? 'New workspace each run'
: (selectedWorktree?.displayName ?? 'Missing workspace')
}
worktreeMap={worktreeMap}
now={relativeNow}
onRunNow={(automation) => void runNow(automation)}
onOpenRunWorkspace={openRunWorkspace}
onEdit={(automation) => void openEditDialog(automation)}
onToggle={(automation) => void toggleAutomation(automation)}
onDelete={requestDeleteAutomation}
/>
</section>
</div>
</main>
)
}
@@ -0,0 +1,206 @@
import React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { Repo, Worktree } from '../../../../shared/types'
const DEFAULT_VALUE = '__project_default__'
function displayBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
export function CreateFromPicker({
repoId,
repoMap,
worktrees,
value,
onValueChange
}: {
repoId: string
repoMap: Map<string, Repo>
worktrees: Worktree[]
value: string
onValueChange: (baseBranch: string) => void
}): React.JSX.Element {
const repo = repoMap.get(repoId)
const [open, setOpen] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const [defaultBaseRef, setDefaultBaseRef] = React.useState<string | null>(null)
const [query, setQuery] = React.useState('')
const [searchResults, setSearchResults] = React.useState<string[]>([])
const [isSearching, setIsSearching] = React.useState(false)
const effectiveDefault = repo?.worktreeBaseRef ?? defaultBaseRef
const selectedValue = value || DEFAULT_VALUE
const selectedLabel =
value || (effectiveDefault ? `${effectiveDefault} (default)` : 'Project default')
const branchOptions = React.useMemo(() => {
const options = new Set<string>()
if (effectiveDefault) {
options.add(effectiveDefault)
}
for (const worktree of worktrees) {
const branch = displayBranchName(worktree.branch).trim()
if (branch) {
options.add(branch)
}
}
for (const branch of searchResults) {
options.add(branch)
}
return Array.from(options).sort((left, right) => left.localeCompare(right))
}, [effectiveDefault, searchResults, worktrees])
React.useEffect(() => {
if (!open) {
return
}
const frame = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(frame)
}, [open])
React.useEffect(() => {
if (!repoId) {
return
}
let stale = false
setDefaultBaseRef(null)
void window.api.repos
.getBaseRefDefault({ repoId })
.then((result) => {
if (!stale) {
setDefaultBaseRef(result.defaultBaseRef)
}
})
.catch(() => {
if (!stale) {
setDefaultBaseRef(null)
}
})
return () => {
stale = true
}
}, [repoId])
React.useEffect(() => {
setQuery('')
setSearchResults([])
setIsSearching(false)
}, [repoId])
React.useEffect(() => {
const trimmedQuery = query.trim()
if (!open || !repoId || trimmedQuery.length < 2) {
setSearchResults([])
setIsSearching(false)
return
}
let stale = false
setIsSearching(true)
const timer = window.setTimeout(() => {
void window.api.repos
.searchBaseRefs({ repoId, query: trimmedQuery, limit: 30 })
.then((results) => {
if (!stale) {
setSearchResults(results)
}
})
.catch(() => {
if (!stale) {
setSearchResults([])
}
})
.finally(() => {
if (!stale) {
setIsSearching(false)
}
})
}, 200)
return () => {
stale = true
window.clearTimeout(timer)
}
}, [open, query, repoId])
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between px-3 text-sm font-normal"
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command>
<CommandInput
ref={inputRef}
value={query}
onValueChange={setQuery}
placeholder="Search repo branches..."
/>
<CommandList className="max-h-72">
<CommandEmpty>
{isSearching ? 'Searching branches...' : 'No branches found.'}
</CommandEmpty>
<CommandItem
value={effectiveDefault ? `${effectiveDefault} default` : 'project default'}
onSelect={() => {
onValueChange('')
setOpen(false)
}}
>
<Check
className={cn(
'size-4',
selectedValue === DEFAULT_VALUE ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="truncate">
{effectiveDefault ? `${effectiveDefault} (default)` : 'Project default'}
</span>
</CommandItem>
{branchOptions
.filter((branch) => branch !== effectiveDefault)
.map((branch) => (
<CommandItem
key={branch}
value={branch}
onSelect={() => {
onValueChange(branch)
setOpen(false)
}}
>
<Check
className={cn('size-4', value === branch ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{branch}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,81 @@
import React from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import type { Worktree } from '../../../../shared/types'
export function WorkspaceCombobox({
worktrees,
value,
onValueChange
}: {
worktrees: Worktree[]
value: string
onValueChange: (workspaceId: string) => void
}): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const selected = worktrees.find((worktree) => worktree.id === value) ?? null
React.useEffect(() => {
if (!open) {
return
}
const frame = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(frame)
}, [open])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between px-3 text-sm font-normal"
>
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
{selected?.displayName ?? 'Select workspace'}
</span>
<ChevronsUpDown className="size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command>
<CommandInput ref={inputRef} placeholder="Search workspaces..." />
<CommandList className="max-h-72">
<CommandEmpty>No workspaces found.</CommandEmpty>
{worktrees.map((worktree) => (
<CommandItem
key={worktree.id}
value={worktree.displayName}
onSelect={() => {
onValueChange(worktree.id)
setOpen(false)
}}
>
<Check
className={cn('size-4', value === worktree.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{worktree.displayName}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,113 @@
import React from 'react'
import type { Badge } from '@/components/ui/badge'
import type { AutomationRun } from '../../../../shared/automations-types'
export function formatAutomationDateTime(value: number | null | undefined): string {
if (!value) {
return 'Never'
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}).format(value)
}
export function formatAutomationRelativeTime(
value: number | null | undefined,
now = Date.now()
): string | null {
if (!value) {
return null
}
const diffMs = value - now
const absMs = Math.abs(diffMs)
const minuteMs = 60 * 1000
const hourMs = 60 * minuteMs
const dayMs = 24 * hourMs
const format = (amount: number, unit: string): string => `${amount}${unit}`
let text: string
if (absMs < minuteMs) {
text = 'now'
} else if (absMs < hourMs) {
text = format(Math.round(absMs / minuteMs), 'm')
} else if (absMs < dayMs) {
text = format(Math.round(absMs / hourMs), 'h')
} else {
text = format(Math.round(absMs / dayMs), 'd')
}
if (text === 'now') {
return text
}
return diffMs >= 0 ? `in ${text}` : `${text} ago`
}
export function formatAutomationDateTimeWithRelative(
value: number | null | undefined,
now = Date.now()
): string {
const absolute = formatAutomationDateTime(value)
const relative = formatAutomationRelativeTime(value, now)
return relative ? `${absolute} (${relative})` : absolute
}
export function getAutomationRunStatusVariant(
status: AutomationRun['status']
): React.ComponentProps<typeof Badge>['variant'] {
if (status === 'dispatched' || status === 'completed') {
return 'secondary'
}
if (status.startsWith('skipped')) {
return 'outline'
}
if (status === 'dispatch_failed') {
return 'destructive'
}
return 'dot'
}
export function getAutomationRunStatusLabel(status: AutomationRun['status']): string {
switch (status) {
case 'pending':
return 'Queued'
case 'dispatching':
return 'Starting'
case 'dispatched':
return 'Launched'
case 'completed':
return 'Done'
case 'skipped_missed':
return 'Skipped'
case 'skipped_unavailable':
return 'Unavailable'
case 'skipped_needs_interactive_auth':
return 'Needs credentials'
case 'dispatch_failed':
return 'Failed'
}
}
export function Field({
label,
children
}: {
label: React.ReactNode
children: React.ReactNode
}): React.JSX.Element {
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">{label}</div>
{children}
</div>
)
}
export function Metric({ label, value }: { label: string; value: string }): React.JSX.Element {
return (
<div className="min-w-0 rounded-md border border-border p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 truncate text-sm font-medium">{value}</div>
</div>
)
}
@@ -266,6 +266,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
</button>
</SearchableSetting>
</div>
<div id="general-skip-delete-automation-confirm" className="scroll-mt-6">
<SearchableSetting
title="Skip Delete Automation Confirmation"
description="Delete automations without a confirmation dialog."
keywords={['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Skip Delete Automation Confirmation</Label>
<p className="text-xs text-muted-foreground">
Delete automations and their run history without a confirmation dialog.
</p>
</div>
<button
role="switch"
aria-checked={settings.skipDeleteAutomationConfirm}
onClick={() =>
updateSettings({
skipDeleteAutomationConfirm: !settings.skipDeleteAutomationConfirm
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.skipDeleteAutomationConfirm ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.skipDeleteAutomationConfirm ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
@@ -15,6 +15,11 @@ export const GENERAL_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
title: 'Skip Delete Worktree Confirmation',
description: 'Delete worktrees from the context menu without a confirmation dialog.',
keywords: ['delete', 'worktree', 'confirm', 'dialog', 'skip', 'prompt']
},
{
title: 'Skip Delete Automation Confirmation',
description: 'Delete automations without a confirmation dialog.',
keywords: ['delete', 'automation', 'confirm', 'dialog', 'skip', 'prompt']
}
]
@@ -1,5 +1,5 @@
import React from 'react'
import { Bell, Github, List, Search } from 'lucide-react'
import { Bell, CalendarClock, Github, List, Search } from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
@@ -11,6 +11,7 @@ const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('
const SidebarNav = React.memo(function SidebarNav() {
const openTaskPage = useAppStore((s) => s.openTaskPage)
const openAutomationsPage = useAppStore((s) => s.openAutomationsPage)
const openActivityPage = useAppStore((s) => s.openActivityPage)
const openModal = useAppStore((s) => s.openModal)
const activeView = useAppStore((s) => s.activeView)
@@ -49,6 +50,7 @@ const SidebarNav = React.memo(function SidebarNav() {
}, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, repos])
const tasksActive = activeView === 'tasks'
const automationsActive = activeView === 'automations'
const activityActive = activeView === 'activity'
const activityUnreadCount = useAppStore((s) => {
let count = 0
@@ -138,6 +140,23 @@ const SidebarNav = React.memo(function SidebarNav() {
</span>
</button>
) : null}
<button
type="button"
onClick={openAutomationsPage}
aria-current={automationsActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
automationsActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground/60 hover:bg-sidebar-foreground/8'
)}
>
<CalendarClock
className={cn('size-4 shrink-0', !automationsActive && 'text-sidebar-foreground/30')}
strokeWidth={automationsActive ? 2.25 : 1.75}
/>
<span className="flex-1">Automations</span>
</button>
<button
type="button"
onClick={openActivityPage}
@@ -0,0 +1,86 @@
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import { parseAgentStatusPayload } from '../../../../shared/agent-status-types'
const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;'
export type ProcessedAgentStatusChunk = {
cleanData: string
payloads: ParsedAgentStatusPayload[]
}
function findAgentStatusTerminator(
data: string,
searchFrom: number
): { index: number; length: 1 | 2 } | null {
const belIndex = data.indexOf('\x07', searchFrom)
const stIndex = data.indexOf('\x1b\\', searchFrom)
if (belIndex === -1 && stIndex === -1) {
return null
}
if (belIndex === -1) {
return { index: stIndex, length: 2 }
}
if (stIndex === -1 || belIndex < stIndex) {
return { index: belIndex, length: 1 }
}
return { index: stIndex, length: 2 }
}
/**
* Stateful OSC 9999 parser for PTY streams.
* Why: automation background launches need the same agent-status parsing as
* mounted terminal panes, even when no terminal has been rendered yet.
*/
export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk {
const MAX_PENDING = 64 * 1024
let pending = ''
return (data: string): ProcessedAgentStatusChunk => {
const combined = pending + data
pending = ''
const payloads: ParsedAgentStatusPayload[] = []
let cleanData = ''
let cursor = 0
while (cursor < combined.length) {
const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor)
if (start === -1) {
const tail = combined.slice(cursor)
const prefixLen = OSC_AGENT_STATUS_PREFIX.length
let partialPrefixLen = 0
for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) {
if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) {
partialPrefixLen = k
break
}
}
if (partialPrefixLen > 0) {
cleanData += tail.slice(0, tail.length - partialPrefixLen)
pending = tail.slice(tail.length - partialPrefixLen)
} else {
cleanData += tail
}
break
}
cleanData += combined.slice(cursor, start)
const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length
const terminator = findAgentStatusTerminator(combined, payloadStart)
if (terminator === null) {
const candidate = combined.slice(start)
pending = candidate.length > MAX_PENDING ? '' : candidate
break
}
const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index))
if (parsed) {
payloads.push(parsed)
}
cursor = terminator.index + terminator.length
}
return { cleanData, payloads }
}
}
@@ -49,6 +49,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi
* guard and suppress xterm auto-replies during replay. */
export const ptyReplayHandlers = new Map<string, (data: string) => void>()
export const ptyExitHandlers = new Map<string, (code: number) => void>()
const ptyExitSidecars = new Map<string, Set<(code: number) => void>>()
/** Per-PTY teardown callbacks registered by each transport to clear closure
* state (stale-title timer, agent tracker) that would otherwise fire after
* the data handler is removed. */
@@ -101,9 +102,37 @@ export function ensurePtyDispatcher(): void {
})
window.api.pty.onExit((payload) => {
ptyExitHandlers.get(payload.id)?.(payload.code)
const sidecars = ptyExitSidecars.get(payload.id)
if (sidecars && sidecars.size > 0) {
const snapshot = Array.from(sidecars)
ptyExitSidecars.delete(payload.id)
for (const sidecar of snapshot) {
sidecar(payload.code)
}
}
})
}
export function subscribeToPtyExit(ptyId: string, watcher: (code: number) => void): () => void {
ensurePtyDispatcher()
let set = ptyExitSidecars.get(ptyId)
if (!set) {
set = new Set()
ptyExitSidecars.set(ptyId, set)
}
set.add(watcher)
return () => {
const current = ptyExitSidecars.get(ptyId)
if (!current) {
return
}
current.delete(watcher)
if (current.size === 0) {
ptyExitSidecars.delete(ptyId)
}
}
}
// ─── Eager PTY buffer for reconnection on restart ────────────────────
// Why: On startup, PTYs are spawned before TerminalPane mounts. Shell output
// (prompt, MOTD) arrives via pty:data before xterm exists. These helpers buffer
@@ -92,6 +92,25 @@ describe('createIpcPtyTransport', () => {
expect(onAgentBecameIdle).not.toHaveBeenCalled()
})
it('keeps exit sidecars after eager-buffered PTYs attach to a terminal', async () => {
const { createIpcPtyTransport, registerEagerPtyBuffer, subscribeToPtyExit } =
await import('./pty-transport')
const eagerExit = vi.fn()
const sidecarExit = vi.fn()
registerEagerPtyBuffer('pty-restored', eagerExit)
subscribeToPtyExit('pty-restored', sidecarExit)
createIpcPtyTransport().attach({
existingPtyId: 'pty-restored',
callbacks: {}
})
onExit?.({ id: 'pty-restored', code: 0 })
expect(eagerExit).not.toHaveBeenCalled()
expect(sidecarExit).toHaveBeenCalledWith(0)
})
it('fires onBell for bare BELs but ignores BELs inside OSC sequences', async () => {
// Why: Claude's OSC titles end with a BEL terminator (`\e]0;…\a`). The
// stateful bell detector must know it is inside an OSC when that BEL
@@ -19,14 +19,14 @@ import {
} from './pty-dispatcher'
import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher'
import { createBellDetector } from './bell-detector'
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import { parseAgentStatusPayload } from '../../../../shared/agent-status-types'
import { createAgentStatusOscProcessor } from './agent-status-osc'
// Re-export public API so existing consumers keep working.
export {
ensurePtyDispatcher,
getEagerPtyBufferHandle,
registerEagerPtyBuffer,
subscribeToPtyExit,
unregisterPtyDataHandlers
} from './pty-dispatcher'
export type {
@@ -37,109 +37,8 @@ export type {
} from './pty-dispatcher'
export { extractLastOscTitle } from '../../../../shared/agent-detection'
// ─── OSC 9999: agent status reporting ──────────────────────────────────────
// Why OSC 9999: avoids known-used codes (7=cwd, 133=VS Code, 777=Superset,
// 1337=iTerm2, 9001=Warp). Agents report structured status by printing
// printf '\x1b]9999;{"state":"working","prompt":"..."}\x07'
const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;'
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
export type ProcessedAgentStatusChunk = {
cleanData: string
payloads: ParsedAgentStatusPayload[]
}
function findAgentStatusTerminator(
data: string,
searchFrom: number
): { index: number; length: 1 | 2 } | null {
const belIndex = data.indexOf('\x07', searchFrom)
const stIndex = data.indexOf('\x1b\\', searchFrom)
if (belIndex === -1 && stIndex === -1) {
return null
}
if (belIndex === -1) {
return { index: stIndex, length: 2 }
}
if (stIndex === -1 || belIndex < stIndex) {
return { index: belIndex, length: 1 }
}
return { index: stIndex, length: 2 }
}
/**
* Stateful OSC 9999 parser for PTY streams.
* Why: the design doc explicitly calls out partial reads across chunks. Regexing
* each chunk independently drops valid status updates when the PTY splits the
* escape sequence mid-payload and can leak raw control bytes into xterm.
*/
export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk {
// Why: cap the pending buffer so a malformed or binary stream containing our
// OSC 9999 prefix without a valid terminator cannot grow memory unbounded.
const MAX_PENDING = 64 * 1024
let pending = ''
return (data: string): ProcessedAgentStatusChunk => {
const combined = pending + data
pending = ''
const payloads: ParsedAgentStatusPayload[] = []
let cleanData = ''
let cursor = 0
while (cursor < combined.length) {
const start = combined.indexOf(OSC_AGENT_STATUS_PREFIX, cursor)
if (start === -1) {
// Why: if the stream ends on a partial copy of the prefix (e.g. "\x1b]9999"
// without the trailing ";"), carrying that tail into `pending` lets the
// next chunk complete the prefix. Without this, the tail would be
// emitted as plain output and the next chunk's valid status update
// would be dropped because its prefix is incomplete on its own.
const tail = combined.slice(cursor)
const prefixLen = OSC_AGENT_STATUS_PREFIX.length
let partialPrefixLen = 0
for (let k = Math.min(prefixLen - 1, tail.length); k > 0; k--) {
if (tail.endsWith(OSC_AGENT_STATUS_PREFIX.slice(0, k))) {
partialPrefixLen = k
break
}
}
if (partialPrefixLen > 0) {
cleanData += tail.slice(0, tail.length - partialPrefixLen)
pending = tail.slice(tail.length - partialPrefixLen)
} else {
cleanData += tail
}
break
}
cleanData += combined.slice(cursor, start)
const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length
const terminator = findAgentStatusTerminator(combined, payloadStart)
if (terminator === null) {
const candidate = combined.slice(start)
// Why: drop the unterminated OSC entirely when it overflows MAX_PENDING,
// instead of flushing it to xterm. xterm.js would treat a lone
// "\x1b]9999;..." as an open string state and could swallow later
// output until it sees a BEL/ST terminator. Bounding the buffer is the
// goal; leaking corrupt escape sequences would be worse than the
// dropped payload.
pending = candidate.length > MAX_PENDING ? '' : candidate
break
}
const parsed = parseAgentStatusPayload(combined.slice(payloadStart, terminator.index))
if (parsed) {
payloads.push(parsed)
}
cursor = terminator.index + terminator.length
}
return { cleanData, payloads }
}
}
// Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher
// so the OSC 9999 status payloads can be forwarded to the store.
@@ -3,7 +3,7 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
activeView: 'terminal' | 'settings' | 'tasks' | 'activity'
activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
activeTabType: 'terminal' | 'editor' | 'browser'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {
@@ -0,0 +1,232 @@
import { useEffect } from 'react'
import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session'
import { useAppStore } from '@/store'
import type { AutomationDispatchResult } from '../../../shared/automations-types'
import { FIRST_PANE_ID } from '../../../shared/pane-key'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): string {
const slug = runTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13)
return `auto-${slug || 'run'}-${stamp}`
}
export function useAutomationDispatchEvents(): void {
useEffect(() => {
const unsubscribe = window.api.automations.onDispatchRequested(async ({ automation, run }) => {
const markDispatchResult = async (result: AutomationDispatchResult): Promise<void> => {
await window.api.automations.markDispatchResult(result)
window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT))
}
const state = useAppStore.getState()
const focusBeforeDispatch = {
activeView: state.activeView,
activeWorktreeId: state.activeWorktreeId,
activeTabId: state.activeTabId,
activeTabType: state.activeTabType
}
const repo = state.repos.find((entry) => entry.id === automation.projectId)
let dispatchWorkspaceId = automation.workspaceId
if (!repo) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: run.workspaceId,
error: 'The target project is no longer available.'
})
return
}
if (repo.connectionId) {
const needsPrompt = await window.api.ssh.needsPassphrasePrompt({
targetId: repo.connectionId
})
if (needsPrompt) {
await markDispatchResult({
runId: run.id,
status: 'skipped_needs_interactive_auth',
workspaceId: dispatchWorkspaceId,
error: 'SSH reconnect requires interactive credentials.'
})
return
}
const sshState = await window.api.ssh.getState({ targetId: repo.connectionId })
if (sshState?.status !== 'connected') {
try {
const connected = await window.api.ssh.connect({ targetId: repo.connectionId })
if (connected?.status !== 'connected') {
throw new Error('SSH target is unavailable.')
}
} catch (error) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: dispatchWorkspaceId,
error: error instanceof Error ? error.message : String(error)
})
return
}
}
}
try {
const worktree =
automation.workspaceMode === 'new_per_run'
? (
await useAppStore
.getState()
.createWorktree(
automation.projectId,
buildAutomationWorkspaceName(run.title, run.scheduledFor),
automation.baseBranch ?? undefined,
'inherit',
undefined,
'unknown',
run.title
)
).worktree
: automation.workspaceId
? useAppStore
.getState()
.allWorktrees()
.find((entry) => entry.id === automation.workspaceId)
: null
if (!worktree) {
await markDispatchResult({
runId: run.id,
status: 'skipped_unavailable',
workspaceId: automation.workspaceId,
error: 'The target workspace is no longer available.'
})
return
}
dispatchWorkspaceId = worktree.id
let dispatchMarked = false
let pendingExitCode: number | null = null
let pendingDone = false
let completionMarked = false
let unsubscribeAgentStatus = (): void => {}
const markCompletionResult = async (): Promise<void> => {
if (completionMarked) {
return
}
completionMarked = true
unsubscribeAgentStatus()
await markDispatchResult({
runId: run.id,
status: 'completed',
workspaceId: worktree.id,
error: null
})
}
const markExitResult = (code: number): Promise<void> => {
unsubscribeAgentStatus()
return markDispatchResult({
runId: run.id,
status: code === 0 ? 'completed' : 'dispatch_failed',
workspaceId: worktree.id,
error: code === 0 ? null : `Automation process exited with code ${code}.`
})
}
const handleAgentDone = (): void => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingDone = true
return
}
void markCompletionResult()
}
const observeAgentStatus = (tabId: string): void => {
const paneKey = `${tabId}:${FIRST_PANE_ID}`
const checkCurrentStatus = (): void => {
if (useAppStore.getState().agentStatusByPaneKey[paneKey]?.state === 'done') {
handleAgentDone()
}
}
// Why: Codex/Claude completion normally arrives through the global
// hook IPC listener, not the hidden PTY OSC fallback.
unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus)
checkCurrentStatus()
}
const result = await launchAgentBackgroundSession({
agent: automation.agentId,
worktreeId: worktree.id,
prompt: automation.prompt,
launchSource: 'unknown',
title: run.title,
onAgentStatus: (payload) => {
if (payload.state !== 'done') {
return
}
handleAgentDone()
},
onExit: (_ptyId, code) => {
if (completionMarked) {
return
}
if (!dispatchMarked) {
pendingExitCode = code
return
}
void markExitResult(code)
}
})
if (!result) {
throw new Error('Unable to build an agent launch plan.')
}
observeAgentStatus(result.tabId)
try {
await markDispatchResult({
runId: run.id,
status: 'dispatched',
workspaceId: worktree.id,
terminalSessionId: result.tabId,
error: null
})
dispatchMarked = true
if (pendingDone) {
await markCompletionResult()
} else if (pendingExitCode !== null) {
await markExitResult(pendingExitCode)
}
} catch (error) {
unsubscribeAgentStatus()
throw error
}
const currentState = useAppStore.getState()
// Why: Run Now and scheduled dispatches should create workspaces/tabs in
// the background; only an explicit row click should navigate there.
if (
focusBeforeDispatch.activeWorktreeId !== worktree.id &&
currentState.activeWorktreeId === worktree.id
) {
currentState.setActiveView(focusBeforeDispatch.activeView)
currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId)
if (focusBeforeDispatch.activeTabId) {
currentState.setActiveTab(focusBeforeDispatch.activeTabId)
}
currentState.setActiveTabType(focusBeforeDispatch.activeTabType)
}
} catch (error) {
await markDispatchResult({
runId: run.id,
status: 'dispatch_failed',
workspaceId: dispatchWorkspaceId,
error: error instanceof Error ? error.message : String(error)
})
}
})
void window.api.automations.rendererReady()
return unsubscribe
}, [])
}
+6 -2
View File
@@ -61,10 +61,11 @@ export async function pasteDraftWhenAgentReady(args: {
tabId: string
content: string
agent?: TuiAgent
submit?: boolean
timeoutMs?: number
onTimeout?: () => void
}): Promise<boolean> {
const { tabId, content, agent, timeoutMs, onTimeout } = args
const { tabId, content, agent, submit, timeoutMs, onTimeout } = args
// Why: agents with a documented prefill flag (currently Claude — see
// TUI_AGENT_CONFIG.claude.draftPromptFlag) launch with the URL already
@@ -88,7 +89,10 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`)
window.api.pty.write(
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}${submit ? '\r' : ''}`
)
return true
}
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSpawn = vi.fn()
const mockCreateTab = vi.fn()
const mockSetTabCustomTitle = vi.fn()
const mockUpdateTabPtyId = vi.fn()
const mockCloseTab = vi.fn()
const mockRegisterEagerPtyBuffer = vi.fn()
const mockSubscribeToPtyData = vi.fn()
const mockSubscribeToPtyExit = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const state = {
settings: { agentCmdOverrides: {} },
repos: [{ id: 'repo-1', connectionId: null }],
allWorktrees: vi.fn(() => [
{ id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' }
]),
createTab: mockCreateTab,
setTabCustomTitle: mockSetTabCustomTitle,
updateTabPtyId: mockUpdateTabPtyId,
closeTab: mockCloseTab,
clearTabPtyId: vi.fn(),
setAgentStatus: vi.fn()
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => state
}
}))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
tuiAgentToAgentKind: (agent: string) => agent
}))
vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
registerEagerPtyBuffer: mockRegisterEagerPtyBuffer,
subscribeToPtyData: mockSubscribeToPtyData,
subscribeToPtyExit: mockSubscribeToPtyExit
}))
describe('launchAgentBackgroundSession', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' })
mockSpawn.mockResolvedValue({ id: 'pty-1' })
mockSubscribeToPtyData.mockReturnValue(vi.fn())
mockSubscribeToPtyExit.mockReturnValue(vi.fn())
vi.stubGlobal('window', {
api: {
pty: {
spawn: mockSpawn
}
}
})
})
it('spawns a PTY immediately and adopts it in an inactive tab', async () => {
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
const result = await launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation',
title: 'Nightly audit'
})
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { activate: false })
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/repo/worktree',
command: "claude 'run the automation'",
env: {
ORCA_PANE_KEY: 'tab-1:1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
connectionId: null,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
)
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit')
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyData).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(mockSubscribeToPtyExit).toHaveBeenCalledWith('pty-1', expect.any(Function))
expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1' })
})
it('parses agent status from hidden PTY output', async () => {
const onAgentStatus = vi.fn()
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation',
onAgentStatus
})
const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07')
expect(state.setAgentStatus).toHaveBeenCalledWith(
'tab-1:1',
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
undefined
)
expect(onAgentStatus).toHaveBeenCalledWith(
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' })
)
})
it('uses a sidecar exit watcher so completion survives terminal attachment', async () => {
const unsubscribe = vi.fn()
mockSubscribeToPtyExit.mockReturnValue(unsubscribe)
const onExit = vi.fn()
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation',
onExit
})
const sidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void
sidecar(0)
expect(state.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(onExit).toHaveBeenCalledWith('pty-1', 0)
expect(unsubscribe).toHaveBeenCalled()
})
it('removes the inactive tab if PTY spawn fails', async () => {
mockSpawn.mockRejectedValueOnce(new Error('spawn failed'))
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await expect(
launchAgentBackgroundSession({
agent: 'claude',
worktreeId: 'wt-1',
prompt: 'run the automation'
})
).rejects.toThrow('spawn failed')
expect(mockCloseTab).toHaveBeenCalledWith('tab-1')
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
})
it('submits prompts for stdin-after-start agents in background mode', async () => {
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'aider',
worktreeId: 'wt-1',
prompt: 'run the automation'
})
expect(mockSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: 'aider' }))
expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith(
expect.objectContaining({
tabId: 'tab-1',
content: 'run the automation',
agent: 'aider',
submit: true
})
)
})
})
@@ -0,0 +1,156 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
import type { LaunchSource } from '../../../shared/telemetry-events'
import { FIRST_PANE_ID } from '../../../shared/pane-key'
import {
registerEagerPtyBuffer,
subscribeToPtyData,
subscribeToPtyExit
} from '@/components/terminal-pane/pty-dispatcher'
import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
export type LaunchAgentBackgroundSessionArgs = {
agent: TuiAgent
worktreeId: string
prompt?: string
launchSource?: LaunchSource
title?: string
onExit?: (ptyId: string, code: number) => void
onAgentStatus?: (payload: ParsedAgentStatusPayload) => void
}
export type LaunchAgentBackgroundSessionResult = {
tabId: string
ptyId: string
startupPlan: AgentStartupPlan
}
export async function launchAgentBackgroundSession(
args: LaunchAgentBackgroundSessionArgs
): Promise<LaunchAgentBackgroundSessionResult | null> {
const { agent, worktreeId, prompt, launchSource, title, onExit, onAgentStatus } = args
const store = useAppStore.getState()
const worktree = store.allWorktrees().find((entry) => entry.id === worktreeId)
const repo = worktree ? store.repos.find((entry) => entry.id === worktree.repoId) : null
if (!worktree) {
throw new Error('The target workspace is no longer available.')
}
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
let startupPlan: AgentStartupPlan | null = null
let pasteDraftAfterLaunch: string | null = null
if (hasPrompt && isFollowupPath) {
startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
} else {
startupPlan = buildAgentStartupPlan({
agent,
prompt: hasPrompt ? trimmedPrompt : '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: !hasPrompt
})
}
if (!startupPlan) {
return null
}
// Why: automation runs should start without revealing the workspace.
// Spawn the PTY immediately, then attach an inactive tab to the live session.
const tab = store.createTab(worktreeId, undefined, undefined, { activate: false })
if (title) {
store.setTabCustomTitle(tab.id, title)
}
const paneKey = `${tab.id}:${FIRST_PANE_ID}`
// Why: agent hook callbacks are keyed by pane, and background automation
// tabs never mount a TerminalPane to inject this env for us.
const paneEnv = {
...startupPlan.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tab.id,
ORCA_WORKTREE_ID: worktreeId
}
let result: Awaited<ReturnType<typeof window.api.pty.spawn>>
try {
result = await window.api.pty.spawn({
cols: 120,
rows: 40,
cwd: worktree.path,
command: startupPlan.launchCommand,
env: paneEnv,
connectionId: repo?.connectionId ?? null,
worktreeId,
tabId: tab.id,
leafId: 'pane:1',
telemetry: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: launchSource ?? 'unknown',
request_kind: 'new'
}
})
} catch (error) {
store.closeTab(tab.id)
throw error
}
store.updateTabPtyId(tab.id, result.id)
let exitHandled = false
let unsubscribeExit = (): void => {}
let unsubscribeData = (): void => {}
const handleExit = (ptyId: string, code: number): void => {
if (exitHandled) {
return
}
exitHandled = true
unsubscribeExit()
unsubscribeData()
useAppStore.getState().clearTabPtyId(tab.id, ptyId)
onExit?.(ptyId, code)
}
registerEagerPtyBuffer(result.id, handleExit)
const processAgentStatus = createAgentStatusOscProcessor()
unsubscribeData = subscribeToPtyData(result.id, (data) => {
const processed = processAgentStatus(data)
for (const payload of processed.payloads) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
onAgentStatus?.(payload)
}
})
// Why: opening the workspace attaches a real terminal transport and disposes
// the eager exit handler. This sidecar keeps automation completion tracking
// alive regardless of whether the tab is hidden or mounted.
unsubscribeExit = subscribeToPtyExit(result.id, (code) => handleExit(result.id, code))
if (pasteDraftAfterLaunch !== null) {
void pasteDraftWhenAgentReady({
tabId: tab.id,
content: pasteDraftAfterLaunch,
agent,
submit: true,
onTimeout: () => {
toast.message("Your automation prompt wasn't sent — open the workspace and paste it.")
track('agent_error', {
error_class: 'paste_readiness_timeout',
agent_kind: tuiAgentToAgentKind(agent)
})
}
})
}
return { tabId: tab.id, ptyId: result.id, startupPlan }
}
+22 -4
View File
@@ -194,10 +194,11 @@ export type UISlice = {
acknowledgedAgentsByPaneKey: Record<string, number>
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
activeView: 'terminal' | 'settings' | 'tasks' | 'activity'
previousViewBeforeTasks: 'terminal' | 'settings' | 'activity'
previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity'
previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks'
activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
previousViewBeforeTasks: 'terminal' | 'settings' | 'activity' | 'automations'
previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity' | 'automations'
previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks' | 'automations'
previousViewBeforeAutomations: 'terminal' | 'settings' | 'tasks' | 'activity'
setActiveView: (view: UISlice['activeView']) => void
taskPageData: {
preselectedRepoId?: string
@@ -229,6 +230,10 @@ export type UISlice = {
closeTaskPage: () => void
openActivityPage: () => void
closeActivityPage: () => void
selectedAutomationId: string | null
setSelectedAutomationId: (id: string | null) => void
openAutomationsPage: () => void
closeAutomationsPage: () => void
setNewWorkspaceDraft: (draft: NonNullable<UISlice['newWorkspaceDraft']>) => void
clearNewWorkspaceDraft: () => void
openSettingsPage: () => void
@@ -408,6 +413,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
previousViewBeforeTasks: 'terminal',
previousViewBeforeSettings: 'terminal',
previousViewBeforeActivity: 'terminal',
previousViewBeforeAutomations: 'terminal',
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
@@ -509,6 +515,18 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set((state) => ({
activeView: state.previousViewBeforeActivity
})),
selectedAutomationId: null,
setSelectedAutomationId: (id) => set({ selectedAutomationId: id }),
openAutomationsPage: () =>
set((state) => ({
activeView: 'automations',
previousViewBeforeAutomations:
state.activeView === 'automations' ? state.previousViewBeforeAutomations : state.activeView
})),
closeAutomationsPage: () =>
set((state) => ({
activeView: state.previousViewBeforeAutomations
})),
setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }),
clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }),
openSettingsPage: () =>
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
buildAutomationRrule,
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter,
parseAutomationRrule
} from './automation-schedules'
describe('automation schedules', () => {
it('uses the latest overdue hourly occurrence for missed-run grace decisions', () => {
const rrule = buildAutomationRrule({ preset: 'hourly', hour: 9, minute: 0 })
const latest = latestAutomationOccurrenceAtOrBefore(
rrule,
new Date('2026-05-12T00:00:00').getTime(),
new Date('2026-05-13T14:20:00').getTime()
)
expect(latest).toBe(new Date('2026-05-13T14:00:00').getTime())
})
it('computes weekday schedules without returning weekend candidates', () => {
const rrule = buildAutomationRrule({ preset: 'weekdays', hour: 9, minute: 30 })
const next = nextAutomationOccurrenceAfter(
rrule,
new Date('2026-05-01T00:00:00').getTime(),
new Date('2026-05-15T12:00:00').getTime()
)
expect(new Date(next).getDay()).toBe(1)
expect(new Date(next).getHours()).toBe(9)
expect(new Date(next).getMinutes()).toBe(30)
})
it('round-trips a weekly schedule for editing', () => {
const rrule = buildAutomationRrule({ preset: 'weekly', hour: 16, minute: 45, dayOfWeek: 3 })
expect(parseAutomationRrule(rrule)).toEqual({
preset: 'weekly',
hour: 16,
minute: 45,
dayOfWeek: 3
})
})
it('round-trips Sunday weekly schedules without coercing them to Monday', () => {
const rrule = buildAutomationRrule({ preset: 'weekly', hour: 10, minute: 15, dayOfWeek: 0 })
expect(parseAutomationRrule(rrule)).toEqual({
preset: 'weekly',
hour: 10,
minute: 15,
dayOfWeek: 0
})
})
})
+165
View File
@@ -0,0 +1,165 @@
import type { AutomationSchedulePreset } from './automations-types'
const DAY_MS = 24 * 60 * 60 * 1000
const HOUR_MS = 60 * 60 * 1000
type ParsedRule = {
freq: 'HOURLY' | 'DAILY' | 'WEEKLY'
byDay: string[]
byHour: number
byMinute: number
}
const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
function parseRrule(rrule: string): ParsedRule {
const entries = new Map<string, string>()
for (const part of rrule.split(';')) {
const [key, value] = part.split('=')
if (key && value) {
entries.set(key.toUpperCase(), value)
}
}
const freq = entries.get('FREQ')
if (freq !== 'HOURLY' && freq !== 'DAILY' && freq !== 'WEEKLY') {
throw new Error('Unsupported automation recurrence.')
}
const byHour = Number(entries.get('BYHOUR') ?? '9')
const byMinute = Number(entries.get('BYMINUTE') ?? '0')
if (!Number.isInteger(byHour) || byHour < 0 || byHour > 23) {
throw new Error('Invalid recurrence hour.')
}
if (!Number.isInteger(byMinute) || byMinute < 0 || byMinute > 59) {
throw new Error('Invalid recurrence minute.')
}
const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean)
return { freq, byDay, byHour, byMinute }
}
export function parseAutomationRrule(rrule: string): {
preset: AutomationSchedulePreset
hour: number
minute: number
dayOfWeek: number
} {
const rule = parseRrule(rrule)
if (rule.freq === 'HOURLY') {
return { preset: 'hourly', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 }
}
if (rule.freq === 'DAILY') {
return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 }
}
if (rule.byDay.join(',') === 'MO,TU,WE,TH,FR') {
return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 }
}
const dayCode = rule.byDay[0] ?? 'MO'
return {
preset: 'weekly',
hour: rule.byHour,
minute: rule.byMinute,
dayOfWeek: Math.max(0, DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]))
}
}
function atLocalTime(dayMs: number, hour: number, minute: number): number {
const date = new Date(dayMs)
date.setHours(hour, minute, 0, 0)
return date.getTime()
}
function startOfLocalDay(timestamp: number): number {
const date = new Date(timestamp)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
function dayMatches(rule: ParsedRule, timestamp: number): boolean {
if (rule.freq === 'DAILY') {
return true
}
const code = DAY_CODES[new Date(timestamp).getDay()]
return rule.byDay.includes(code)
}
function scanDayCandidates(rule: ParsedRule, anchor: number, direction: 1 | -1): number | null {
let day = startOfLocalDay(anchor)
for (let i = 0; i < 370; i += 1) {
const candidate = atLocalTime(day, rule.byHour, rule.byMinute)
if (dayMatches(rule, candidate)) {
if (direction === 1 && candidate > anchor) {
return candidate
}
if (direction === -1 && candidate <= anchor) {
return candidate
}
}
day += direction * DAY_MS
}
return null
}
export function buildAutomationRrule(args: {
preset: AutomationSchedulePreset
hour: number
minute: number
dayOfWeek?: number
}): string {
const hour = Math.max(0, Math.min(23, Math.floor(args.hour)))
const minute = Math.max(0, Math.min(59, Math.floor(args.minute)))
if (args.preset === 'hourly') {
return `FREQ=HOURLY;BYMINUTE=${minute}`
}
if (args.preset === 'weekdays') {
return `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=${hour};BYMINUTE=${minute}`
}
if (args.preset === 'weekly') {
const day = DAY_CODES[Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))]
return `FREQ=WEEKLY;BYDAY=${day};BYHOUR=${hour};BYMINUTE=${minute}`
}
return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}`
}
export function nextAutomationOccurrenceAfter(
rrule: string,
dtstart: number,
after: number
): number {
const rule = parseRrule(rrule)
if (rule.freq === 'HOURLY') {
const start = Math.max(dtstart, after)
const base = new Date(start)
base.setMinutes(rule.byMinute, 0, 0)
let candidate = base.getTime()
if (candidate <= after) {
candidate += HOUR_MS
}
return Math.max(candidate, dtstart)
}
const candidate = scanDayCandidates(rule, Math.max(dtstart - 1, after), 1)
if (candidate === null) {
throw new Error('Unable to compute next automation run.')
}
return candidate
}
export function latestAutomationOccurrenceAtOrBefore(
rrule: string,
dtstart: number,
now: number
): number | null {
if (now < dtstart) {
return null
}
const rule = parseRrule(rrule)
if (rule.freq === 'HOURLY') {
const base = new Date(now)
base.setMinutes(rule.byMinute, 0, 0)
let candidate = base.getTime()
if (candidate > now) {
candidate -= HOUR_MS
}
return candidate >= dtstart ? candidate : null
}
const candidate = scanDayCandidates(rule, now, -1)
return candidate !== null && candidate >= dtstart ? candidate : null
}
+105
View File
@@ -0,0 +1,105 @@
import type { TuiAgent } from './types'
export type AutomationWorkspaceMode = 'existing' | 'new_per_run'
export type AutomationExecutionTargetType = 'local' | 'ssh'
export type AutomationSchedulerOwner = 'local_host_service' | 'ssh_bridge' | 'remote_host_service'
export type AutomationMissedRunPolicy = 'run_once_within_grace'
export type AutomationRunStatus =
| 'pending'
| 'dispatching'
| 'dispatched'
| 'completed'
| 'skipped_missed'
| 'skipped_unavailable'
| 'skipped_needs_interactive_auth'
| 'dispatch_failed'
export type AutomationRunTrigger = 'scheduled' | 'manual'
export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly'
export type Automation = {
id: string
name: string
prompt: string
agentId: TuiAgent
projectId: string
executionTargetType: AutomationExecutionTargetType
executionTargetId: string
schedulerOwner: AutomationSchedulerOwner
workspaceMode: AutomationWorkspaceMode
workspaceId: string | null
baseBranch: string | null
timezone: string
rrule: string
dtstart: number
enabled: boolean
nextRunAt: number
lastRunAt?: number
missedRunPolicy: AutomationMissedRunPolicy
missedRunGraceMinutes: number
createdAt: number
updatedAt: number
}
export type AutomationRun = {
id: string
automationId: string
title: string
scheduledFor: number
status: AutomationRunStatus
trigger: AutomationRunTrigger
workspaceId: string | null
sessionKind: 'terminal'
chatSessionId: string | null
terminalSessionId: string | null
error: string | null
startedAt: number | null
dispatchedAt: number | null
createdAt: number
}
export type AutomationCreateInput = {
name: string
prompt: string
agentId: TuiAgent
projectId: string
workspaceMode: AutomationWorkspaceMode
workspaceId?: string | null
baseBranch?: string | null
timezone: string
rrule: string
dtstart: number
enabled?: boolean
missedRunGraceMinutes?: number
}
export type AutomationUpdateInput = Partial<
Pick<
Automation,
| 'name'
| 'prompt'
| 'agentId'
| 'projectId'
| 'workspaceMode'
| 'workspaceId'
| 'baseBranch'
| 'timezone'
| 'rrule'
| 'dtstart'
| 'enabled'
| 'missedRunGraceMinutes'
>
>
export type AutomationDispatchRequest = {
automation: Automation
run: AutomationRun
}
export type AutomationDispatchResult = {
runId: string
status: AutomationRunStatus
workspaceId?: string | null
terminalSessionId?: string | null
error?: string | null
}
+3
View File
@@ -221,6 +221,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
skipDeleteAutomationConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
defaultRepoSelection: null,
@@ -283,6 +284,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
workspaceSession: getDefaultWorkspaceSession(),
sshTargets: [],
sshRemotePtyLeases: [],
automations: [],
automationRuns: [],
onboarding: getDefaultOnboardingState()
}
}
+7
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import type { SshRemotePtyLease, SshTarget } from './ssh-types'
import type { Automation, AutomationRun } from './automations-types'
import type { WorkspaceSource } from './telemetry-events'
import type { GitHubProjectSettings } from './github-project-types'
@@ -1236,6 +1237,10 @@ export type GlobalSettings = {
* again" checkbox inside it or from the General settings pane. We keep this
* defaulted to false so first-time behavior stays safe. */
skipDeleteWorktreeConfirm: boolean
/** Why: deleting an automation also deletes its run history. Keep this
* separate from worktree deletion so skipping one destructive confirmation
* does not silently skip the other. */
skipDeleteAutomationConfirm: boolean
/** Default preset in the new-workspace GitHub task view. */
defaultTaskViewPreset: TaskViewPresetId
/** Why: persists the user's last-used task source so the Tasks page
@@ -1665,6 +1670,8 @@ export type PersistedState = {
workspaceSession: WorkspaceSessionState
sshTargets: SshTarget[]
sshRemotePtyLeases: SshRemotePtyLease[]
automations: Automation[]
automationRuns: AutomationRun[]
onboarding: OnboardingState
}