From 1948458fb0020d0caa7f37366b17458df8264b98 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 14 May 2026 02:31:52 -0400 Subject: [PATCH] Add local automations workflow (#1806) * Add local automations Co-authored-by: Orca * Add local automations workflow Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/automations/service.test.ts | 91 +++ src/main/automations/service.ts | 130 ++++ .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/index.ts | 13 +- src/main/ipc/automations.ts | 42 + src/main/ipc/register-core-handlers.ts | 8 +- src/main/persistence.test.ts | 51 ++ src/main/persistence.ts | 194 +++++ src/preload/api-types.ts | 19 + src/preload/index.ts | 31 + src/renderer/src/App.tsx | 20 +- .../automations/AutomationDetail.tsx | 290 +++++++ .../automations/AutomationEditorDialog.tsx | 286 +++++++ .../automations/AutomationsPage.tsx | 723 ++++++++++++++++++ .../automations/CreateFromPicker.tsx | 206 +++++ .../automations/WorkspaceCombobox.tsx | 81 ++ .../automations/automation-page-parts.tsx | 113 +++ .../src/components/settings/GeneralPane.tsx | 34 + .../src/components/settings/general-search.ts | 5 + .../src/components/sidebar/SidebarNav.tsx | 21 +- .../terminal-pane/agent-status-osc.ts | 86 +++ .../terminal-pane/pty-dispatcher.ts | 29 + .../terminal-pane/pty-transport.test.ts | 19 + .../components/terminal-pane/pty-transport.ts | 105 +-- src/renderer/src/hooks/resolve-zoom-target.ts | 2 +- .../src/hooks/useAutomationDispatchEvents.ts | 232 ++++++ src/renderer/src/lib/agent-paste-draft.ts | 8 +- .../launch-agent-background-session.test.ts | 178 +++++ .../lib/launch-agent-background-session.ts | 156 ++++ src/renderer/src/store/slices/ui.ts | 26 +- src/shared/automation-schedules.test.ts | 51 ++ src/shared/automation-schedules.ts | 165 ++++ src/shared/automations-types.ts | 105 +++ src/shared/constants.ts | 3 + src/shared/types.ts | 7 + 36 files changed, 3414 insertions(+), 118 deletions(-) create mode 100644 src/main/automations/service.test.ts create mode 100644 src/main/automations/service.ts create mode 100644 src/main/ipc/automations.ts create mode 100644 src/renderer/src/components/automations/AutomationDetail.tsx create mode 100644 src/renderer/src/components/automations/AutomationEditorDialog.tsx create mode 100644 src/renderer/src/components/automations/AutomationsPage.tsx create mode 100644 src/renderer/src/components/automations/CreateFromPicker.tsx create mode 100644 src/renderer/src/components/automations/WorkspaceCombobox.tsx create mode 100644 src/renderer/src/components/automations/automation-page-parts.tsx create mode 100644 src/renderer/src/components/terminal-pane/agent-status-osc.ts create mode 100644 src/renderer/src/hooks/useAutomationDispatchEvents.ts create mode 100644 src/renderer/src/lib/launch-agent-background-session.test.ts create mode 100644 src/renderer/src/lib/launch-agent-background-session.ts create mode 100644 src/shared/automation-schedules.test.ts create mode 100644 src/shared/automation-schedules.ts create mode 100644 src/shared/automations-types.ts diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts new file mode 100644 index 00000000000..ef016d486ca --- /dev/null +++ b/src/main/automations/service.test.ts @@ -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 => ({ + 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() + ) + }) +}) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts new file mode 100644 index 00000000000..ac3111f725b --- /dev/null +++ b/src/main/automations/service.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 6eafb567889..35164220fca 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -89,6 +89,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, + skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index d1cac96eb3a..3a44b1bf7f6 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -82,6 +82,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, skipDeleteWorktreeConfirm: false, + skipDeleteAutomationConfirm: false, defaultTaskViewPreset: 'all', defaultTaskSource: 'github', defaultRepoSelection: null, diff --git a/src/main/index.ts b/src/main/index.ts index 997c6441ea5..2f16305dd6a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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() diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts new file mode 100644 index 00000000000..e01d2767455 --- /dev/null +++ b/src/main/ipc/automations.ts @@ -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 => service.runNow(args.id) + ) + ipcMain.handle( + 'automations:markDispatchResult', + (_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result) + ) + ipcMain.handle('automations:rendererReady', (): void => { + service.setRendererReady() + }) +} diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 8e9434354a6..e15bc313250 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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 diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b00b6cdce84..27563d2bb05 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7aea40c20ba..068d3344578 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index dafb891053c..ea6ee2a1446 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 } + automations: { + list: () => Promise + listRuns: (args?: { automationId?: string }) => Promise + create: (input: AutomationCreateInput) => Promise + update: (args: { id: string; updates: AutomationUpdateInput }) => Promise + delete: (args: { id: string }) => Promise + runNow: (args: { id: string }) => Promise + markDispatchResult: (result: AutomationDispatchResult) => Promise + rendererReady: () => Promise + onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void) => () => void + } wsl: { isAvailable: () => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 93e281048c3..4bdb579ec94 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('ssh:submitCredential', args) }, + + automations: { + list: (): Promise => ipcRenderer.invoke('automations:list'), + listRuns: (args?: { automationId?: string }): Promise => + ipcRenderer.invoke('automations:listRuns', args), + create: (input: AutomationCreateInput): Promise => + ipcRenderer.invoke('automations:create', input), + update: (args: { id: string; updates: AutomationUpdateInput }): Promise => + ipcRenderer.invoke('automations:update', args), + delete: (args: { id: string }): Promise => ipcRenderer.invoke('automations:delete', args), + runNow: (args: { id: string }): Promise => + ipcRenderer.invoke('automations:runNow', args), + markDispatchResult: (result: AutomationDispatchResult): Promise => + ipcRenderer.invoke('automations:markDispatchResult', result), + rendererReady: (): Promise => 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 }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f11dd8d398a..24d5f9c6c49 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { {activeView === 'settings' ? : null} {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} {activeView === 'activity' ? : null} {activeView === 'terminal' && !activeWorktreeId ? : null} diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx new file mode 100644 index 00000000000..562f7ea5634 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -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 + 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 ( +
+
{label}
+
{value}
+
+ ) +} + +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 ( + + + + + + {label} + + + ) +} + +export function AutomationDetail({ + automation, + runs, + projectName, + workspaceName, + projectDefaultBaseRef, + worktreeMap, + now, + onRunNow, + onOpenRunWorkspace, + onEdit, + onToggle, + onDelete +}: AutomationDetailProps): React.JSX.Element { + if (!automation) { + return ( +
+ Create an automation to start scheduling agent work. +
+ ) + } + + return ( +
+
+
+
+

{automation.name}

+ + {automation.enabled ? 'Enabled' : 'Paused'} + +
+

+ {projectName} / {workspaceName} +

+
+
+ + onEdit(automation)}> + + + onToggle(automation)} + > + {automation.enabled ? : } + + onDelete(automation)} + className="text-destructive hover:text-destructive" + > + + +
+
+ + {automation.executionTargetType === 'ssh' ? ( +
+ 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. +
+ ) : null} + +
+ + + + +
+ +
+
Configuration
+
+
+
Agent
+
+ + + {AGENT_CATALOG.find((agent) => agent.id === automation.agentId)?.label ?? + automation.agentId} + +
+
+ + +
+
Prompt
+

