mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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'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
|
||||
}, [])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user