mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(server): harden headless lifecycle review findings
This commit is contained in:
@@ -288,6 +288,7 @@ jobs:
|
||||
node_server_package:
|
||||
name: node server package (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -304,6 +305,10 @@ jobs:
|
||||
- name: Build Node server package
|
||||
run: pnpm run build:node-server
|
||||
|
||||
- name: Verify Windows profile process lock
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/node-server/server-profile-process-lock.test.ts
|
||||
|
||||
- name: Verify installed package and runtime
|
||||
run: pnpm run verify:node-server-package
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "paired-runtime-host"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "paired-runtime-host"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "A real local child process and injected SSH acquisition/channel barriers cover cancellation, late fulfillment and rejection, process death, channel close, listener cleanup, and service drain. Physical Windows taskkill and live SSH transport remain uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/13452"],
|
||||
"invariant": "Stopping a headless automation service must abort any scheduled precheck, terminate its local process tree or close its SSH channel, consume late SSH acquisition settlement, and drain without waiting for the configured precheck timeout or leaving an unobserved error event.",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { win32 } from 'node:path'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveNodeServerNpmInvocation } from './node-server-npm-invocation.mjs'
|
||||
|
||||
const dockerfile = readFileSync('config/docker/node-server/Dockerfile', 'utf8')
|
||||
const dockerfile = readFileSync(join('config', 'docker', 'node-server', 'Dockerfile'), 'utf8')
|
||||
|
||||
describe('Node server package workflow contract', () => {
|
||||
it('verifies the selected Node archive before extracting it', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -7,29 +6,18 @@ import { decodePairingOffer, type PairingOffer } from '../../src/shared/pairing'
|
||||
import { sendRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client'
|
||||
import type { RuntimeStatus, RuntimeTerminalRead } from '../../src/shared/runtime-types'
|
||||
import { verifyInstalledCliProfileBehavior } from './node-server-installed-cli-oracle'
|
||||
import {
|
||||
isExpectedInstalledServerStopResult,
|
||||
waitForInstalledServerExit
|
||||
} from './node-server-installed-process-harness'
|
||||
import { stopNodeServerVerifierDaemons } from './node-server-verifier-daemon-cleanup'
|
||||
import { initializeNodeServerVerifierGitWorkspace } from './node-server-verifier-git-fixture'
|
||||
import { isSameExistingHostPath } from './node-server-verifier-host-path'
|
||||
|
||||
type ReadyPayload = {
|
||||
type: 'orca_server_ready'
|
||||
schemaVersion: 1
|
||||
runtimeId: string
|
||||
boundEndpoint: string
|
||||
pairing: {
|
||||
available: true
|
||||
url: string
|
||||
webClientUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
type RunningServer = { child: ChildProcess; ready: ReadyPayload; stderr: string[] }
|
||||
import {
|
||||
startNodeServerVerifierProcess,
|
||||
stopNodeServerVerifierProcess,
|
||||
terminateNodeServerVerifierProcess,
|
||||
type NodeServerVerifierProcess
|
||||
} from './node-server-verifier-server-process'
|
||||
const cliPath = resolve(readArgument('--cli') ?? 'resources/npm-server/dist/cli.js')
|
||||
const expectedVersion = readArgument('--expected-version') ?? packageVersionFromEnvironment()
|
||||
// Keep Unix daemon socket paths below platform limits while preserving Windows-native temp paths.
|
||||
const shortTemporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
|
||||
const ownedRoot = realpathSync.native(mkdtempSync(join(shortTemporaryRoot, 'orca-nsv-')))
|
||||
const dataPath = join(ownedRoot, 'state')
|
||||
@@ -37,12 +25,12 @@ const gitPath = join(ownedRoot, 'git-workspace')
|
||||
const folderPath = join(ownedRoot, 'folder-workspace')
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let activeServer: RunningServer | null = null
|
||||
let activeServer: NodeServerVerifierProcess | null = null
|
||||
try {
|
||||
initializeNodeServerVerifierGitWorkspace(gitPath)
|
||||
mkdirSync(folderPath)
|
||||
|
||||
const first = await startServer()
|
||||
const first = await startNodeServerVerifierProcess({ cliPath, dataPath })
|
||||
activeServer = first
|
||||
const pairing = decodePairingOffer(first.ready.pairing.url)
|
||||
await verifyWebClient(first.ready.pairing.webClientUrl)
|
||||
@@ -59,10 +47,10 @@ async function main(): Promise<void> {
|
||||
terminalHandle: terminal.handle,
|
||||
temporaryRoot: ownedRoot
|
||||
})
|
||||
await stopServer(first)
|
||||
await stopNodeServerVerifierProcess(first)
|
||||
activeServer = null
|
||||
|
||||
const second = await startServer()
|
||||
const second = await startNodeServerVerifierProcess({ cliPath, dataPath })
|
||||
activeServer = second
|
||||
assert(
|
||||
second.ready.runtimeId !== first.ready.runtimeId,
|
||||
@@ -79,12 +67,14 @@ async function main(): Promise<void> {
|
||||
const reattached = restored.terminals.find((candidate) => candidate.ptyId === terminal.ptyId)
|
||||
assert(reattached, 'terminal daemon session was not adopted')
|
||||
await waitForTerminalMarker(reconnectedPairing, reattached.handle, terminal.marker)
|
||||
await stopServer(second)
|
||||
await stopNodeServerVerifierProcess(second)
|
||||
activeServer = null
|
||||
process.stdout.write('Node server runtime verification passed\n')
|
||||
} finally {
|
||||
if (activeServer?.child.exitCode === null && activeServer.child.signalCode === null) {
|
||||
await stopServer(activeServer).catch(() => activeServer?.child.kill('SIGKILL'))
|
||||
await stopNodeServerVerifierProcess(activeServer).catch(() =>
|
||||
terminateNodeServerVerifierProcess(activeServer.child)
|
||||
)
|
||||
}
|
||||
await stopNodeServerVerifierDaemons(dataPath)
|
||||
await removeHostTree(ownedRoot)
|
||||
@@ -107,81 +97,8 @@ function packageVersionFromEnvironment(): string {
|
||||
return process.env.npm_package_version ?? '0.0.0-dev'
|
||||
}
|
||||
|
||||
function startServer(): Promise<RunningServer> {
|
||||
return new Promise((resolveStart, rejectStart) => {
|
||||
const stderr: string[] = []
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
cliPath,
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--listen',
|
||||
'127.0.0.1',
|
||||
'--pairing-address',
|
||||
'127.0.0.1',
|
||||
'--json'
|
||||
],
|
||||
{ env: { ...process.env, ORCA_SERVER_DATA_DIR: dataPath }, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
let stdout = ''
|
||||
const timeout = setTimeout(() => fail(new Error('server readiness timed out')), 30_000)
|
||||
|
||||
child.stderr?.setEncoding('utf8')
|
||||
child.stderr?.on('data', (chunk: string) => stderr.push(chunk))
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
const newline = stdout.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const ready = JSON.parse(stdout.slice(0, newline)) as ReadyPayload
|
||||
assert(ready.type === 'orca_server_ready', 'unexpected readiness type')
|
||||
assert(ready.schemaVersion === 1, 'unexpected readiness schema')
|
||||
assert(ready.pairing.available, 'pairing unavailable')
|
||||
assert(new URL(ready.boundEndpoint).hostname === '127.0.0.1', 'listener is not loopback')
|
||||
clearTimeout(timeout)
|
||||
resolveStart({ child, ready, stderr })
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
child.once('error', fail)
|
||||
child.once('exit', (code) =>
|
||||
fail(new Error(`server exited before readiness (${code}): ${stderr.join('')}`))
|
||||
)
|
||||
|
||||
function fail(error: unknown): void {
|
||||
clearTimeout(timeout)
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
rejectStart(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function stopServer(server: RunningServer): Promise<void> {
|
||||
const { child } = server
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`server exited unexpectedly (${child.exitCode ?? child.signalCode})`)
|
||||
}
|
||||
if (!child.kill('SIGTERM')) {
|
||||
throw new Error('server rejected the shutdown signal')
|
||||
}
|
||||
const result = await waitForInstalledServerExit(child)
|
||||
if (!isExpectedInstalledServerStopResult(result, process.platform)) {
|
||||
throw new Error(
|
||||
`server shutdown failed (${result.code ?? result.signal}): ${server.stderr.join('')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyWebClient(url: string): Promise<void> {
|
||||
const response = await fetch(url)
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) })
|
||||
assert(response.ok, `web client returned ${response.status}`)
|
||||
assert((await response.text()).includes('<!doctype html>'), 'web client HTML was not served')
|
||||
}
|
||||
@@ -201,7 +118,10 @@ async function verifyStatus(
|
||||
'browser.headless.v1',
|
||||
'browser.certificate-trust.v1'
|
||||
]) {
|
||||
assert(!capabilities.includes(capability as never), `browser capability leaked: ${capability}`)
|
||||
assert(
|
||||
!capabilities.some((candidate) => candidate === capability),
|
||||
`browser capability leaked: ${capability}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const clientState = vi.hoisted(() => ({ requests: [] as string[] }))
|
||||
|
||||
vi.mock('../../src/main/daemon/client', () => ({
|
||||
DaemonClient: class {
|
||||
async ensureConnectedWithin(): Promise<void> {}
|
||||
|
||||
async request(method: string): Promise<unknown> {
|
||||
clientState.requests.push(method)
|
||||
return method === 'listSessions' ? { sessions: [] } : undefined
|
||||
}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
import { stopNodeServerVerifierDaemons } from './node-server-verifier-daemon-cleanup'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
clientState.requests.length = 0
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('stopNodeServerVerifierDaemons', () => {
|
||||
it('waits for every shutdown attempt before aggregating cleanup failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dataPath = mkdtempSync(join(tmpdir(), 'orca-daemon-cleanup-'))
|
||||
temporaryPaths.push(dataPath)
|
||||
const daemonPath = join(dataPath, 'daemon')
|
||||
mkdirSync(daemonPath)
|
||||
writeFileSync(join(daemonPath, 'daemon-v4.pid'), JSON.stringify({ pid: 4101 }))
|
||||
writeFileSync(join(daemonPath, 'daemon-v5.pid'), JSON.stringify({ pid: 5101 }))
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
|
||||
const outcome = stopNodeServerVerifierDaemons(dataPath).catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(10_100)
|
||||
|
||||
const error = await outcome
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toHaveLength(2)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
expect.objectContaining({
|
||||
message: 'terminal daemon 4101 did not exit after the shutdown request'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: 'terminal daemon 5101 did not exit after the shutdown request'
|
||||
})
|
||||
])
|
||||
expect(clientState.requests.filter((method) => method === 'shutdown')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,13 @@ export async function stopNodeServerVerifierDaemons(dataPath: string): Promise<v
|
||||
// The isolated verifier directory can contain a daemon that already retired.
|
||||
}
|
||||
}
|
||||
await Promise.all(shutdowns)
|
||||
const results = await Promise.allSettled(shutdowns)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Terminal daemon cleanup failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdownDaemon(
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import {
|
||||
isExpectedInstalledServerStopResult,
|
||||
waitForInstalledServerExit
|
||||
} from './node-server-installed-process-harness'
|
||||
|
||||
type ReadyPayload = {
|
||||
type: 'orca_server_ready'
|
||||
schemaVersion: 1
|
||||
runtimeId: string
|
||||
boundEndpoint: string
|
||||
pairing: {
|
||||
available: true
|
||||
url: string
|
||||
webClientUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
export type NodeServerVerifierProcess = {
|
||||
child: ChildProcess
|
||||
ready: ReadyPayload
|
||||
stderr: string[]
|
||||
}
|
||||
|
||||
export function startNodeServerVerifierProcess(args: {
|
||||
cliPath: string
|
||||
dataPath: string
|
||||
}): Promise<NodeServerVerifierProcess> {
|
||||
return new Promise((resolveStart, rejectStart) => {
|
||||
const stderr: string[] = []
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
args.cliPath,
|
||||
'serve',
|
||||
'--port',
|
||||
'0',
|
||||
'--listen',
|
||||
'127.0.0.1',
|
||||
'--pairing-address',
|
||||
'127.0.0.1',
|
||||
'--json'
|
||||
],
|
||||
{
|
||||
env: { ...process.env, ORCA_SERVER_DATA_DIR: args.dataPath },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
let stdout = ''
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => fail(new Error('server readiness timed out')), 30_000)
|
||||
|
||||
child.stderr?.setEncoding('utf8')
|
||||
child.stderr?.on('data', (chunk: string) => stderr.push(chunk))
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', onStdout)
|
||||
child.once('error', fail)
|
||||
child.once('exit', onEarlyExit)
|
||||
|
||||
function onStdout(chunk: string): void {
|
||||
stdout += chunk
|
||||
const newline = stdout.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const ready = JSON.parse(stdout.slice(0, newline)) as ReadyPayload
|
||||
assertReadyPayload(ready)
|
||||
settled = true
|
||||
stopObservingReadiness()
|
||||
child.on('error', observeRuntimeError)
|
||||
resolveStart({ child, ready, stderr })
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onEarlyExit(code: number | null): void {
|
||||
fail(new Error(`server exited before readiness (${code}): ${stderr.join('')}`))
|
||||
}
|
||||
|
||||
function observeRuntimeError(error: Error): void {
|
||||
stderr.push(`${error.stack ?? error.message}\n`)
|
||||
}
|
||||
|
||||
function stopObservingReadiness(): void {
|
||||
clearTimeout(timeout)
|
||||
child.stdout?.off('data', onStdout)
|
||||
child.off('error', fail)
|
||||
child.off('exit', onEarlyExit)
|
||||
}
|
||||
|
||||
function fail(error: unknown): void {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
stopObservingReadiness()
|
||||
void terminateNodeServerVerifierProcess(child).then(
|
||||
() => rejectStart(error),
|
||||
(terminationError) =>
|
||||
rejectStart(
|
||||
new AggregateError([error, terminationError], 'server readiness cleanup failed')
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function terminateNodeServerVerifierProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
if (!child.kill('SIGKILL')) {
|
||||
throw new Error('server rejected forced termination')
|
||||
}
|
||||
await waitForInstalledServerExit(child)
|
||||
}
|
||||
|
||||
export async function stopNodeServerVerifierProcess(
|
||||
server: NodeServerVerifierProcess
|
||||
): Promise<void> {
|
||||
const { child } = server
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`server exited unexpectedly (${child.exitCode ?? child.signalCode})`)
|
||||
}
|
||||
if (!child.kill('SIGTERM')) {
|
||||
throw new Error('server rejected the shutdown signal')
|
||||
}
|
||||
const result = await waitForInstalledServerExit(child)
|
||||
if (!isExpectedInstalledServerStopResult(result, process.platform)) {
|
||||
throw new Error(
|
||||
`server shutdown failed (${result.code ?? result.signal}): ${server.stderr.join('')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertReadyPayload(ready: ReadyPayload): void {
|
||||
if (
|
||||
ready.type !== 'orca_server_ready' ||
|
||||
ready.schemaVersion !== 1 ||
|
||||
!ready.pairing.available ||
|
||||
new URL(ready.boundEndpoint).hostname !== '127.0.0.1'
|
||||
) {
|
||||
throw new Error('server returned invalid readiness data')
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ describe('PR workflow parallelism', () => {
|
||||
const job = workflow.jobs.node_server_package
|
||||
|
||||
expect(job.strategy.matrix.os).toEqual(['ubuntu-latest', 'windows-2022'])
|
||||
expect(job['timeout-minutes']).toBe(20)
|
||||
expect(
|
||||
job.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with
|
||||
).toBeUndefined()
|
||||
@@ -145,6 +146,11 @@ describe('PR workflow parallelism', () => {
|
||||
expect(job.steps.find((step) => step.name === 'Verify installed package and runtime').run).toBe(
|
||||
'pnpm run verify:node-server-package'
|
||||
)
|
||||
const windowsLockStep = job.steps.find(
|
||||
(step) => step.name === 'Verify Windows profile process lock'
|
||||
)
|
||||
expect(windowsLockStep.if).toBe("runner.os == 'Windows'")
|
||||
expect(windowsLockStep.run).toContain('server-profile-process-lock.test.ts')
|
||||
const dockerStep = job.steps.find(
|
||||
(step) => step.name === 'Verify Ubuntu 20.04 and glibc 2.31 floor'
|
||||
)
|
||||
|
||||
@@ -4,14 +4,15 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-runtime-verifier-build-'))
|
||||
const verifierPath = join(temporaryRoot, 'verifier.cjs')
|
||||
|
||||
try {
|
||||
await build({
|
||||
absWorkingDir: resolve('.'),
|
||||
absWorkingDir: repoRoot,
|
||||
bundle: true,
|
||||
entryPoints: ['config/scripts/node-server-runtime-verifier.ts'],
|
||||
entryPoints: [join(repoRoot, 'config', 'scripts', 'node-server-runtime-verifier.ts')],
|
||||
format: 'cjs',
|
||||
outfile: verifierPath,
|
||||
platform: 'node',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Orca Server
|
||||
|
||||
Requires Node.js 24 or newer.
|
||||
|
||||
Run Orca on a development server without installing the Electron desktop application:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationDispatchResult, AutomationRun } from '../../shared/automations-types'
|
||||
import { settleHeadlessAutomationCompletion } from './headless-completion-settlement'
|
||||
|
||||
describe('settleHeadlessAutomationCompletion', () => {
|
||||
it('retains the precheck result when completion fails', async () => {
|
||||
const precheckResult = {
|
||||
command: 'test -f ready',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
durationMs: 5,
|
||||
stdout: 'ready',
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: null,
|
||||
startedAt: 10,
|
||||
completedAt: 15
|
||||
}
|
||||
const mark = vi.fn(
|
||||
async (result: AutomationDispatchResult) => result as unknown as AutomationRun
|
||||
)
|
||||
|
||||
await settleHeadlessAutomationCompletion({
|
||||
completion: Promise.reject(new Error('terminal read failed')),
|
||||
runId: 'run-1',
|
||||
target: {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceDisplayName: 'Workspace',
|
||||
terminalSessionId: 'session-1',
|
||||
terminalPaneKey: 'pane-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
},
|
||||
precheckResult,
|
||||
mark
|
||||
})
|
||||
|
||||
expect(mark).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
status: 'dispatch_failed',
|
||||
precheckResult,
|
||||
error: 'terminal read failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,7 @@ export function settleHeadlessAutomationCompletion(args: {
|
||||
runId: args.runId,
|
||||
status: 'dispatch_failed',
|
||||
...args.target,
|
||||
precheckResult: args.precheckResult,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -22,22 +22,25 @@ describe('HeadlessAutomationWorkDrain', () => {
|
||||
expect(drained).toBe(true)
|
||||
})
|
||||
|
||||
it('observes rejected fire-and-forget work without an unhandled rejection', async () => {
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
it.each([true, false])(
|
||||
'observes rejected fire-and-forget work without an unhandled rejection (enabled=%s)',
|
||||
async (enabled) => {
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
try {
|
||||
const drain = new HeadlessAutomationWorkDrain(true)
|
||||
drain.track(Promise.reject(new Error('dispatch failed')))
|
||||
try {
|
||||
const drain = new HeadlessAutomationWorkDrain(enabled)
|
||||
drain.track(Promise.reject(new Error('dispatch failed')))
|
||||
|
||||
await drain.drain()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(unhandled).toEqual([])
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
await drain.drain()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(unhandled).toEqual([])
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,9 +4,6 @@ export class HeadlessAutomationWorkDrain {
|
||||
constructor(private readonly enabled: boolean) {}
|
||||
|
||||
track<T>(work: Promise<T>): Promise<T> {
|
||||
if (!this.enabled) {
|
||||
return work
|
||||
}
|
||||
let settlement: Promise<void>
|
||||
settlement = work.then(
|
||||
() => {
|
||||
@@ -16,7 +13,9 @@ export class HeadlessAutomationWorkDrain {
|
||||
this.pending.delete(settlement)
|
||||
}
|
||||
)
|
||||
this.pending.add(settlement)
|
||||
if (this.enabled) {
|
||||
this.pending.add(settlement)
|
||||
}
|
||||
return work
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ vi.mock('../ipc/ssh', () => ({
|
||||
const node = JSON.stringify(process.execPath)
|
||||
|
||||
function nodeCommand(script: string): string {
|
||||
return `${node} -e ${JSON.stringify(script)}`
|
||||
const encodedScript = Buffer.from(script).toString('base64')
|
||||
return `${node} -e "eval(Buffer.from('${encodedScript}','base64').toString())"`
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
@@ -100,7 +101,7 @@ describe('runAutomationPrecheck', () => {
|
||||
timedOut: false,
|
||||
error: 'Precheck cancelled.'
|
||||
})
|
||||
await vi.waitFor(() => expect(isProcessAlive(childPid)).toBe(false))
|
||||
await vi.waitFor(() => expect(isProcessAlive(childPid)).toBe(false), { timeout: 15_000 })
|
||||
} finally {
|
||||
if (childPid && isProcessAlive(childPid)) {
|
||||
process.kill(childPid, 'SIGKILL')
|
||||
@@ -140,6 +141,11 @@ describe('runAutomationPrecheck', () => {
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('ready')
|
||||
expect(result.error).toBeNull()
|
||||
expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow()
|
||||
expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(channel.listenerCount('error')).toBe(0)
|
||||
expect(channel.stderr.listenerCount('error')).toBe(0)
|
||||
})
|
||||
|
||||
it('closes an acquired SSH channel and settles when aborted', async () => {
|
||||
@@ -180,6 +186,35 @@ describe('runAutomationPrecheck', () => {
|
||||
expect(channel.stderr.listenerCount('error')).toBe(0)
|
||||
})
|
||||
|
||||
it('consumes errors after an SSH failure until the channel closes', async () => {
|
||||
const channel = Object.assign(new EventEmitter(), {
|
||||
stderr: new PassThrough(),
|
||||
close: vi.fn()
|
||||
})
|
||||
sshManagerState.manager = {
|
||||
getConnection: vi.fn(() => ({
|
||||
getState: () => ({ status: 'connected' }),
|
||||
exec: vi.fn(async () => channel)
|
||||
}))
|
||||
}
|
||||
|
||||
const resultPromise = runAutomationPrecheck({
|
||||
precheck: { command: 'failing-command', timeoutSeconds: 5 },
|
||||
target: { type: 'ssh', cwd: '/repo/path', connectionId: 'ssh-1' }
|
||||
})
|
||||
await vi.waitFor(() => expect(channel.listenerCount('error')).toBe(1))
|
||||
channel.emit('error', new Error('SSH command failed'))
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({ error: 'SSH command failed' })
|
||||
expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow()
|
||||
expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow()
|
||||
expect(channel.listenerCount('close')).toBe(1)
|
||||
channel.emit('close')
|
||||
expect(channel.listenerCount('error')).toBe(0)
|
||||
expect(channel.stderr.listenerCount('error')).toBe(0)
|
||||
expect(channel.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds SSH channel acquisition and closes a channel that resolves after abort', async () => {
|
||||
let resolveExec!: (
|
||||
channel: EventEmitter & { stderr: PassThrough; close: ReturnType<typeof vi.fn> }
|
||||
|
||||
@@ -15,6 +15,22 @@ type SshPrecheckTarget = {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function consumeLateSshErrors(channel: ClientChannel, alreadyClosed: boolean): void {
|
||||
const consumeError = (): void => {}
|
||||
const stopObserving = (): void => {
|
||||
channel.off('error', consumeError)
|
||||
channel.stderr.off('error', consumeError)
|
||||
channel.off('close', stopObserving)
|
||||
}
|
||||
channel.on('error', consumeError)
|
||||
channel.stderr.on('error', consumeError)
|
||||
if (alreadyClosed) {
|
||||
setImmediate(stopObserving)
|
||||
} else {
|
||||
channel.once('close', stopObserving)
|
||||
}
|
||||
}
|
||||
|
||||
function closeSshPrecheckChannel(channel: ClientChannel): void {
|
||||
const consumeError = (): void => {}
|
||||
const stopObserving = (): void => {
|
||||
@@ -49,6 +65,7 @@ function runSshChannelPrecheck(args: {
|
||||
let exitCode: number | null = null
|
||||
let timedOut = false
|
||||
let cancelled = false
|
||||
let closed = false
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -65,6 +82,7 @@ function runSshChannelPrecheck(args: {
|
||||
exitCode = typeof code === 'number' ? code : null
|
||||
}
|
||||
const onClose = (code?: number | null): void => {
|
||||
closed = true
|
||||
if (typeof code === 'number') {
|
||||
exitCode = code
|
||||
}
|
||||
@@ -90,6 +108,9 @@ function runSshChannelPrecheck(args: {
|
||||
channel.stderr.off('data', collectStderr)
|
||||
channel.off('exit', onExit)
|
||||
channel.off('close', onClose)
|
||||
if (!cancelled && !timedOut) {
|
||||
consumeLateSshErrors(channel, closed)
|
||||
}
|
||||
}
|
||||
const settle = (resultExitCode: number | null, error: string | null): void => {
|
||||
if (settled) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
export async function evaluateScheduledAutomation(args: {
|
||||
store: Store
|
||||
automation: Automation
|
||||
now: number
|
||||
requestDispatch: (automation: Automation, run: AutomationRun) => Promise<AutomationRun>
|
||||
}): Promise<void> {
|
||||
const { store, automation, now, requestDispatch } = args
|
||||
const scheduledFor = store.getLatestAutomationOccurrence(automation, now)
|
||||
if (scheduledFor === null) {
|
||||
store.advanceAutomationNextRun(automation.id, now)
|
||||
return
|
||||
}
|
||||
const run = store.createAutomationRun(automation, scheduledFor)
|
||||
try {
|
||||
const graceMs = automation.missedRunGraceMinutes * 60 * 1000
|
||||
if (now - scheduledFor > graceMs) {
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_missed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: 'Orca was unavailable during the missed-run grace window.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await requestDispatch(automation, run)
|
||||
} catch (error) {
|
||||
try {
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} catch (persistenceError) {
|
||||
throw new AggregateError(
|
||||
[error, persistenceError],
|
||||
`Automation ${automation.id} dispatch and failure persistence both failed`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
store.advanceAutomationNextRun(automation.id, now)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,76 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('fails one scheduled dispatch, advances it, and continues evaluating later due runs', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:59:00'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const createDueAutomation = (name: string) =>
|
||||
store.createAutomation({
|
||||
name,
|
||||
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()
|
||||
})
|
||||
const failing = createDueAutomation('Failing dispatch')
|
||||
const succeeding = createDueAutomation('Later dispatch')
|
||||
vi.setSystemTime(new Date('2026-05-13T09:01:00'))
|
||||
const send = vi.fn((_channel: string, payload: { automation: { id: string } }) => {
|
||||
if (payload.automation.id === failing.id) {
|
||||
throw new Error('renderer send failed')
|
||||
}
|
||||
})
|
||||
const service = new AutomationService(store)
|
||||
service.setWebContents({ isDestroyed: () => false, send } as never)
|
||||
|
||||
service.setRendererReady()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.listAutomationRuns(failing.id)[0]?.status).toBe('dispatch_failed')
|
||||
expect(store.listAutomationRuns(succeeding.id)[0]?.status).toBe('dispatching')
|
||||
})
|
||||
expect(store.listAutomationRuns(failing.id)[0]?.error).toBe('renderer send failed')
|
||||
expect(store.listAutomations().find((entry) => entry.id === failing.id)?.nextRunAt).toBe(
|
||||
new Date('2026-05-14T09:00:00').getTime()
|
||||
)
|
||||
expect(store.listAutomations().find((entry) => entry.id === succeeding.id)?.nextRunAt).toBe(
|
||||
new Date('2026-05-14T09:00:00').getTime()
|
||||
)
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves manual run rejection semantics when renderer dispatch throws', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual failure',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: Date.now() + 60_000
|
||||
})
|
||||
const service = new AutomationService(store)
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send: () => {
|
||||
throw new Error('renderer send failed')
|
||||
}
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
await expect(service.runNow(automation.id)).rejects.toThrow('renderer send failed')
|
||||
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('dispatching')
|
||||
})
|
||||
|
||||
it('returns the persisted status for manual runs after dispatch is requested', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
@@ -457,6 +527,51 @@ describe('AutomationService', () => {
|
||||
expect(store.listAutomationRuns(automation.id)[0]?.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('observes a rejected headless completion settlement', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Settlement failure',
|
||||
prompt: 'Finish before shutdown',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: Date.now() + 60_000
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher: vi.fn().mockResolvedValue({
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
completion: Promise.resolve({ status: 'completed', outputSnapshot: null, error: null })
|
||||
})
|
||||
})
|
||||
vi.spyOn(service, 'markDispatchResult').mockRejectedValue(new Error('persistence failed'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
try {
|
||||
await service.runNow(automation.id)
|
||||
await vi.waitFor(() =>
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[automations] headless completion settlement failed:',
|
||||
expect.objectContaining({ message: 'persistence failed' })
|
||||
)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(unhandled).toEqual([])
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
didAutomationPrecheckPass,
|
||||
formatAutomationPrecheckFailure
|
||||
} from '../../shared/automation-precheck'
|
||||
import { evaluateScheduledAutomation } from './scheduled-automation-evaluation'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -68,7 +69,7 @@ export class AutomationService {
|
||||
|
||||
setRendererReady(): void {
|
||||
this.rendererReady = true
|
||||
void this.headlessWork.track(this.evaluateDueRuns())
|
||||
this.trackBackgroundWork(this.evaluateDueRuns(), 'renderer-ready evaluation')
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -76,12 +77,12 @@ export class AutomationService {
|
||||
return
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
void this.headlessWork.track(this.evaluateDueRuns())
|
||||
this.trackBackgroundWork(this.evaluateDueRuns(), 'scheduled evaluation')
|
||||
}, this.tickMs)
|
||||
// Why: headless serve never gets a renderer-ready IPC, but due runs still
|
||||
// need the same startup catch-up pass desktop gets after renderer attach.
|
||||
if (this.rendererReady || this.headlessDispatcher) {
|
||||
void this.headlessWork.track(this.evaluateDueRuns())
|
||||
this.trackBackgroundWork(this.evaluateDueRuns(), 'startup evaluation')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,36 +197,22 @@ export class AutomationService {
|
||||
if (!automation.enabled || automation.nextRunAt > now) {
|
||||
continue
|
||||
}
|
||||
await this.evaluateAutomation(automation, now)
|
||||
try {
|
||||
await evaluateScheduledAutomation({
|
||||
store: this.store,
|
||||
automation,
|
||||
now,
|
||||
requestDispatch: (entry, run) => this.requestDispatch(entry, run)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[automations] Scheduled evaluation failed for ${automation.id}:`, error)
|
||||
}
|
||||
}
|
||||
} 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
|
||||
@@ -301,14 +288,15 @@ export class AutomationService {
|
||||
error: null
|
||||
})
|
||||
if (launch.completion) {
|
||||
void this.headlessWork.track(
|
||||
this.trackBackgroundWork(
|
||||
settleHeadlessAutomationCompletion({
|
||||
completion: launch.completion,
|
||||
runId: run.id,
|
||||
target: launchRunTarget,
|
||||
precheckResult,
|
||||
mark: (result) => this.markDispatchResult(result)
|
||||
})
|
||||
}),
|
||||
'headless completion settlement'
|
||||
)
|
||||
}
|
||||
return updated
|
||||
@@ -317,8 +305,15 @@ export class AutomationService {
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private trackBackgroundWork(work: Promise<unknown>, context: string): void {
|
||||
void this.headlessWork.track(work).catch((error) => {
|
||||
console.error(`[automations] ${context} failed:`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ import type { Store } from '../main/persistence'
|
||||
const state = vi.hoisted(() => {
|
||||
const calls: string[] = []
|
||||
const signerKeyIds: string[] = []
|
||||
const inactiveResolvers = {
|
||||
claude: null as (() => unknown) | null,
|
||||
codex: null as (() => unknown) | null
|
||||
}
|
||||
const normalizationCalls = { claude: 0, codex: 0 }
|
||||
const stopErrors = {
|
||||
automation: null as Error | null,
|
||||
rateLimits: null as Error | null,
|
||||
targetSync: null as Error | null,
|
||||
hook: null as Error | null
|
||||
}
|
||||
|
||||
class Runtime {
|
||||
private accountServices: {
|
||||
@@ -73,8 +84,12 @@ const state = vi.hoisted(() => {
|
||||
setClaudeAuthPreparationResolver(): void {}
|
||||
setCodexFetchTarget(): void {}
|
||||
setClaudeFetchTarget(): void {}
|
||||
setInactiveClaudeAccountsResolver(): void {}
|
||||
setInactiveCodexAccountsResolver(): void {}
|
||||
setInactiveClaudeAccountsResolver(resolver: () => unknown): void {
|
||||
inactiveResolvers.claude = resolver
|
||||
}
|
||||
setInactiveCodexAccountsResolver(resolver: () => unknown): void {
|
||||
inactiveResolvers.codex = resolver
|
||||
}
|
||||
ingestLiveClaudeRateLimits(): void {}
|
||||
getState(): unknown {
|
||||
return { codex: null, claude: null }
|
||||
@@ -84,6 +99,9 @@ const state = vi.hoisted(() => {
|
||||
}
|
||||
stop(): void {
|
||||
calls.push('rate-stop')
|
||||
if (stopErrors.rateLimits) {
|
||||
throw stopErrors.rateLimits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +117,22 @@ const state = vi.hoisted(() => {
|
||||
}
|
||||
async stopAndDrain(): Promise<void> {
|
||||
this.stop()
|
||||
if (stopErrors.automation) {
|
||||
throw stopErrors.automation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { Automation, calls, RateLimits, Runtime, signerKeyIds }
|
||||
return {
|
||||
Automation,
|
||||
calls,
|
||||
inactiveResolvers,
|
||||
normalizationCalls,
|
||||
RateLimits,
|
||||
Runtime,
|
||||
signerKeyIds,
|
||||
stopErrors
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../main/runtime/orca-runtime', () => ({ OrcaRuntimeService: state.Runtime }))
|
||||
@@ -174,17 +204,32 @@ vi.mock('../main/rate-limits/account-runtime-target-sync', () => ({
|
||||
createAccountRuntimeTargetSettingsSync: () => async () => {}
|
||||
}))
|
||||
vi.mock('../main/codex-accounts/runtime-selection', () => ({
|
||||
normalizeCodexRuntimeSelection: () => ({ host: null, wsl: {} })
|
||||
normalizeCodexRuntimeSelection: () => {
|
||||
state.normalizationCalls.codex += 1
|
||||
return { host: null, wsl: {} }
|
||||
}
|
||||
}))
|
||||
vi.mock('../main/claude-accounts/runtime-selection', () => ({
|
||||
normalizeClaudeRuntimeSelection: () => ({ host: null, wsl: {} })
|
||||
normalizeClaudeRuntimeSelection: () => {
|
||||
state.normalizationCalls.claude += 1
|
||||
return { host: null, wsl: {} }
|
||||
}
|
||||
}))
|
||||
|
||||
const hookServer = vi.hoisted(() => ({
|
||||
start: vi.fn(async () => state.calls.push('hook-start')),
|
||||
stop: vi.fn(() => state.calls.push('hook-stop')),
|
||||
stopAndWait: vi.fn(async () => state.calls.push('hook-stop')),
|
||||
setClaudeStatusLineListener: vi.fn(),
|
||||
stop: vi.fn(() => state.calls.push('hook-stop-unjoined')),
|
||||
stopAndWait: vi.fn(async () => {
|
||||
state.calls.push('hook-stop-wait')
|
||||
if (state.stopErrors.hook) {
|
||||
throw state.stopErrors.hook
|
||||
}
|
||||
}),
|
||||
setClaudeStatusLineListener: vi.fn((listener: unknown) => {
|
||||
if (listener === null) {
|
||||
state.calls.push('hook-listener-clear')
|
||||
}
|
||||
}),
|
||||
ingestTerminalStatus: vi.fn(),
|
||||
getStatusSnapshot: vi.fn(() => []),
|
||||
getStatusSnapshotForPane: vi.fn(() => []),
|
||||
@@ -214,6 +259,14 @@ const temporaryPaths: string[] = []
|
||||
afterEach(() => {
|
||||
state.calls.length = 0
|
||||
state.signerKeyIds.length = 0
|
||||
state.stopErrors.automation = null
|
||||
state.stopErrors.rateLimits = null
|
||||
state.stopErrors.targetSync = null
|
||||
state.stopErrors.hook = null
|
||||
state.inactiveResolvers.claude = null
|
||||
state.inactiveResolvers.codex = null
|
||||
state.normalizationCalls.claude = 0
|
||||
state.normalizationCalls.codex = 0
|
||||
vi.clearAllMocks()
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { force: true, recursive: true })
|
||||
@@ -243,6 +296,17 @@ describe('browserless runtime composition', () => {
|
||||
expect(state.signerKeyIds[0]).toBe(state.signerKeyIds[1])
|
||||
})
|
||||
|
||||
it('normalizes each account runtime selection once per inactive-account resolution', () => {
|
||||
const dataPath = mkdtempSync(join(tmpdir(), 'orca-browserless-composition-'))
|
||||
temporaryPaths.push(dataPath)
|
||||
createBrowserlessRuntimeComposition({ store: createStore(), dataPath })
|
||||
|
||||
state.inactiveResolvers.claude?.()
|
||||
state.inactiveResolvers.codex?.()
|
||||
|
||||
expect(state.normalizationCalls).toEqual({ claude: 1, codex: 1 })
|
||||
})
|
||||
|
||||
it('orders startup recovery and joins owned lifecycle shutdown', async () => {
|
||||
const dataPath = mkdtempSync(join(tmpdir(), 'orca-browserless-composition-'))
|
||||
temporaryPaths.push(dataPath)
|
||||
@@ -262,8 +326,41 @@ describe('browserless runtime composition', () => {
|
||||
'automation-start',
|
||||
'automation-stop',
|
||||
'rate-stop',
|
||||
'hook-stop'
|
||||
'target-sync-stop',
|
||||
'hook-listener-clear',
|
||||
'hook-stop-wait'
|
||||
])
|
||||
expect(hookServer.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attempts every owned teardown in order and aggregates failures', async () => {
|
||||
const dataPath = mkdtempSync(join(tmpdir(), 'orca-browserless-composition-'))
|
||||
temporaryPaths.push(dataPath)
|
||||
const composition = createBrowserlessRuntimeComposition({ store: createStore(), dataPath })
|
||||
state.stopErrors.automation = new Error('automation drain failed')
|
||||
state.stopErrors.rateLimits = new Error('rate stop failed')
|
||||
state.stopErrors.targetSync = new Error('target sync stop failed')
|
||||
state.stopErrors.hook = new Error('hook stop failed')
|
||||
|
||||
const error = await composition.stop().catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
expect((error as AggregateError).errors).toEqual([
|
||||
state.stopErrors.automation,
|
||||
state.stopErrors.rateLimits,
|
||||
state.stopErrors.targetSync,
|
||||
state.stopErrors.hook
|
||||
])
|
||||
expect(state.calls).toEqual([
|
||||
'prepare',
|
||||
'automation-stop',
|
||||
'rate-stop',
|
||||
'target-sync-stop',
|
||||
'hook-listener-clear',
|
||||
'hook-stop-wait'
|
||||
])
|
||||
await expect(composition.stop()).rejects.toBe(error)
|
||||
expect(hookServer.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up once when startup fails', async () => {
|
||||
@@ -275,7 +372,14 @@ describe('browserless runtime composition', () => {
|
||||
await expect(composition.startAfterDaemon()).rejects.toThrow('hook bind failed')
|
||||
await composition.stop()
|
||||
|
||||
expect(state.calls).toEqual(['prepare', 'automation-stop', 'rate-stop', 'hook-stop'])
|
||||
expect(state.calls).toEqual([
|
||||
'prepare',
|
||||
'automation-stop',
|
||||
'rate-stop',
|
||||
'target-sync-stop',
|
||||
'hook-listener-clear',
|
||||
'hook-stop-wait'
|
||||
])
|
||||
await expect(composition.startAfterDaemon()).rejects.toThrow('already stopped')
|
||||
})
|
||||
})
|
||||
@@ -284,6 +388,11 @@ function createStore(): Store {
|
||||
const settings = { claudeManagedAccounts: [], codexManagedAccounts: [] }
|
||||
return {
|
||||
getSettings: () => settings,
|
||||
onSettingsChanged: () => () => {}
|
||||
onSettingsChanged: () => () => {
|
||||
state.calls.push('target-sync-stop')
|
||||
if (state.stopErrors.targetSync) {
|
||||
throw state.stopErrors.targetSync
|
||||
}
|
||||
}
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
@@ -104,17 +104,18 @@ export function createBrowserlessRuntimeComposition(args: {
|
||||
})
|
||||
|
||||
let started = false
|
||||
let stopped = false
|
||||
const stopOwnedServices = async (): Promise<void> => {
|
||||
if (stopped) {
|
||||
return
|
||||
let stopPromise: Promise<void> | null = null
|
||||
const stopOwnedServices = (): Promise<void> => {
|
||||
if (!stopPromise) {
|
||||
stopPromise = settleBrowserlessOwnedServiceStops([
|
||||
() => automations.stopAndDrain(),
|
||||
() => rateLimits.stop(),
|
||||
stopAccountRuntimeTargetSync,
|
||||
() => agentHookServer.setClaudeStatusLineListener(null),
|
||||
() => agentHookServer.stopAndWait()
|
||||
])
|
||||
}
|
||||
stopped = true
|
||||
await automations.stopAndDrain()
|
||||
rateLimits.stop()
|
||||
stopAccountRuntimeTargetSync()
|
||||
agentHookServer.setClaudeStatusLineListener(null)
|
||||
await agentHookServer.stopAndWait()
|
||||
return stopPromise
|
||||
}
|
||||
return {
|
||||
runtime,
|
||||
@@ -122,7 +123,7 @@ export function createBrowserlessRuntimeComposition(args: {
|
||||
if (started) {
|
||||
return
|
||||
}
|
||||
if (stopped) {
|
||||
if (stopPromise) {
|
||||
throw new Error('The browserless runtime composition is already stopped.')
|
||||
}
|
||||
started = true
|
||||
@@ -146,7 +147,14 @@ export function createBrowserlessRuntimeComposition(args: {
|
||||
automations.start()
|
||||
} catch (error) {
|
||||
started = false
|
||||
await stopOwnedServices()
|
||||
try {
|
||||
await stopOwnedServices()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Browserless runtime startup and cleanup both failed'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
@@ -156,6 +164,22 @@ export function createBrowserlessRuntimeComposition(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function settleBrowserlessOwnedServiceStops(
|
||||
stops: readonly (() => void | Promise<void>)[]
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
for (const stop of stops) {
|
||||
try {
|
||||
await stop()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Browserless owned service shutdown failed')
|
||||
}
|
||||
}
|
||||
|
||||
function configureAccountServices(
|
||||
store: Store,
|
||||
rateLimits: RateLimitService,
|
||||
@@ -184,12 +208,8 @@ function configureAccountServices(
|
||||
function configureInactiveAccountResolvers(store: Store, rateLimits: RateLimitService): void {
|
||||
rateLimits.setInactiveClaudeAccountsResolver(() => {
|
||||
const settings = store.getSettings()
|
||||
const activeIds = new Set(
|
||||
[
|
||||
normalizeClaudeRuntimeSelection(settings).host,
|
||||
...Object.values(normalizeClaudeRuntimeSelection(settings).wsl)
|
||||
].filter(Boolean)
|
||||
)
|
||||
const selection = normalizeClaudeRuntimeSelection(settings)
|
||||
const activeIds = new Set([selection.host, ...Object.values(selection.wsl)].filter(Boolean))
|
||||
return settings.claudeManagedAccounts
|
||||
.filter((account) => !activeIds.has(account.id))
|
||||
.map((account) => ({
|
||||
@@ -202,12 +222,8 @@ function configureInactiveAccountResolvers(store: Store, rateLimits: RateLimitSe
|
||||
})
|
||||
rateLimits.setInactiveCodexAccountsResolver(() => {
|
||||
const settings = store.getSettings()
|
||||
const activeIds = new Set(
|
||||
[
|
||||
normalizeCodexRuntimeSelection(settings).host,
|
||||
...Object.values(normalizeCodexRuntimeSelection(settings).wsl)
|
||||
].filter(Boolean)
|
||||
)
|
||||
const selection = normalizeCodexRuntimeSelection(settings)
|
||||
const activeIds = new Set([selection.host, ...Object.values(selection.wsl)].filter(Boolean))
|
||||
return settings.codexManagedAccounts
|
||||
.filter((account) => !activeIds.has(account.id))
|
||||
.map((account) => ({ id: account.id, managedHomePath: account.managedHomePath }))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as managedHookLockClaims from '../main/agent-hooks/managed-hook-lock-claims'
|
||||
import {
|
||||
readManagedHookHostIdentity,
|
||||
readManagedHookProcessIdentity
|
||||
@@ -296,6 +297,17 @@ describe('server profile process lock', () => {
|
||||
await recovered.release()
|
||||
})
|
||||
|
||||
it('retries release after a transient removal failure', async () => {
|
||||
const root = await createRoot()
|
||||
const lock = await acquireServerProfileProcessLock(root)
|
||||
const removeLock = vi.spyOn(managedHookLockClaims, 'removeManagedHookLock')
|
||||
removeLock.mockResolvedValueOnce('unverifiable')
|
||||
|
||||
await expect(lock.release()).rejects.toMatchObject({ reason: 'release_failed' })
|
||||
await expect(lock.release()).resolves.toBeUndefined()
|
||||
expect(removeLock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces a typed error when the current process cannot be identified', async () => {
|
||||
const root = await createRoot()
|
||||
const error = await acquireServerProfileProcessLock(root, {
|
||||
|
||||
@@ -166,7 +166,6 @@ function createOwnedLock(
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
try {
|
||||
const removal = await removeManagedHookLock(
|
||||
lockPath,
|
||||
@@ -182,6 +181,7 @@ function createOwnedLock(
|
||||
owner.pid
|
||||
)
|
||||
}
|
||||
released = true
|
||||
} finally {
|
||||
deactivateManagedHookLockOwner(owner.token)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { NodeServerSetupCallout } from './NodeServerSetupCallout'
|
||||
|
||||
const writeClipboardText = vi.fn()
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { writeClipboardText } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('NodeServerSetupCallout', () => {
|
||||
it('does not expose clipboard failure details', async () => {
|
||||
writeClipboardText.mockRejectedValueOnce(new Error('private clipboard detail'))
|
||||
render(<NodeServerSetupCallout />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Copy server command' }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to copy server command.')
|
||||
expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain(
|
||||
'private clipboard detail'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,12 @@ export function NodeServerSetupCallout(): React.JSX.Element {
|
||||
'Copied server command.'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.NodeServerSetupCallout.copyFailed',
|
||||
'Failed to copy server command.'
|
||||
)
|
||||
translate(
|
||||
'auto.components.settings.NodeServerSetupCallout.copyFailed',
|
||||
'Failed to copy server command.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ describe('RuntimeHostAccessForm', () => {
|
||||
expect(markup).toContain('Tailscale')
|
||||
expect(markup).toContain('SSH tunnel')
|
||||
expect(markup).toContain('Already have the Orca desktop app')
|
||||
expect(markup.indexOf('npx @stablyai/orca@latest')).toBeLessThan(
|
||||
markup.indexOf('Already have the Orca desktop app')
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the sanitized destination without credential material', () => {
|
||||
|
||||
Reference in New Issue
Block a user