mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* feat(ai-vault): isolate scanning in service processes * fix(ai-vault): retire idle service processes * fix(ai-vault): discard unverified cache processes * fix(ai-vault): clear relay sidecar cancel watchdog on acknowledgement A cancelled relay call is settled before its 2s cancel watchdog is armed, so the acknowledgement path bailed out of settle() before clearing the timer. The watchdog then faulted a healthy sidecar two seconds after every aborted scan, killing whatever request had since become active. * fix(ai-vault): clear the pending restart before scheduling another recordFault overwrote this.timer, stranding a restart that dispose() could no longer cancel. * refactor(ai-vault): drop the orphaned first-prompt IPC wrapper session-first-user-prompt-handler.ts now owns this entry point and routes through the service; the copy left in the read module had no callers. * fix(ai-vault): retry a faulted cold start before surfacing it A slow first start surfaced a raw 'did not become ready' error to the caller even though the supervisor was already respawning. Requeue an unsent call once onto the scheduled respawn instead. Also stop arming the cancellation watchdog for a call the child never received: no acknowledgement is coming, so it killed a healthy service and stalled the lane. Invalidation bookkeeping and ready-waiter construction move to the state module to stay under the max-lines cap. * fix(ai-vault): give relay title reads their own lane Before this branch the relay read title files directly, concurrently with scans. Routing both through one sidecar lane put title resolution behind a list scan that may run up to 130s, so SSH tab titles could lag minutes behind. Split cache and interactive lanes in both the relay client and the sidecar entry, mirroring the desktop service. Also: clear the ready deadline on fault, so a sidecar that dies before ready cannot fault its healthy replacement five seconds later; retry an unsent call once across a respawn; and skip the cancellation watchdog for a call the sidecar never received. Restart/circuit bookkeeping moves to its own module, mirroring the desktop policy, to stay under the max-lines cap. * fix(ai-vault): degrade relay title resolution on sidecar failure listSessions already returns a host issue when the sidecar is unavailable; titles propagated the raw RPC error instead. Return no titles so callers fall back to preview text, and keep cancellation propagating. * fix(ai-vault): scrub the service child environment The children are forked with a 384 MiB heap cap and no loader, but both spawn sites handed them the full parent environment, so an exported NODE_OPTIONS silently raised the cap or --require'd code into them. Allowlist both, following the plugin worker. The desktop child keeps the eleven agent-root overrides it resolves its own roots from; the relay sidecar takes remoteHome and hostPlatform from its init message and so needs none of them. Both children share one priority module while they share this one. * fix(ai-vault): soft-disable relay vault when the service is missing A missing service threw out of the constructor, so a Vault wiring bug would abort relay startup and take every PTY on the host with it. The unsupported-platform branch three lines above already treats a Vault failure as a soft disable; do the same here. Threading the service through the two handlers instead of a field also retires the definite-assignment assertion the throw was propping up. * fix(ai-vault): drain consumed cache invalidations invalidatedPaths was re-applied in every request's finally and never drained, so once N paths had been invalidated every later request paid N evictions for the life of the process; the 4096 cap only bounded how bad that got. The re-apply exists to cover a read that overlapped the invalidation, so drain once nothing is executing. Clearing unconditionally would drop the re-apply for a request still running on the other lane. * fix(ai-vault): keep a busy child through slow invalidation acks invalidate() reused the 5s ready budget as its acknowledgement deadline and killed the child on expiry, so a delete issued during a large scan could kill a healthy process mid-scan and burn a slot toward the restart circuit. Fault only when nothing is executing. Fork IPC ordering already puts the invalidation ahead of any later request, so a busy child owes no ack here, and the 130s/15s request deadlines still catch a wedged one. The start-retry predicate moves to the state module to stay under the line cap, matching the shape the relay client already uses. * fix(ai-vault): report a failed local scan as a host issue A local-scope scan let its error escape to the renderer, which paints it over the session list. Service supervision now produces those errors, so "AI Vault service restart circuit is open." replaced the list. Route local scope through the degradation the all-hosts leg and every SSH leg already use, so it lands as a retryable host issue row instead. Same result shape either way, so no IPC or wire contract changes. * test(ai-vault): cover the relay restart circuit transitions The relay policy shipped without tests. Pin both circuit edges, the aging-out case, the forced-refresh reopen the relay has and the desktop does not, and the backoff schedule. * fix(ai-vault): keep the OpenCode roots in the service child env The scrubbed allowlist dropped XDG_DATA_HOME and OPENCODE_DB, which the child reads to locate the OpenCode store and database. The pre-PR worker thread inherited them, so a user who sets either lost every OpenCode session. * test(ai-vault): anchor the service spawn env assertion
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
|
import {
|
|
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
|
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD
|
|
} from '../shared/ssh-ai-vault-relay'
|
|
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
|
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
|
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
|
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
|
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
|
import { AiVaultHandler } from './ai-vault-handler'
|
|
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
|
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
|
|
|
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
|
|
|
|
const temporaryHomes: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true }))
|
|
)
|
|
})
|
|
|
|
describe('AiVaultHandler', () => {
|
|
it('resolves an exact transcript title without invoking the broad scanner', async () => {
|
|
const remoteHome = await makeTemporaryHome()
|
|
const transcriptPath = join(remoteHome, 'session.jsonl')
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({
|
|
timestamp: '2026-07-26T01:00:00.000Z',
|
|
type: 'session_meta',
|
|
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-07-26T01:00:01.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Resolve only this transcript' }]
|
|
}
|
|
})
|
|
].join('\n')
|
|
)
|
|
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome,
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions)
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
|
|
requests: [
|
|
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath },
|
|
{ agent: 'codex', sessionId: 'other', transcriptPath: '' }
|
|
]
|
|
})
|
|
).resolves.toEqual({
|
|
titles: [{ agent: 'codex', sessionId: 'ssh-session', title: 'Resolve only this transcript' }]
|
|
})
|
|
expect(scanRemoteSessions).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discovers and parses sessions entirely on the relay host', async () => {
|
|
const remoteHome = await makeTemporaryHome()
|
|
const transcriptPath = join(
|
|
remoteHome,
|
|
'.codex',
|
|
'sessions',
|
|
'2026',
|
|
'07',
|
|
'26',
|
|
'rollout-test.jsonl'
|
|
)
|
|
await mkdir(dirname(transcriptPath), { recursive: true })
|
|
await writeFile(
|
|
transcriptPath,
|
|
[
|
|
JSON.stringify({
|
|
timestamp: '2026-07-26T01:00:00.000Z',
|
|
type: 'session_meta',
|
|
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
|
|
}),
|
|
JSON.stringify({
|
|
timestamp: '2026-07-26T01:00:01.000Z',
|
|
type: 'response_item',
|
|
payload: {
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Scan on the SSH target' }]
|
|
}
|
|
})
|
|
].join('\n')
|
|
)
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome,
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'))
|
|
})
|
|
|
|
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
|
limit: 20
|
|
})) as AiVaultListResult
|
|
|
|
expect(result.issues).toEqual([])
|
|
expect(result.sessions).toHaveLength(1)
|
|
expect(result.sessions[0]).toMatchObject({
|
|
executionHostId: 'local',
|
|
executionHostPlatform: 'linux',
|
|
sessionId: 'ssh-session',
|
|
title: 'Scan on the SSH target',
|
|
filePath: transcriptPath
|
|
})
|
|
})
|
|
|
|
it('bounds relay scan parameters before touching the target filesystem', async () => {
|
|
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(
|
|
'/home/ada',
|
|
getRemoteHostPlatform('linux-x64'),
|
|
scanRemoteSessions
|
|
)
|
|
})
|
|
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
|
|
|
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
|
limit: 50_000,
|
|
scopePaths
|
|
})
|
|
|
|
expect(scanRemoteSessions).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
executionHostId: 'local',
|
|
limit: 1000,
|
|
scopePaths: scopePaths.slice(0, 64)
|
|
})
|
|
)
|
|
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
|
scopePaths
|
|
})) as AiVaultListResult
|
|
expect(result.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
kind: 'scope',
|
|
message: expect.stringContaining('first 64 project paths')
|
|
})
|
|
)
|
|
})
|
|
|
|
it('forwards Unlimited without the relay numeric cap', async () => {
|
|
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(
|
|
'/home/ada',
|
|
getRemoteHostPlatform('linux-x64'),
|
|
scanRemoteSessions
|
|
)
|
|
})
|
|
|
|
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
|
limit: 50_000,
|
|
unlimited: true
|
|
})
|
|
|
|
expect(scanRemoteSessions).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: undefined, unlimited: true })
|
|
)
|
|
})
|
|
|
|
it('coalesces identical in-flight scans without coupling caller cancellation', async () => {
|
|
let resolveScan: ((result: AiVaultListResult) => void) | undefined
|
|
let sharedSignal: AbortSignal | undefined
|
|
const scanRemoteSessions = vi.fn(
|
|
(args: { signal?: AbortSignal }) =>
|
|
new Promise<AiVaultListResult>((resolve) => {
|
|
sharedSignal = args.signal
|
|
resolveScan = resolve
|
|
})
|
|
)
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(
|
|
'/home/ada',
|
|
getRemoteHostPlatform('linux-x64'),
|
|
scanRemoteSessions as never
|
|
)
|
|
})
|
|
const firstController = new AbortController()
|
|
const first = dispatcher.call(
|
|
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
|
{ limit: 20 },
|
|
firstController.signal
|
|
)
|
|
const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
|
await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1))
|
|
|
|
firstController.abort()
|
|
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
|
expect(sharedSignal?.aborted).toBe(false)
|
|
|
|
resolveScan?.(emptyResult())
|
|
await expect(second).resolves.toEqual(emptyResult())
|
|
})
|
|
|
|
it('re-joins a preempted relay caller onto the forced refresh', async () => {
|
|
const signals: AbortSignal[] = []
|
|
let resolveForced: ((result: AiVaultListResult) => void) | undefined
|
|
const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => {
|
|
signals.push(args.signal)
|
|
return new Promise<AiVaultListResult>((resolve) => {
|
|
if (signals.length === 1) {
|
|
args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true })
|
|
} else {
|
|
resolveForced = resolve
|
|
}
|
|
})
|
|
})
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService(
|
|
'/home/ada',
|
|
getRemoteHostPlatform('linux-x64'),
|
|
scanRemoteSessions as never
|
|
)
|
|
})
|
|
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1))
|
|
|
|
const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
|
limit: 20,
|
|
force: true
|
|
})
|
|
await vi.waitFor(() => expect(signals).toHaveLength(2))
|
|
|
|
expect(signals[0]?.aborted).toBe(true)
|
|
resolveForced?.(emptyResult())
|
|
// The desktop caller that did not ask for a refresh still gets sessions.
|
|
await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()])
|
|
})
|
|
|
|
it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => {
|
|
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true })
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
|
try {
|
|
const dispatcher = createMockDispatcher()
|
|
|
|
expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow()
|
|
|
|
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
|
|
} finally {
|
|
stderr.mockRestore()
|
|
if (platform) {
|
|
Object.defineProperty(process, 'platform', platform)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('soft-disables the method instead of aborting relay startup when the service is missing', () => {
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
|
try {
|
|
const dispatcher = createMockDispatcher()
|
|
|
|
expect(
|
|
() =>
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64')
|
|
})
|
|
).not.toThrow()
|
|
|
|
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
|
|
} finally {
|
|
stderr.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('stops a relay-local scan when the owning request is cancelled', async () => {
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64'))
|
|
})
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
await expect(
|
|
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
|
|
).rejects.toMatchObject({ name: 'AbortError' })
|
|
})
|
|
|
|
it('returns a host issue when the sidecar is unavailable', async () => {
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: {
|
|
listSessions: () => Promise.reject(new Error('sidecar crashed')),
|
|
resolveSessionTitles: () => Promise.resolve({ titles: [] })
|
|
}
|
|
})
|
|
|
|
await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({
|
|
sessions: [],
|
|
issues: [
|
|
expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') })
|
|
]
|
|
})
|
|
})
|
|
|
|
it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => {
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
|
try {
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: {
|
|
listSessions: () => Promise.resolve(emptyResult()),
|
|
resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed'))
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
|
|
requests: [
|
|
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' }
|
|
]
|
|
})
|
|
).resolves.toEqual({ titles: [] })
|
|
} finally {
|
|
stderr.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('propagates title cancellation instead of degrading it', async () => {
|
|
const dispatcher = createMockDispatcher()
|
|
new AiVaultHandler(dispatcher.value, {
|
|
remoteHome: '/home/ada',
|
|
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
|
service: {
|
|
listSessions: () => Promise.resolve(emptyResult()),
|
|
resolveSessionTitles: () => {
|
|
const error = new Error('The operation was aborted.')
|
|
error.name = 'AbortError'
|
|
return Promise.reject(error)
|
|
}
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] })
|
|
).rejects.toMatchObject({ name: 'AbortError' })
|
|
})
|
|
})
|
|
|
|
async function makeTemporaryHome(): Promise<string> {
|
|
const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-'))
|
|
temporaryHomes.push(path)
|
|
return path
|
|
}
|
|
|
|
function emptyResult(): AiVaultListResult {
|
|
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
|
|
}
|
|
|
|
function createTestService(
|
|
remoteHome: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions
|
|
): RelayAiVaultServiceApi {
|
|
return {
|
|
listSessions: (params, signal) =>
|
|
scan({
|
|
provider: createRelayAiVaultFilesystemProvider(),
|
|
executionHostId: 'local',
|
|
remoteHome,
|
|
hostPlatform,
|
|
limit: params.limit,
|
|
unlimited: params.unlimited,
|
|
scopePaths: params.scopePaths,
|
|
signal
|
|
}),
|
|
resolveSessionTitles: (requests, signal) =>
|
|
readAiVaultSessionTitlesFromFiles(requests, { signal })
|
|
}
|
|
}
|
|
|
|
function createMockDispatcher(): {
|
|
value: RelayDispatcher
|
|
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
|
|
} {
|
|
const handlers = new Map<string, RequestHandler>()
|
|
const value = {
|
|
onRequest(method: string, handler: RequestHandler) {
|
|
handlers.set(method, handler)
|
|
}
|
|
} as RelayDispatcher
|
|
return {
|
|
value,
|
|
call(method, params, signal) {
|
|
const handler = handlers.get(method)
|
|
if (!handler) {
|
|
throw new Error(`No handler for ${method}`)
|
|
}
|
|
return handler(params, {
|
|
clientId: 1,
|
|
isStale: () => signal?.aborted ?? false,
|
|
signal
|
|
})
|
|
}
|
|
}
|
|
}
|