mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(automations): restore desktop shell automation creation
This commit is contained in:
@@ -20,6 +20,7 @@ import { RuntimeClientError } from './types'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
@@ -73,6 +74,7 @@ describe('CLI remote WebSocket transport', () => {
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -58,7 +58,7 @@ function createPrecheckResult(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function failedPrecheckResult(
|
||||
export function failedPrecheckResult(
|
||||
precheck: AutomationPrecheck,
|
||||
startedAt: number,
|
||||
error: string
|
||||
|
||||
@@ -50,12 +50,12 @@ const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
|
||||
|
||||
type TestStore = Awaited<ReturnType<typeof createStore>>
|
||||
|
||||
function createAutomation(store: TestStore): Automation {
|
||||
function createAutomation(store: TestStore, agentId: Automation['agentId'] = 'claude'): Automation {
|
||||
store.addRepo(makeRepo())
|
||||
return store.createAutomation({
|
||||
name: 'Nightly check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
agentId,
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
@@ -119,6 +119,40 @@ describe('authority-owned automation run completion', () => {
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it.each(['renderer', 'headless', 'retained'] as const)(
|
||||
'keeps %s shell runs out of the agent-idle completion watcher',
|
||||
async (mode) => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store, null)
|
||||
const observer = createObserver(async () => ({ status: 'completed' }))
|
||||
const resolveTerminal = vi.spyOn(observer, 'resolveRunTerminal')
|
||||
const service = new AutomationService(store, {
|
||||
terminalObserver: observer,
|
||||
headlessDispatcher: mode === 'headless' ? async () => ({ ...LAUNCH_TARGET }) : undefined
|
||||
})
|
||||
|
||||
try {
|
||||
let run: AutomationRun
|
||||
if (mode === 'headless') {
|
||||
run = await service.runNow(automation.id)
|
||||
} else {
|
||||
run = store.createAutomationRun(automation, Date.now(), 'manual')
|
||||
const result = { runId: run.id, status: 'dispatched' as const, ...LAUNCH_TARGET }
|
||||
if (mode === 'renderer') {
|
||||
await service.markDispatchResult(result)
|
||||
} else {
|
||||
store.updateAutomationRun(result)
|
||||
service.start()
|
||||
}
|
||||
}
|
||||
expect(resolveTerminal).not.toHaveBeenCalled()
|
||||
expect(readRun(store, automation.id, run.id).status).toBe('dispatched')
|
||||
} finally {
|
||||
service.stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a headless dispatched run alone when the authority cannot observe it', async () => {
|
||||
const store = await createStore()
|
||||
const automation = createAutomation(store)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { AutomationService } from './service'
|
||||
import type * as PrecheckRunnerModule from './precheck-runner'
|
||||
import { runHeadlessAutomationDispatch } from './headless-dispatch-runner'
|
||||
import { createAutomationRunWriter } from './automation-run-writer'
|
||||
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
|
||||
@@ -22,7 +23,8 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./precheck-runner', () => ({
|
||||
vi.mock('./precheck-runner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof PrecheckRunnerModule>()),
|
||||
runAutomationPrecheck: runAutomationPrecheckMock
|
||||
}))
|
||||
|
||||
@@ -155,10 +157,18 @@ describe('AutomationService prechecks', () => {
|
||||
|
||||
const result = await service.runPrecheck(automation.id, run.id)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
expect(result).toEqual({
|
||||
command: 'test -f ready',
|
||||
exitCode: null,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: 'Project path for the selected automation host has changed.',
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
})
|
||||
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import { runAutomationPrecheck } from './precheck-runner'
|
||||
import { failedPrecheckResult, runAutomationPrecheck } from './precheck-runner'
|
||||
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
|
||||
import { collectAutomationRunUsage } from './run-usage-collection'
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
@@ -166,19 +166,7 @@ export class AutomationService {
|
||||
}
|
||||
const target = this.resolveTarget(automation)
|
||||
if (!target.ok) {
|
||||
return {
|
||||
command: automation.precheck.command,
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: target.error,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
}
|
||||
return failedPrecheckResult(automation.precheck, Date.now(), target.error)
|
||||
}
|
||||
return await runAutomationPrecheck({
|
||||
precheck: automation.precheck,
|
||||
@@ -193,8 +181,8 @@ export class AutomationService {
|
||||
const run = this.runs.updateRun(result)
|
||||
clearAutomationDispatchTokens(run.automationId, run.id)
|
||||
if (!isFinalAutomationRunStatus(run.status)) {
|
||||
if (run.status === 'dispatched' && this.isAgentRun(run)) {
|
||||
this.completionWatcher?.watch(run)
|
||||
if (run.status === 'dispatched' && this.completionWatcher && this.isAgentRun(run)) {
|
||||
this.completionWatcher.watch(run)
|
||||
}
|
||||
return run
|
||||
}
|
||||
@@ -325,8 +313,8 @@ export class AutomationService {
|
||||
runPrecheck: () => this.runPrecheck(automation.id, run.id),
|
||||
markDispatchResult: (result) => this.markDispatchResult(result),
|
||||
watchRun: (dispatched) => {
|
||||
if (this.isAgentRun(dispatched)) {
|
||||
this.completionWatcher?.watch(dispatched)
|
||||
if (this.completionWatcher && this.isAgentRun(dispatched)) {
|
||||
this.completionWatcher.watch(dispatched)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AUTOMATION_SHELL_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
type StreamRecord = {
|
||||
clientCapabilities: readonly string[] | undefined
|
||||
connectionId: string | undefined
|
||||
emit: (response: string) => void
|
||||
settled: boolean
|
||||
@@ -39,9 +41,14 @@ vi.mock('../runtime/rpc/dispatcher', () => ({
|
||||
dispatchStreaming(
|
||||
request: { id: string },
|
||||
emit: (response: string) => void,
|
||||
options: { connectionId?: string; signal: AbortSignal }
|
||||
options: {
|
||||
clientCapabilities?: readonly string[]
|
||||
connectionId?: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
): Promise<void> {
|
||||
const record: StreamRecord = {
|
||||
clientCapabilities: options.clientCapabilities,
|
||||
connectionId: options.connectionId,
|
||||
emit,
|
||||
settled: false,
|
||||
@@ -157,6 +164,14 @@ describe('runtime:subscribe renderer lifecycle cleanup', () => {
|
||||
registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never)
|
||||
})
|
||||
|
||||
it('advertises shell support on same-version desktop subscriptions', () => {
|
||||
const harness = createSender(1)
|
||||
subscribe(harness.sender, 'sub-shell')
|
||||
|
||||
expect(streamFor('sub-shell').clientCapabilities).toContain(AUTOMATION_SHELL_RUNTIME_CAPABILITY)
|
||||
harness.destroy()
|
||||
})
|
||||
|
||||
it('aborts a live stream once its sender commits a navigation', () => {
|
||||
const harness = createSender(1)
|
||||
subscribe(harness.sender, 'sub-reload')
|
||||
|
||||
@@ -170,6 +170,41 @@ describe('registerRuntimeHandlers', () => {
|
||||
expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } })
|
||||
})
|
||||
|
||||
it('lets the same-version desktop create and list blank-terminal automations', async () => {
|
||||
const input = {
|
||||
name: 'Shell check',
|
||||
prompt: 'echo ready',
|
||||
agentId: null,
|
||||
repo: 'repo-1',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 1
|
||||
}
|
||||
const automation = { id: 'automation-shell', ...input }
|
||||
const listing = {
|
||||
automations: [automation],
|
||||
items: [{ automationId: automation.id, selector: { kind: 'self' } }]
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
createAutomation: vi.fn().mockResolvedValue(automation),
|
||||
listAutomationsForScope: vi.fn().mockReturnValue(listing)
|
||||
}
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
const handler = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')![1]
|
||||
|
||||
const event = runtimeCallEvent()
|
||||
await expect(
|
||||
handler(event, { method: 'automation.create', params: input })
|
||||
).resolves.toMatchObject({ ok: true, result: { automation } })
|
||||
expect(runtime.createAutomation).toHaveBeenCalledWith(input)
|
||||
await expect(
|
||||
handler(event, {
|
||||
method: 'automation.list',
|
||||
params: { selector: { kind: 'self' } }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, result: listing })
|
||||
})
|
||||
|
||||
it('registers project group runtime RPC methods for local desktop callers', async () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
|
||||
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
|
||||
import {
|
||||
AUTOMATION_SHELL_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
@@ -81,7 +82,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
connectionId: desktopSenders.connectionIdFor(event.sender),
|
||||
clientCapabilities: [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_SHELL_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
)) as RuntimeRpcResponse<unknown>
|
||||
@@ -129,7 +131,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
connectionId,
|
||||
clientCapabilities: [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_SHELL_RUNTIME_CAPABILITY
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -784,22 +784,8 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
it.each(['claude', null] as const)(
|
||||
'releases ownership on nonzero exit without finalizing the %s tab',
|
||||
async (agentId) => {
|
||||
let onExit: ((ptyId: string, code: number) => void) | undefined
|
||||
mockLaunchAgentBackgroundSession.mockImplementation(async (args) => {
|
||||
onExit = args.onExit
|
||||
return {
|
||||
tabId: 'agent-tab',
|
||||
paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d',
|
||||
ptyId: 'agent-pty',
|
||||
startupPlan: {},
|
||||
terminalOwnership: {
|
||||
finalize: mockFinalizeTerminalOwnership,
|
||||
release: mockReleaseTerminalOwnership
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await registerAndDispatch(makeAutomation({ agentId }))
|
||||
const { onExit } = mockLaunchAgentBackgroundSession.mock.calls[0][0]
|
||||
if (agentId === null) {
|
||||
expect(mockStoreSubscribe).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx',
|
||||
2
|
||||
],
|
||||
['src/renderer/src/components/automations/AutomationDetail.tsx', 2],
|
||||
['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2],
|
||||
'src/renderer/src/components/automations/automation-draft-model.ts',
|
||||
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],
|
||||
|
||||
Reference in New Issue
Block a user