+ {automation.prompt} +

+
+
+
+ +
+
+
Run history
+
{runs.length} runs
+
+
+
Run
+
Workspace
+
Status
+
+
+ {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 = ( + <> +
+
{formatAutomationDateTime(run.scheduledFor)}
+ {run.error ? ( +
{run.error}
+ ) : null} +
+
+ {workspaceLabel} +
+
+ + {getAutomationRunStatusLabel(run.status)} + +
+ + ) + return runWorktree ? ( + + ) : ( +
+ {rowContent} +
+ ) + })} + {runs.length === 0 ? ( +
No runs yet.
+ ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx new file mode 100644 index 00000000000..64f87107dca --- /dev/null +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -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 + 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 ( + + { + event.preventDefault() + }} + > + + + {isEditing ? 'Edit Automation' : 'Create Automation'} + + +
+ + + onDraftChange((current) => ({ ...current, name: event.target.value })) + } + /> + + + + + + + workspaceMode && + onDraftChange((current) => ({ + ...current, + workspaceMode: workspaceMode as AutomationWorkspaceMode + })) + } + variant="outline" + size="sm" + className="grid w-full grid-cols-2" + > + + Selected workspace + + + New workspace each run + + + + {draft.workspaceMode === 'existing' ? ( + + + onDraftChange((current) => ({ ...current, workspaceId })) + } + /> + + ) : ( + + + onDraftChange((current) => ({ ...current, baseBranch })) + } + /> + + )} +
+ + + agentId && onDraftChange((current) => ({ ...current, agentId })) + } + defaultAgent={settings?.defaultTuiAgent ?? null} + triggerClassName="h-9 w-full min-w-0" + /> + + + + +
+
+ + + onDraftChange((current) => ({ ...current, time: event.target.value })) + } + /> + + + + +
+ + Missed-run grace + + + + + + 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. + + + + } + > + + + +