mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
refactor(relay): delete the SSH relay server, its build assets and tests (#27215)
* wip: delete the SSH relay server and its build, assets and tests * wip: drop main/relay plugin conformance; WSL reader lookup test * fix: drop the emptied relay bench; prune baselines * test: keep the native halves of tests that also covered the relay * chore(ci): drop shard timings for the deleted relay server tests * fix: keep the directory listing budget that darwin terminal names now import --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
+1
-2
@@ -18,8 +18,7 @@ function finish<T>(callback: Callback<T>, error: NodeJS.ErrnoException | null, v
|
||||
callback(null, value)
|
||||
}
|
||||
|
||||
/** The managed installers only need this small callback-style SFTP surface.
|
||||
* On the remote host it turns hundreds of WAN round trips into local fs calls. */
|
||||
/** Runs the managed installers' callback-style SFTP surface against the local disk in tests. */
|
||||
export function createManagedHookLocalFilesystem(): SFTPWrapper {
|
||||
const adapter = {
|
||||
readFile(path: string, _encoding: string, callback: Callback<string | Buffer>): void {
|
||||
@@ -1,81 +0,0 @@
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
installRemoteManagedAgentHooks,
|
||||
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
} from './remote-managed-hook-installers'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
|
||||
const tempHomes: string[] = []
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
|
||||
|
||||
async function createTempHome(): Promise<string> {
|
||||
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
|
||||
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
async function listFiles(root: string): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true })
|
||||
const nested = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(root, entry.name)
|
||||
return entry.isDirectory() ? await listFiles(path) : [path]
|
||||
})
|
||||
)
|
||||
return nested.flat()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('managed-hook local filesystem', () => {
|
||||
it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => {
|
||||
const home = await createTempHome()
|
||||
const filesystem = createManagedHookLocalFilesystem()
|
||||
const options = {
|
||||
grokHomeDir: join(home, '.grok'),
|
||||
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
}
|
||||
|
||||
const cold = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
const warm = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
|
||||
expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(cold.filter((result) => result.state === 'error')).toEqual([])
|
||||
expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(warm.filter((result) => result.state === 'error')).toEqual([])
|
||||
const files = await listFiles(home)
|
||||
expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([])
|
||||
const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path))
|
||||
expect(scripts.length).toBeGreaterThanOrEqual(10)
|
||||
if (process.platform !== 'win32') {
|
||||
for (const script of scripts) {
|
||||
expect((await stat(script)).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates a malformed config while installing the remaining agents', async () => {
|
||||
const home = await createTempHome()
|
||||
const claudeConfig = join(home, '.claude', 'settings.json')
|
||||
await mkdir(join(home, '.claude'), { recursive: true })
|
||||
await writeFile(claudeConfig, '{"hooks": }', 'utf8')
|
||||
|
||||
const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, {
|
||||
grokHomeDir: join(home, '.grok'),
|
||||
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(results.find((result) => result.agent === 'claude')?.state).toBe('error')
|
||||
expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed')
|
||||
expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed')
|
||||
expect(results.find((result) => result.agent === 'jcode')?.state).toBe('installed')
|
||||
expect(await readFile(claudeConfig, 'utf8')).toBe('{"hooks": }')
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { installManagedHooks } = await import('./managed-hook-runtime')
|
||||
|
||||
const tempHomes: string[] = []
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
|
||||
const SHELL_NAME = 'login-shell'
|
||||
const SHELL_RUNS_NAME = 'login-shell-runs'
|
||||
|
||||
async function createTempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */
|
||||
async function stubLoginShell(home: string): Promise<void> {
|
||||
const shell = join(home, SHELL_NAME)
|
||||
await writeFile(
|
||||
shell,
|
||||
`#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`,
|
||||
'utf8'
|
||||
)
|
||||
await chmod(shell, 0o755)
|
||||
vi.stubEnv('HOME', home)
|
||||
vi.stubEnv('SHELL', shell)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('installManagedHooks', () => {
|
||||
it.each([
|
||||
['omitted', undefined],
|
||||
['empty', { agents: [] }]
|
||||
])(
|
||||
'writes nothing and runs no probe when the allowlist is %s (issue #11641)',
|
||||
async (_label, options) => {
|
||||
const home = await createTempHome()
|
||||
await stubLoginShell(home)
|
||||
|
||||
await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 })
|
||||
|
||||
// Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe.
|
||||
expect(await readdir(home)).toEqual([SHELL_NAME])
|
||||
}
|
||||
)
|
||||
|
||||
it('still rejects an aborted request rather than resolving an empty summary', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
installManagedHooks({ signal: controller.signal, agents: [] })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('installs only the allowlisted agent, under the install lock', async () => {
|
||||
const home = await createTempHome()
|
||||
await stubLoginShell(home)
|
||||
|
||||
await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({
|
||||
installers: 1,
|
||||
errors: 0
|
||||
})
|
||||
|
||||
expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME])
|
||||
})
|
||||
|
||||
it('still probes the login shell when Grok is selected', async () => {
|
||||
const home = await createTempHome()
|
||||
await stubLoginShell(home)
|
||||
|
||||
await expect(installManagedHooks({ agents: ['grok'] })).resolves.toEqual({
|
||||
installers: 1,
|
||||
errors: 0
|
||||
})
|
||||
|
||||
expect((await readdir(home)).sort()).toEqual(['.grok', '.orca', SHELL_NAME, SHELL_RUNS_NAME])
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { resolveLoginShellGrokHome, resolveLoginShellKiroHome } from './agent-home-login-shell'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import type { AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { withManagedHookInstallLock } from './managed-hook-install-lock'
|
||||
import {
|
||||
readManagedHookHostIdentity,
|
||||
scopeManagedHookHostIdentity
|
||||
} from './managed-hook-owner-identity'
|
||||
|
||||
export type ManagedHookInstallSummary = {
|
||||
installers: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export async function installManagedHooks(options?: {
|
||||
signal?: AbortSignal
|
||||
hostKeyFingerprint?: string
|
||||
agents?: readonly AgentHookTarget[]
|
||||
claudeVersion?: string
|
||||
}): Promise<ManagedHookInstallSummary> {
|
||||
options?.signal?.throwIfAborted()
|
||||
// Why: empty/omitted allowlist fails closed before any home/host probes.
|
||||
const agents = options?.agents ?? []
|
||||
if (agents.length === 0) {
|
||||
return { installers: 0, errors: 0 }
|
||||
}
|
||||
const home = homedir()
|
||||
// Why parallel: each probe may wait out its own login-shell timeout.
|
||||
const [grokHomeDir, kiroHomeDir] = await Promise.all([
|
||||
agents.includes('grok') ? resolveLoginShellGrokHome(home, options?.signal) : undefined,
|
||||
agents.includes('kiro') ? resolveLoginShellKiroHome(home, options?.signal) : undefined
|
||||
])
|
||||
options?.signal?.throwIfAborted()
|
||||
const hostIdentity = scopeManagedHookHostIdentity(
|
||||
await readManagedHookHostIdentity(),
|
||||
options?.hostKeyFingerprint
|
||||
)
|
||||
return await withManagedHookInstallLock(
|
||||
home,
|
||||
options?.signal,
|
||||
async () => {
|
||||
const results = await installRemoteManagedAgentHooks(
|
||||
createManagedHookLocalFilesystem(),
|
||||
home,
|
||||
{
|
||||
grokHomeDir,
|
||||
kiroHomeDir,
|
||||
signal: options?.signal,
|
||||
agents,
|
||||
...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {})
|
||||
}
|
||||
)
|
||||
return {
|
||||
installers: results.length,
|
||||
errors: results.filter((result) => result.state === 'error').length
|
||||
}
|
||||
},
|
||||
hostIdentity
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem.test-fixture'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import '../runtime/rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AiVaultHandler } from '../../relay/ai-vault-handler'
|
||||
import type { RelayDispatcher } from '../../wsl-guest/dispatcher'
|
||||
import { createSessionSearchClient } from '../../shared/ai-vault-search-client'
|
||||
import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture'
|
||||
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault'
|
||||
@@ -28,22 +25,6 @@ afterEach(() => {
|
||||
resetSessionSearchScopeCatalogForTests()
|
||||
})
|
||||
|
||||
function relayHandler(): (params: Record<string, unknown>) => Promise<unknown> {
|
||||
const handlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
const dispatcher = {
|
||||
onRequest: (method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
handlers.set(method, handler)
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls `onRequest`, and the test asserts the registration it makes.
|
||||
new AiVaultHandler(dispatcher as unknown as RelayDispatcher)
|
||||
const search = handlers.get('aiVault.searchSessions')
|
||||
if (!search) {
|
||||
throw new Error('relay registered no session search handler')
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
describe('every search entry point carries the scope identity through', () => {
|
||||
it('resolves over the in-process IPC entry point', async () => {
|
||||
const service = fakeSearchService()
|
||||
@@ -76,40 +57,6 @@ describe('every search entry point carries the scope identity through', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves over the relay entry point', async () => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
installSessionSearchScopeCatalogSource(() => CATALOG)
|
||||
await relayHandler()({
|
||||
query: 'needle',
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
supportsQoderHistory: true,
|
||||
supportsJcodeHistory: true,
|
||||
within: WITHIN
|
||||
})
|
||||
expect(service.search).toHaveBeenCalledWith(expect.anything(), {
|
||||
kind: 'resolved',
|
||||
paths: ['/work/app']
|
||||
})
|
||||
})
|
||||
|
||||
it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
await relayHandler()({
|
||||
query: 'needle',
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
supportsQoderHistory: true,
|
||||
supportsJcodeHistory: true,
|
||||
within: WITHIN
|
||||
})
|
||||
expect(service.search).toHaveBeenCalledWith(
|
||||
{ query: 'needle', limit: 20 },
|
||||
{
|
||||
kind: 'unknown'
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shared remote client', () => {
|
||||
|
||||
@@ -2,13 +2,10 @@ import { runProcess } from '@orca/process-host'
|
||||
import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { dedupeScannedSessions } from './session-root-dedup'
|
||||
import { jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
|
||||
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
|
||||
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
|
||||
@@ -190,39 +187,4 @@ describe('Antigravity IDE history discovery', () => {
|
||||
expect(await readLocalAntigravityHistory(file)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const remoteHome = '/remote/home'
|
||||
for (const origin of origins) {
|
||||
const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs`
|
||||
provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10)
|
||||
provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11)
|
||||
}
|
||||
const read = vi.spyOn(provider, 'readFile')
|
||||
const options = {
|
||||
provider,
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
includeAntigravityIdeSessions: true
|
||||
}
|
||||
const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' })
|
||||
const second = await scanRemoteAiVaultSessions({
|
||||
...options,
|
||||
executionHostId: 'ssh:second-host'
|
||||
})
|
||||
expect(first.sessions).toHaveLength(3)
|
||||
expect(second.sessions).toHaveLength(3)
|
||||
expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6)
|
||||
expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true)
|
||||
expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true)
|
||||
expect(
|
||||
first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl'))
|
||||
).toBe(true)
|
||||
const legacy = await scanRemoteAiVaultSessions({
|
||||
...options,
|
||||
executionHostId: 'ssh:first-host',
|
||||
includeAntigravityIdeSessions: false
|
||||
})
|
||||
expect(legacy.sessions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -23,7 +23,6 @@ vi.mock('node:fs/promises', async (original) => {
|
||||
})
|
||||
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
|
||||
import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream'
|
||||
|
||||
const roots: string[] = []
|
||||
async function filePath() {
|
||||
@@ -43,25 +42,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('bounded regular metadata reads', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects real FIFO paths and symlinks without waiting for a writer',
|
||||
async () => {
|
||||
const file = await filePath()
|
||||
await fifo(file)
|
||||
const alias = `${file}.link`
|
||||
await symlink(file, alias)
|
||||
for (const path of [file, alias]) {
|
||||
expect(await readLocalAntigravityHistory(path)).toBeNull()
|
||||
await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow(
|
||||
'regular file'
|
||||
)
|
||||
await expect(
|
||||
readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next()
|
||||
).rejects.toThrow('regular file')
|
||||
}
|
||||
},
|
||||
2000
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a regular path replaced by a FIFO between stat and open',
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
|
||||
|
||||
function provider(): RemoteSessionFilesystemProvider {
|
||||
return {
|
||||
readDir: async () => [],
|
||||
stat: async () => ({ type: 'file', size: 2, mtime: 0 }),
|
||||
readFile: async () => ({ content: '{}', isBinary: false })
|
||||
}
|
||||
}
|
||||
|
||||
describe('execution-host Antigravity metadata reads', () => {
|
||||
it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => {
|
||||
const host = provider()
|
||||
const read = vi.fn(async function* () {
|
||||
yield Buffer.from('{}')
|
||||
})
|
||||
host.readTranscriptBytes = read
|
||||
expect(
|
||||
await readRemoteAntigravityIndex(
|
||||
limitRemoteScanFilesystemConcurrency(host),
|
||||
'/host/projects.json'
|
||||
)
|
||||
).toBe('{}')
|
||||
expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, {
|
||||
regularFileOnly: true,
|
||||
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the existing bounded read-file options on legacy providers', async () => {
|
||||
const host = provider()
|
||||
const read = vi.spyOn(host, 'readFile')
|
||||
expect(
|
||||
await readRemoteAntigravityIndex(
|
||||
limitRemoteScanFilesystemConcurrency(host),
|
||||
'/host/projects.json'
|
||||
)
|
||||
).toBe('{}')
|
||||
expect(read).toHaveBeenCalledWith('/host/projects.json', {
|
||||
maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read directories or symlinks from legacy providers', async () => {
|
||||
for (const type of ['directory', 'symlink'] as const) {
|
||||
const host = provider()
|
||||
host.stat = async () => ({ type, size: 0, mtime: 0 })
|
||||
const read = vi.spyOn(host, 'readFile')
|
||||
expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull()
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops waiting for a legacy request that cannot cancel on the wire', async () => {
|
||||
const host = provider()
|
||||
host.readFile = () => new Promise(() => {})
|
||||
const controller = new AbortController()
|
||||
const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
await expect(read).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
ANTIGRAVITY_INDEX_MAX_BYTES,
|
||||
readBoundedAntigravityIndex
|
||||
} from './session-scanner-antigravity-metadata'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import {
|
||||
abandonRemoteSessionScanOnCancel,
|
||||
throwIfAiVaultScanCancelled
|
||||
} from './ai-vault-scan-cancellation'
|
||||
|
||||
export async function readRemoteAntigravityIndex(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (provider.readTranscriptBytes) {
|
||||
return await abandonRemoteSessionScanOnCancel(
|
||||
readBoundedAntigravityIndex(
|
||||
provider.readTranscriptBytes(path, signal, {
|
||||
regularFileOnly: true,
|
||||
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
),
|
||||
signal
|
||||
)
|
||||
}
|
||||
const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal)
|
||||
if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
|
||||
return null
|
||||
}
|
||||
const read = await abandonRemoteSessionScanOnCancel(
|
||||
provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }),
|
||||
signal
|
||||
)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
? null
|
||||
: read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type { RemoteScannerContext } from './remote-session-scanner-types'
|
||||
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
|
||||
import { walkSessionFiles } from './session-scanner-discovery'
|
||||
|
||||
@@ -49,44 +45,4 @@ describe('OpenClaw session discovery', () => {
|
||||
expect(relativeToAgents(files)).toEqual(EXPECTED)
|
||||
})
|
||||
|
||||
it('never lists an embedded agent home over a remote filesystem', async () => {
|
||||
const readDirs: string[] = []
|
||||
const provider: RemoteScannerContext['provider'] = {
|
||||
readDir: async (dirPath) => {
|
||||
readDirs.push(dirPath)
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
},
|
||||
stat: async (path) => {
|
||||
const info = await stat(path)
|
||||
return { size: info.size, type: 'file', mtime: info.mtimeMs }
|
||||
},
|
||||
readFile: async () => ({ content: '', isBinary: false })
|
||||
}
|
||||
const hostPlatform = getRemoteHostPlatform(
|
||||
process.platform === 'win32' ? 'win32-x64' : 'linux-x64'
|
||||
)
|
||||
const source = remoteSessionSources(home, hostPlatform).find(
|
||||
(candidate) => candidate.agent === 'openclaw'
|
||||
)
|
||||
|
||||
const context: RemoteScannerContext = {
|
||||
provider,
|
||||
executionHostId: 'ssh:host',
|
||||
hostPlatform,
|
||||
titleCaches: new Map(),
|
||||
antigravityWorkspaceResolver: { enrich: async (session) => session }
|
||||
}
|
||||
expect(source).toBeDefined()
|
||||
const candidates = source
|
||||
? await discoverRemoteSourceCandidates({ source, context, issues: [] })
|
||||
: []
|
||||
|
||||
expect(relativeToAgents(candidates.map(({ file }) => file.path))).toEqual(EXPECTED)
|
||||
expect(readDirs.some((dir) => dir.includes('codex-home'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,10 +87,7 @@ describe('WSL SQLite runtime preparation', () => {
|
||||
args: ['-e', expect.stringMatching(/require\('node:sqlite'\).*backup/)]
|
||||
})
|
||||
)
|
||||
expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([
|
||||
'linux-x64',
|
||||
'linux-arm64'
|
||||
])
|
||||
expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual(['wsl'])
|
||||
expect(mocks.download).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -102,9 +102,7 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
const app = getAppEnvironment()
|
||||
// The reader is plain JavaScript; either packaged Linux architecture is usable.
|
||||
const reader = (['linux-x64', 'linux-arm64'] as const)
|
||||
.flatMap((platform) => relayBundleCandidates(platform, app.getAppPath()))
|
||||
const reader = relayBundleCandidates('wsl', app.getAppPath())
|
||||
.map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME))
|
||||
.find(existsSync)
|
||||
if (!reader) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionDocument } from './session-scanner-devin-parser'
|
||||
import { parseHermesSessionDocument } from './session-scanner-hermes-parser'
|
||||
import {
|
||||
parseGeminiSessionDocument,
|
||||
parseGeminiJsonlSessionLines
|
||||
} from './session-scanner-gemini-parsers'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
|
||||
export function remoteSessionDocumentParsers(
|
||||
agent: AiVaultAgent
|
||||
): Pick<RemoteSessionSource, 'parseLines' | 'parseDocument'> {
|
||||
const parse =
|
||||
agent === 'hermes'
|
||||
? parseHermesSessionDocument
|
||||
: agent === 'devin'
|
||||
? parseDevinSessionDocument
|
||||
: agent === 'gemini'
|
||||
? parseGeminiSessionDocument
|
||||
: null
|
||||
if (!parse) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
parseDocument: (file, bytes, context) =>
|
||||
parse(
|
||||
file,
|
||||
bytes,
|
||||
context.hostPlatform.os,
|
||||
{
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
},
|
||||
context.signal
|
||||
),
|
||||
...(agent === 'gemini'
|
||||
? {
|
||||
parseLines: (file, lines, context) =>
|
||||
parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines,
|
||||
platform: context.hostPlatform.os,
|
||||
options: {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const platform = getRemoteHostPlatform(
|
||||
process.platform === 'win32'
|
||||
? 'win32-x64'
|
||||
: process.platform === 'darwin'
|
||||
? 'darwin-arm64'
|
||||
: 'linux-x64'
|
||||
)
|
||||
const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`
|
||||
const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000)
|
||||
|
||||
describe('large remote history through real relay filesystem', () => {
|
||||
it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-'))
|
||||
try {
|
||||
const directory = join(home, '.codex', 'sessions')
|
||||
await mkdir(directory, { recursive: true })
|
||||
const metadata = (id: string) =>
|
||||
jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }])
|
||||
const badPath = join(directory, 'bad.jsonl')
|
||||
await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024))
|
||||
await writeFile(join(directory, 'good.jsonl'), metadata('good'))
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
|
||||
executionHostId: 'ssh:record-limit',
|
||||
remoteHome: home,
|
||||
hostPlatform: platform,
|
||||
unlimited: true
|
||||
})
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['good'])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: badPath.replace(/\\/g, '/'),
|
||||
message: 'Session transcript record exceeds 10485760 byte limit'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('lists a large Codex rollout with middle messages and usage intact', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-'))
|
||||
try {
|
||||
const path = join(home, '.codex', 'sessions', 'large.jsonl')
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(
|
||||
path,
|
||||
jsonl([
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-09-13T01:00:00Z',
|
||||
payload: { id: 'large', cwd: '/repo' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-09-13T01:00:01Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Keep my history' }]
|
||||
}
|
||||
}
|
||||
]) +
|
||||
filler.slice(0, filler.length / 2) +
|
||||
jsonl([
|
||||
{
|
||||
type: 'response_item',
|
||||
timestamp: '2026-09-13T01:02:00Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Middle answer' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-09-13T01:03:00Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]) +
|
||||
filler.slice(filler.length / 2)
|
||||
)
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
|
||||
executionHostId: 'ssh:synthetic-17744',
|
||||
remoteHome: home,
|
||||
hostPlatform: platform,
|
||||
unlimited: true
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'large',
|
||||
messageCount: 2,
|
||||
totalTokens: 168,
|
||||
title: 'Keep my history'
|
||||
})
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['hermes', 'devin', 'gemini', 'cline'] as const)(
|
||||
'lists large %s documents with every message counted',
|
||||
async (agent) => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-'))
|
||||
try {
|
||||
const messages = Array.from({ length: 11000 }, () => ({
|
||||
role: 'assistant',
|
||||
content: 'x'.repeat(1024)
|
||||
}))
|
||||
messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' })
|
||||
let path: string, record: unknown
|
||||
if (agent === 'hermes') {
|
||||
path = join(home, '.hermes', 'sessions', 'large.json')
|
||||
record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages }
|
||||
} else if (agent === 'devin') {
|
||||
path =
|
||||
platform.os === 'win32'
|
||||
? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json')
|
||||
: join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json')
|
||||
record = {
|
||||
session_id: 'large',
|
||||
working_directory: '/repo',
|
||||
steps: messages.map((message) => ({
|
||||
...message,
|
||||
metadata: {
|
||||
is_user_input: message.role === 'user',
|
||||
metrics: { input_tokens: 2, output_tokens: 1 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
} else if (agent === 'gemini') {
|
||||
path = join(home, '.gemini', 'tmp', 'large.json')
|
||||
record = {
|
||||
sessionId: 'large',
|
||||
messages: messages.map((message) => ({
|
||||
type: message.role === 'assistant' ? 'gemini' : 'user',
|
||||
content: message.content
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json')
|
||||
record = { session_id: 'large', cwd: '/repo' }
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages }))
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, JSON.stringify(record))
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
|
||||
executionHostId: `ssh:large-${agent}`,
|
||||
remoteHome: home,
|
||||
hostPlatform: platform,
|
||||
unlimited: true
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 })
|
||||
if (agent === 'devin') {
|
||||
expect(result.sessions[0].totalTokens).toBe(33003)
|
||||
}
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
it('keeps normal-size reads on their existing path and supports providers without streaming', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-'))
|
||||
try {
|
||||
const directory = join(home, '.codex', 'sessions')
|
||||
await mkdir(directory, { recursive: true })
|
||||
const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }])
|
||||
await writeFile(join(directory, 'small.jsonl'), content)
|
||||
const provider = createRelayAiVaultFilesystemProvider({
|
||||
homeDirectory: home,
|
||||
environment: {}
|
||||
})
|
||||
provider.readTranscriptBytes = () => {
|
||||
throw new Error('Small file must keep its existing read path')
|
||||
}
|
||||
const small = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:small-original',
|
||||
remoteHome: home,
|
||||
hostPlatform: platform,
|
||||
unlimited: true
|
||||
})
|
||||
expect(small.issues).toEqual([])
|
||||
expect(small.sessions.map((session) => session.sessionId)).toEqual(['small'])
|
||||
await writeFile(join(directory, 'large.jsonl'), content + filler)
|
||||
const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat }
|
||||
const fallback = await scanRemoteAiVaultSessions({
|
||||
provider: legacy,
|
||||
executionHostId: 'ssh:legacy-original',
|
||||
remoteHome: home,
|
||||
hostPlatform: platform,
|
||||
unlimited: true
|
||||
})
|
||||
expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small'])
|
||||
expect(
|
||||
fallback.issues.some(
|
||||
(issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit')
|
||||
)
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,216 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { FileReadResult } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
|
||||
/**
|
||||
* Counts whole-transcript reads, which is the cost #13753 is about. Codex's
|
||||
* per-scan `session_index.jsonl` title lookup is one small file and is not part
|
||||
* of the corpus term, so it is excluded rather than asserted on.
|
||||
*/
|
||||
class CountingRemoteProvider extends MemoryRemoteProvider {
|
||||
readonly readFilePaths: string[] = []
|
||||
|
||||
override async readFile(filePath: string): Promise<FileReadResult> {
|
||||
if (filePath.includes('/sessions/')) {
|
||||
this.readFilePaths.push(filePath)
|
||||
}
|
||||
return await super.readFile(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function transcript(sessionId: string, title: string, timestamp: string): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp,
|
||||
type: 'session_meta',
|
||||
payload: { id: sessionId, cwd: '/home/ada/repo' }
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function scan(provider: CountingRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> {
|
||||
return scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
}
|
||||
|
||||
describe('remote AI Vault transcript re-reads', () => {
|
||||
beforeEach(() => {
|
||||
resetRemoteSessionParseCacheForTests()
|
||||
})
|
||||
|
||||
it('does not re-read an unchanged corpus on the next scan', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
for (const day of ['07/07', '07/25', '08/10']) {
|
||||
provider.addFile(
|
||||
`/home/ada/.codex/sessions/2026/${day}/rollout-${day.replace('/', '')}.jsonl`,
|
||||
transcript(
|
||||
`session-${day.replace('/', '')}`,
|
||||
`Work from ${day}`,
|
||||
'2026-07-07T01:00:00.000Z'
|
||||
),
|
||||
1_000
|
||||
)
|
||||
}
|
||||
|
||||
const first = await scan(provider)
|
||||
expect(first.sessions).toHaveLength(3)
|
||||
expect(provider.readFilePaths).toHaveLength(3)
|
||||
|
||||
provider.readFilePaths.length = 0
|
||||
const second = await scan(provider)
|
||||
|
||||
// Historical transcripts are immutable; a second pass must cost zero reads.
|
||||
expect(provider.readFilePaths).toEqual([])
|
||||
expect(second.sessions.map((session) => session.title)).toEqual(
|
||||
first.sessions.map((session) => session.title)
|
||||
)
|
||||
})
|
||||
|
||||
it('re-reads a transcript that actually changed', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-live.jsonl'
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript('live-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
|
||||
1_000
|
||||
)
|
||||
|
||||
await scan(provider)
|
||||
provider.readFilePaths.length = 0
|
||||
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript('live-session', 'Second prompt', '2026-08-31T02:00:00.000Z'),
|
||||
2_000
|
||||
)
|
||||
const result = await scan(provider)
|
||||
|
||||
expect(provider.readFilePaths).toEqual([path])
|
||||
expect(result.sessions[0]?.title).toBe('Second prompt')
|
||||
})
|
||||
|
||||
it('re-reads when only the size changed under an unchanged mtime', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-grown.jsonl'
|
||||
provider.addFile(path, transcript('grown-session', 'Short', '2026-08-31T01:00:00.000Z'), 1_000)
|
||||
|
||||
await scan(provider)
|
||||
provider.readFilePaths.length = 0
|
||||
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript(
|
||||
'grown-session',
|
||||
'A much longer first prompt than before',
|
||||
'2026-08-31T01:00:00.000Z'
|
||||
),
|
||||
1_000
|
||||
)
|
||||
const result = await scan(provider)
|
||||
|
||||
expect(provider.readFilePaths).toEqual([path])
|
||||
expect(result.sessions[0]?.title).toBe('A much longer first prompt than before')
|
||||
})
|
||||
|
||||
// Codex names threads in $CODEX_HOME/session_index.jsonl asynchronously, after
|
||||
// the rollout's last append — so the transcript's mtime+size never changes to
|
||||
// signal it. That file sits outside `sessions/`, hence outside the read count.
|
||||
it('picks up a session_index title written after the transcript was cached', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-named-later.jsonl'
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript('named-later-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
|
||||
1_000
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: 'some-other-session', thread_name: 'Unrelated thread' }]),
|
||||
1_000
|
||||
)
|
||||
|
||||
const first = await scan(provider)
|
||||
expect(first.sessions[0]?.title).toBe('First prompt')
|
||||
|
||||
provider.readFilePaths.length = 0
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([
|
||||
{ id: 'some-other-session', thread_name: 'Unrelated thread' },
|
||||
{ id: 'named-later-session', thread_name: 'Named by Codex after the fact' }
|
||||
]),
|
||||
2_000
|
||||
)
|
||||
const second = await scan(provider)
|
||||
|
||||
expect(second.sessions[0]?.title).toBe('Named by Codex after the fact')
|
||||
// The #13753 win is preserved: the index is read, the transcript is not.
|
||||
expect(provider.readFilePaths).toEqual([])
|
||||
})
|
||||
|
||||
it('does not serve a cached parse to a different execution host', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-host.jsonl'
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript('host-session', 'Host scoped', '2026-08-31T01:00:00.000Z'),
|
||||
1_000
|
||||
)
|
||||
|
||||
await scan(provider)
|
||||
provider.readFilePaths.length = 0
|
||||
|
||||
const other = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:other-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(provider.readFilePaths).toEqual([path])
|
||||
expect(other.sessions[0]?.executionHostId).toBe('ssh:other-box')
|
||||
})
|
||||
|
||||
it('does not cache a read that failed', async () => {
|
||||
const provider = new CountingRemoteProvider()
|
||||
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-flaky.jsonl'
|
||||
provider.addFile(
|
||||
path,
|
||||
transcript('flaky-session', 'Recovered', '2026-08-31T01:00:00.000Z'),
|
||||
1_000
|
||||
)
|
||||
|
||||
let failNextRead = true
|
||||
const originalReadFile = provider.readFile.bind(provider)
|
||||
provider.readFile = async (filePath: string): Promise<FileReadResult> => {
|
||||
if (failNextRead && filePath === path) {
|
||||
failNextRead = false
|
||||
provider.readFilePaths.push(filePath)
|
||||
throw new Error('EIO: transient relay read failure')
|
||||
}
|
||||
return await originalReadFile(filePath)
|
||||
}
|
||||
|
||||
const failed = await scan(provider)
|
||||
expect(failed.sessions).toEqual([])
|
||||
expect(failed.issues).toHaveLength(1)
|
||||
|
||||
provider.readFilePaths.length = 0
|
||||
const recovered = await scan(provider)
|
||||
|
||||
expect(provider.readFilePaths).toEqual([path])
|
||||
expect(recovered.sessions[0]?.title).toBe('Recovered')
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
|
||||
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
|
||||
|
||||
// Matches the local scanner's cap. The relay sidecar is forked with
|
||||
// --max-old-space-size=384, and a retained session row is a title, a preview
|
||||
// window and counters — orders of magnitude smaller than the transcript it was
|
||||
// parsed from, which is what the cache stops us re-reading.
|
||||
const MAX_CACHE_ENTRIES = 4096
|
||||
|
||||
type RemoteSessionParseCacheEntry = {
|
||||
mtimeMs: number
|
||||
sizeBytes: number | null
|
||||
hostKey: string
|
||||
sidecar?: SessionSidecarObservation
|
||||
session: AiVaultSession | null
|
||||
}
|
||||
|
||||
// Module scope so it outlives one scan: the sidecar is retired only after 10
|
||||
// idle minutes, so it spans many passes of a 30s cadence.
|
||||
const cache = new Map<string, RemoteSessionParseCacheEntry>()
|
||||
|
||||
export type RemoteSessionParseStats = { reused: number; parsed: number }
|
||||
|
||||
export function resetRemoteSessionParseCacheForTests(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
/** Identity of the host a parse result belongs to; a result is not portable across either field. */
|
||||
export function remoteSessionParseHostKey(context: RemoteScannerContext): string {
|
||||
return `${context.executionHostId}\u0000${context.hostPlatform.relayPlatform}`
|
||||
}
|
||||
|
||||
export function remoteSessionCandidateKey(candidate: RemoteSessionCandidate): string {
|
||||
return `${candidate.source.agent}\u0000${candidate.file.path}`
|
||||
}
|
||||
|
||||
function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void {
|
||||
cache.delete(path)
|
||||
cache.set(path, entry)
|
||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const oldest = cache.keys().next()
|
||||
if (!oldest.done) {
|
||||
cache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a remote transcript, reusing the previous result when the file is
|
||||
* provably unchanged.
|
||||
*
|
||||
* Why this exists: the remote scanner had no cache of any kind, so every pass
|
||||
* re-read and re-parsed the whole corpus — up to 3000 whole-file reads, GBs of
|
||||
* JSONL, including July transcripts that had not changed in a month — which is
|
||||
* what pegged the relay host on the renderer's 30s forced-rescan cadence
|
||||
* (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly
|
||||
* this reason; this is its remote counterpart.
|
||||
*
|
||||
* `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation
|
||||
* discovery records beside it (remote-session-scanner-discovery.ts) covers a
|
||||
* source's companion file, so a metadata-only transcript whose companion
|
||||
* changed still looks changed. Remote Cline is the only such source; remote
|
||||
* Cursor streams transcript content with no sibling to read. Sources whose
|
||||
* parse reads a file discovery does not stat — Codex looks its title up in
|
||||
* `session_index.jsonl` — are not covered by either and pass
|
||||
* `refreshReusedSession` to re-derive the uncovered part without touching the
|
||||
* transcript.
|
||||
*
|
||||
* Only a completed parse is stored. A read that threw stays uncached so a
|
||||
* transient filesystem failure cannot pin a wrong answer for the corpus's life.
|
||||
*/
|
||||
export async function parseRemoteSessionFileCached(args: {
|
||||
candidate: RemoteSessionCandidate
|
||||
hostKey: string
|
||||
parse: () => Promise<AiVaultSession | null>
|
||||
// Applied to a reused session only; must not re-read the transcript.
|
||||
refreshReusedSession?: (session: AiVaultSession) => Promise<AiVaultSession>
|
||||
stats?: RemoteSessionParseStats
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const { file } = args.candidate
|
||||
const key = remoteSessionCandidateKey(args.candidate)
|
||||
const entry = cache.get(key)
|
||||
const unchanged =
|
||||
entry !== undefined &&
|
||||
entry.hostKey === args.hostKey &&
|
||||
entry.mtimeMs === file.mtimeMs &&
|
||||
(entry.sizeBytes === null ||
|
||||
file.sizeBytes === undefined ||
|
||||
entry.sizeBytes === file.sizeBytes) &&
|
||||
sidecarUnchanged(entry.sidecar, file.sidecar)
|
||||
if (unchanged) {
|
||||
if (args.stats) {
|
||||
args.stats.reused++
|
||||
}
|
||||
if (entry.session && args.refreshReusedSession) {
|
||||
entry.session = await args.refreshReusedSession(entry.session)
|
||||
}
|
||||
// Refresh recency without re-parsing so the LRU evicts cold paths first.
|
||||
storeEntry(key, entry)
|
||||
return entry.session
|
||||
}
|
||||
|
||||
const session = await args.parse()
|
||||
if (args.stats) {
|
||||
args.stats.parsed++
|
||||
}
|
||||
storeEntry(key, {
|
||||
mtimeMs: file.mtimeMs,
|
||||
sizeBytes: file.sizeBytes ?? null,
|
||||
hostKey: args.hostKey,
|
||||
sidecar: file.sidecar,
|
||||
session
|
||||
})
|
||||
return session
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
|
||||
describe('mapRemoteScanBatches', () => {
|
||||
it('observes an abort that lands while the final batch yields', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches(
|
||||
['a', 'b'],
|
||||
2,
|
||||
async (item) => {
|
||||
controller.abort()
|
||||
return item
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('observes an abort for empty inputs that never enter the loop', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
mapRemoteScanBatches([], 8, async (item) => item, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns every batch result when nothing cancels', async () => {
|
||||
expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6])
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
// Batching bounds concurrent remote round trips; the yield between batches keeps
|
||||
// the process responsive, and the batch boundary is where cancellation lands.
|
||||
export async function mapRemoteScanBatches<T, U>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<U>,
|
||||
signal?: AbortSignal
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += concurrency) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper))))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
// An abort can land while the last batch yields, and empty inputs never enter
|
||||
// the loop at all — observe it here so neither path returns as a success.
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return results
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
|
||||
// Why: discovery batches (8 sources) each stat in batches of 8, and parse
|
||||
// batches read whole transcripts — nested fan-out put ~64 filesystem round
|
||||
// trips on one SSH mux or relay event loop at once, blowing the scan budget and
|
||||
// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling.
|
||||
const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8
|
||||
|
||||
/** Wraps a provider so all of one scan's filesystem calls share a single
|
||||
* in-flight ceiling, regardless of how the callers nest their batches. */
|
||||
export function limitRemoteScanFilesystemConcurrency(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY
|
||||
): RemoteSessionFilesystemProvider {
|
||||
const gate = createConcurrencyGate(maxInFlight)
|
||||
return {
|
||||
openCode: provider.openCode,
|
||||
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
|
||||
readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)),
|
||||
stat: (filePath) => gate(() => provider.stat(filePath)),
|
||||
...(provider.readTranscriptBytes
|
||||
? {
|
||||
readTranscriptBytes: async function* (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { regularFileOnly: true; maxBytes: number }
|
||||
) {
|
||||
let enter!: () => void
|
||||
let release!: () => void
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
enter = resolve
|
||||
})
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const held = gate(async () => {
|
||||
enter()
|
||||
await released
|
||||
})
|
||||
await entered
|
||||
try {
|
||||
yield* provider.readTranscriptBytes!(path, signal, options)
|
||||
} finally {
|
||||
release()
|
||||
await held
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function createConcurrencyGate(maxInFlight: number): <T>(run: () => Promise<T>) => Promise<T> {
|
||||
const limit = Math.max(1, Math.floor(maxInFlight))
|
||||
const waiting: (() => void)[] = []
|
||||
let inFlight = 0
|
||||
|
||||
return async <T>(run: () => Promise<T>): Promise<T> => {
|
||||
if (inFlight < limit) {
|
||||
inFlight++
|
||||
} else {
|
||||
// The releasing call hands its slot over directly, so a queued caller can
|
||||
// never race a fresh one into an over-limit slot.
|
||||
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
}
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
const next = waiting.shift()
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
inFlight--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath,
|
||||
parseClineSessionDocuments,
|
||||
parseClineSessionContent
|
||||
} from './session-scanner-cline-parser'
|
||||
|
||||
export function remoteClineSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'cline',
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
directoryPredicate: (_name, depth) => depth === 0,
|
||||
parseDocument: (file, bytes, context) =>
|
||||
parseClineSessionDocuments(
|
||||
file,
|
||||
bytes,
|
||||
() =>
|
||||
context.provider.readTranscriptBytes!(
|
||||
clineMessagesPathForMetadata(file.path),
|
||||
context.signal
|
||||
),
|
||||
context.hostPlatform.os,
|
||||
{
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
},
|
||||
context.signal
|
||||
),
|
||||
parse: async (file, content, context) => {
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
messagesContent = read.isBinary ? null : read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
import { remoteSessionContentLines } from './remote-session-content-lines'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionFilesystemProvider
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
// One index read per CODEX_HOME per scan (`context.titleCaches` is scan-scoped);
|
||||
// used both by the transcript parse and by the parse cache's reuse path.
|
||||
export function remoteCodexIndexedTitleReader(
|
||||
codexHome: string,
|
||||
context: RemoteScannerContext
|
||||
): (sessionId: string) => Promise<string | null> {
|
||||
return async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform: context.hostPlatform,
|
||||
titleCaches: context.titleCaches,
|
||||
signal: context.signal
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
}
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
signal?: AbortSignal
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(
|
||||
args.provider,
|
||||
args.codexHome,
|
||||
args.hostPlatform,
|
||||
args.signal
|
||||
)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
signal?: AbortSignal
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for await (const line of remoteSessionContentLines(content, signal)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { joinRemotePath, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
||||
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
|
||||
import type { RemoteSessionContent } from './remote-session-content-lines'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types'
|
||||
|
||||
/** Codex is the one remote agent whose sessions live under two CODEX_HOME roots, so it
|
||||
* yields a source per root. Split from `remote-session-scanner-sources.ts` so adding an
|
||||
* agent does not push that module past its line budget. */
|
||||
export function remoteCodexSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
joinRemotePath(hostPlatform, remoteHome, '.codex'),
|
||||
joinRemotePath(
|
||||
hostPlatform,
|
||||
remoteHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
].map((codexHome) => {
|
||||
const parse = (
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
context: RemoteScannerContext
|
||||
) =>
|
||||
parseCodexSessionContent({
|
||||
file,
|
||||
content,
|
||||
platform: context.hostPlatform.os,
|
||||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
signal: context.signal,
|
||||
readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context)
|
||||
})
|
||||
return {
|
||||
agent: 'codex',
|
||||
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
|
||||
codexHome,
|
||||
extensions: ['.jsonl'],
|
||||
parse,
|
||||
parseLines: parse
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
|
||||
// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and
|
||||
// ~/.local/share on posix ones.
|
||||
function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] {
|
||||
return hostPlatform.os === 'win32'
|
||||
? ['AppData', 'Roaming', 'devin', 'cli']
|
||||
: ['.local', 'share', 'devin', 'cli']
|
||||
}
|
||||
|
||||
export function remoteDevinSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
directory: 'transcripts' | 'agent_logs' = 'transcripts'
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'devin',
|
||||
rootDir: joinRemotePath(
|
||||
hostPlatform,
|
||||
remoteHome,
|
||||
...remoteDevinDataSegments(hostPlatform),
|
||||
directory
|
||||
),
|
||||
extensions: ['.json'],
|
||||
...remoteSessionDocumentParsers('devin'),
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(
|
||||
parseDevinSessionContent(file, content, context.hostPlatform.os, {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { extname } from 'node:path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import type { SessionSidecarObservation } from './session-sidecar-stat'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
const REMOTE_DISCOVERY_CONCURRENCY = 8
|
||||
|
||||
export async function discoverRemoteSourceCandidates(args: {
|
||||
source: RemoteSessionSource
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
if (args.source.discover) {
|
||||
const files = await args.source.discover(args.context, args.issues)
|
||||
throwIfAiVaultScanCancelled(args.context.signal)
|
||||
return files.map((file) => ({ source: args.source, file }))
|
||||
}
|
||||
const walked = args.source.fixedChildFileSegments
|
||||
? await listRemoteFixedChildFiles(args.source, args.context, args.issues)
|
||||
: await walkRemoteSessionFiles(args.source, args.context, args.issues)
|
||||
const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null
|
||||
const paths = partition ? partition.sessionFilePaths : walked
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({
|
||||
source: args.source,
|
||||
file,
|
||||
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
|
||||
}))
|
||||
}
|
||||
|
||||
async function statRemoteCandidateFile(
|
||||
path: string,
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
const file = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
path,
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{
|
||||
missingIsExpected: Boolean(source.fixedChildFileSegments),
|
||||
signal: context.signal
|
||||
}
|
||||
)
|
||||
if (!file || !source.contentDependencyPath) {
|
||||
return file
|
||||
}
|
||||
const sidecarPath = source.contentDependencyPath(path)
|
||||
// Recorded beside the transcript's own stat, never folded into it: one key
|
||||
// cannot mean both "the transcript grew" and "the sibling changed".
|
||||
return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stat that failed for any reason other than a missing path is `'unknown'`,
|
||||
* not `'none'`: serving the cached session over an unreadable sibling would
|
||||
* publish metadata nobody can currently see. `statRemoteSessionFile` already
|
||||
* recorded the issue for the failure.
|
||||
*/
|
||||
async function observeRemoteSidecar(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
sidecarPath: string,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionSidecarObservation> {
|
||||
try {
|
||||
const sidecar = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
sidecarPath,
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{ missingIsExpected: true, signal: context.signal, rethrowFailures: true }
|
||||
)
|
||||
return sidecar
|
||||
? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 }
|
||||
: 'none'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteFixedChildFiles(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(source.rootDir)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
|
||||
return []
|
||||
}
|
||||
const segments = source.fixedChildFileSegments ?? []
|
||||
// Why: Antigravity's transcript path is fixed. Constructing it avoids three
|
||||
// serialized SSH readDir round trips for every conversation directory.
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory && !entry.isSymlink)
|
||||
.flatMap((entry) =>
|
||||
[segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) =>
|
||||
joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments)
|
||||
)
|
||||
)
|
||||
.filter((path) => source.filePredicate?.(path) ?? true)
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[],
|
||||
dirPath = source.rootDir,
|
||||
depth = 0
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
let entries
|
||||
try {
|
||||
entries = await context.provider.readDir(dirPath)
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
|
||||
return []
|
||||
}
|
||||
|
||||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
|
||||
if (
|
||||
entry.isDirectory &&
|
||||
!entry.isSymlink &&
|
||||
(source.directoryPredicate?.(entry.name, depth) ?? true)
|
||||
) {
|
||||
files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1)))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!entry.isSymlink &&
|
||||
extensions.has(extname(entry.name).toLowerCase()) &&
|
||||
(source.filePredicate?.(fullPath) ?? true)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function recordRemoteDirectoryIssue(
|
||||
source: RemoteSessionSource,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[],
|
||||
path: string,
|
||||
err: unknown
|
||||
): void {
|
||||
if (!isMissingRemoteSessionPathError(err)) {
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId,
|
||||
agent: source.agent,
|
||||
kind: 'host',
|
||||
path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import {
|
||||
remoteSessionContentLines,
|
||||
streamedSessionContentLines
|
||||
} from './remote-session-content-lines'
|
||||
import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import {
|
||||
isKiroSessionMetadataPath,
|
||||
kiroTranscriptPathForMetadata,
|
||||
parseKiroSessionContent
|
||||
} from './session-scanner-kiro-parser'
|
||||
|
||||
async function readTranscriptLines(
|
||||
metadataPath: string,
|
||||
context: RemoteScannerContext
|
||||
): Promise<Iterable<string> | AsyncIterable<string> | null> {
|
||||
const transcriptPath = kiroTranscriptPathForMetadata(metadataPath)
|
||||
// Why: beside the execution host the transcript streams; megabyte tool outputs are common.
|
||||
if (context.provider.readTranscriptBytes) {
|
||||
return streamedSessionContentLines(
|
||||
context.provider.readTranscriptBytes(transcriptPath, context.signal),
|
||||
context.signal
|
||||
)
|
||||
}
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(transcriptPath)
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
return read.isBinary ? null : remoteSessionContentLines(read.content, context.signal)
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (isMissingRemoteSessionPathError(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteKiroSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
kiroHomeDir?: string
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'kiro',
|
||||
rootDir: kiroHomeDir
|
||||
? joinRemotePath(hostPlatform, kiroHomeDir, 'sessions', 'cli')
|
||||
: joinRemotePath(hostPlatform, remoteHome, '.kiro', 'sessions', 'cli'),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isKiroSessionMetadataPath,
|
||||
contentDependencyPath: kiroTranscriptPathForMetadata,
|
||||
directoryPredicate: () => false,
|
||||
parse: async (file, content, context) => {
|
||||
const options = {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
try {
|
||||
const lines = await readTranscriptLines(file.path, context)
|
||||
return await parseKiroSessionContent(file, content, lines, context.hostPlatform.os, options)
|
||||
} catch (error) {
|
||||
// A transcript that vanished mid-stream leaves a valid metadata-only session.
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
return parseKiroSessionContent(file, content, null, context.hostPlatform.os, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('remote Kiro history', () => {
|
||||
it.each([
|
||||
['linux-x64', '/home/ada', '/srv/kiro'],
|
||||
['win32-x64', 'C:/Users/Ada', 'D:/kiro']
|
||||
] as const)(
|
||||
'reads the %s execution host override, independent of the client',
|
||||
async (platform, remoteHome, kiroHomeDir) => {
|
||||
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const path = `${kiroHomeDir}/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
|
||||
provider.addFile(path, JSON.stringify({ cwd: remoteHome, title: 'Remote Kiro' }), 10)
|
||||
provider.addFile(
|
||||
`${path}l`,
|
||||
jsonLines([
|
||||
{ kind: 'Prompt', data: { content: [{ kind: 'text', data: 'Read the file' }] } },
|
||||
{ kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done' }] } }
|
||||
]),
|
||||
11
|
||||
)
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:kiro-host',
|
||||
remoteHome,
|
||||
kiroHomeDir,
|
||||
hostPlatform: getRemoteHostPlatform(platform)
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'kiro',
|
||||
sessionId: KIRO_FIXTURE_SESSION_ID,
|
||||
filePath: path,
|
||||
messageCount: 2,
|
||||
executionHostId: 'ssh:kiro-host'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the remote default when the peer supplies no override', async () => {
|
||||
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const path = `/home/ada/.kiro/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
|
||||
provider.addFile(path, JSON.stringify({ cwd: '/home/ada', title: 'Remote Kiro' }), 10)
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:kiro-host',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.filePath).toBe(path)
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions muse', () => {
|
||||
it('discovers Muse transcripts under the remote XDG sessions root', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote'
|
||||
provider.addFile(
|
||||
`${sessionDir}/session.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.session.metadata',
|
||||
recorded_at: 1780000000000000,
|
||||
payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } }
|
||||
},
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.user_intent.accepted',
|
||||
recorded_at: 1780000001000000,
|
||||
payload: {
|
||||
intent_id: 'intent-remote',
|
||||
refill_blocks: [{ kind: 'text', text: 'Remote muse title' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.session',
|
||||
recorded_at: 1780000002000000,
|
||||
payload: {
|
||||
kind: 'run',
|
||||
run_id: 'run-remote',
|
||||
event: {
|
||||
kind: 'model_completed',
|
||||
model: 'muse-spark-remote',
|
||||
usage: { input_tokens: 3, output_tokens: 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
// Sidecars next to the transcript must not list as sessions.
|
||||
provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'muse',
|
||||
sessionId: 'muse-remote',
|
||||
title: 'Remote muse title',
|
||||
model: 'muse-spark-remote',
|
||||
filePath: `${sessionDir}/session.jsonl`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => {
|
||||
it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => {
|
||||
const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000'
|
||||
const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0'
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
`${workspaceDir}/${stem}.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:00:01.000Z',
|
||||
message: { role: 'user', content: 'Delegate some tasks' }
|
||||
}
|
||||
]),
|
||||
70
|
||||
)
|
||||
for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) {
|
||||
provider.addFile(
|
||||
`${workspaceDir}/${stem}/${label}.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:01:00.000Z',
|
||||
message: { role: 'user', content: `${label} task prompt` }
|
||||
}
|
||||
]),
|
||||
71
|
||||
)
|
||||
}
|
||||
// A grandchild belongs to its own parent: neither a row nor a count here.
|
||||
provider.addFile(
|
||||
`${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:02:00.000Z',
|
||||
message: { role: 'user', content: 'Nested task prompt' }
|
||||
}
|
||||
]),
|
||||
72
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'omp',
|
||||
sessionId: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
subagentTranscriptCount: 2,
|
||||
filePath: `${workspaceDir}/${stem}.jsonl`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
|
||||
import { remotePathSegments } from './remote-session-scanner-source-parsers'
|
||||
import type {
|
||||
RemoteOpenCodeSessionReader,
|
||||
RemoteScannerContext,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { restampAiVaultListResult } from './session-list-results'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
export function remoteOpenCodeSources(
|
||||
reader: RemoteOpenCodeSessionReader | undefined,
|
||||
candidateLimit: number
|
||||
): RemoteSessionSource[] {
|
||||
if (!reader) {
|
||||
return []
|
||||
}
|
||||
let databasePaths: Promise<string[]> | undefined
|
||||
return (['opencode', 'opencode2'] as const).map((agent) => {
|
||||
const source: RemoteSessionSource = {
|
||||
agent,
|
||||
rootDir: reader.dataDirectory,
|
||||
extensions: ['.json'],
|
||||
parse: async () => null,
|
||||
parseCandidate: async (file, context) => {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const candidate = splitOpenCodeSqliteCandidate(file.path)
|
||||
const session = candidate
|
||||
? await reader.parse({
|
||||
...candidate,
|
||||
platform: context.hostPlatform.os,
|
||||
...(agent === 'opencode2' ? { agent } : {}),
|
||||
signal: context.signal
|
||||
})
|
||||
: await reader.parseLegacy(file, context.hostPlatform.os)
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
const stamped = restampAiVaultListResult(
|
||||
{ sessions: [session], issues: [], scannedAt: '' },
|
||||
context.executionHostId
|
||||
).sessions[0]
|
||||
return stamped ? { ...stamped, executionHostPlatform: context.hostPlatform.os } : null
|
||||
},
|
||||
discover: async (context, issues) => {
|
||||
databasePaths ??= discoverDatabasePaths(reader, context, issues)
|
||||
const paths = await databasePaths
|
||||
const ownPaths =
|
||||
agent === 'opencode2'
|
||||
? paths
|
||||
: paths.filter(
|
||||
(path) => !isOpenCodeV2DatabaseName(remotePathSegments(path).at(-1) ?? '')
|
||||
)
|
||||
const readIssues: AiVaultScanIssue[] = []
|
||||
const candidates = await reader.list({
|
||||
dbPaths: ownPaths,
|
||||
limit: candidateLimit,
|
||||
issues: readIssues,
|
||||
...(agent === 'opencode2' ? { agent } : {}),
|
||||
signal: context.signal
|
||||
})
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
for (const issue of readIssues) {
|
||||
recordSessionScanIssue(issues, { ...issue, executionHostId: context.executionHostId })
|
||||
}
|
||||
const files = candidates.map((candidate) => candidate.file)
|
||||
if (agent === 'opencode2') {
|
||||
return files
|
||||
}
|
||||
const legacy = await discoverRemoteSourceCandidates({
|
||||
source: {
|
||||
...source,
|
||||
discover: undefined,
|
||||
rootDir: joinRemotePath(
|
||||
context.hostPlatform,
|
||||
reader.dataDirectory,
|
||||
'storage',
|
||||
'session'
|
||||
)
|
||||
},
|
||||
context,
|
||||
issues
|
||||
})
|
||||
const ids = new Set(files.map((file) => splitOpenCodeSqliteCandidate(file.path)?.sessionId))
|
||||
return [
|
||||
...files,
|
||||
...legacy
|
||||
.filter(
|
||||
({ file }) =>
|
||||
!ids.has(
|
||||
remotePathSegments(file.path)
|
||||
.at(-1)
|
||||
?.replace(/\.json$/, '')
|
||||
)
|
||||
)
|
||||
.map(({ file }) => file)
|
||||
]
|
||||
}
|
||||
}
|
||||
return source
|
||||
})
|
||||
}
|
||||
|
||||
async function discoverDatabasePaths(
|
||||
reader: RemoteOpenCodeSessionReader,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<string[]> {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (reader.databasePath === null) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
if (reader.databasePath !== undefined) {
|
||||
const stat = await context.provider.stat(reader.databasePath)
|
||||
return stat.type === 'file' || stat.type === 'symlink' ? [reader.databasePath] : []
|
||||
}
|
||||
const entries = await context.provider.readDir(reader.dataDirectory)
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
!entry.isDirectory &&
|
||||
!entry.isSymlink &&
|
||||
/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)
|
||||
)
|
||||
.map((entry) => joinRemotePath(context.hostPlatform, reader.dataDirectory, entry.name))
|
||||
.sort()
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId: context.executionHostId,
|
||||
agent: 'opencode',
|
||||
kind: 'scope',
|
||||
path: reader.databasePath ?? reader.dataDirectory,
|
||||
message: errorMessage(error)
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import type { RemoteOpenCodeSessionReader } from './remote-session-scanner-types'
|
||||
import { createAccumulator, finalizeSession, updateTimeline } from './session-scanner-accumulator'
|
||||
import {
|
||||
buildOpenCodeSqliteCandidatePath,
|
||||
splitOpenCodeSqliteCandidate
|
||||
} from './session-scanner-opencode-sqlite-paths'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
|
||||
const remoteHome = '/home/ada'
|
||||
const dataDirectory = `${remoteHome}/.local/share/opencode`
|
||||
const hostPlatform = getRemoteHostPlatform('linux-x64')
|
||||
const scanOptions = { remoteHome, hostPlatform, executionHostId: 'ssh:one' as const }
|
||||
|
||||
beforeEach(resetRemoteSessionParseCacheForTests)
|
||||
|
||||
function fixture() {
|
||||
const sessions = new Map<string, AiVaultSession>()
|
||||
const list: RemoteOpenCodeSessionReader['list'] = vi.fn(async (args) =>
|
||||
[...sessions.values()]
|
||||
.filter(
|
||||
(session) =>
|
||||
session.agent === (args.agent ?? 'opencode') && args.dbPaths.includes(session.filePath)
|
||||
)
|
||||
.sort((left, right) => Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt))
|
||||
.slice(0, args.limit)
|
||||
.map((session) => ({
|
||||
agent: session.agent,
|
||||
codexHome: null,
|
||||
file: file(
|
||||
buildOpenCodeSqliteCandidatePath(session.filePath, session.sessionId),
|
||||
Date.parse(session.modifiedAt)
|
||||
)
|
||||
}))
|
||||
)
|
||||
const parse: RemoteOpenCodeSessionReader['parse'] = vi.fn(
|
||||
async (args) =>
|
||||
sessions.get(
|
||||
`${args.agent ?? 'opencode'}:${buildOpenCodeSqliteCandidatePath(args.dbPath, args.sessionId)}`
|
||||
) ?? null
|
||||
)
|
||||
const parseLegacy = vi.fn(async (entry: FileWithMtime) =>
|
||||
session('opencode', 'legacy', entry, '/home/ada/repo')
|
||||
)
|
||||
const provider = Object.assign(new MemoryRemoteProvider(), {
|
||||
openCode: { dataDirectory, list, parse, parseLegacy }
|
||||
})
|
||||
const add = (
|
||||
id: string,
|
||||
mtime: number,
|
||||
options: { agent?: 'opencode' | 'opencode2'; cwd?: string; dbName?: string } = {}
|
||||
) => {
|
||||
const agent = options.agent ?? 'opencode'
|
||||
const dbPath = `${dataDirectory}/${options.dbName ?? 'opencode.db'}`
|
||||
provider.addFile(dbPath, 'database', 1)
|
||||
const path = buildOpenCodeSqliteCandidatePath(dbPath, id)
|
||||
sessions.set(
|
||||
`${agent}:${path}`,
|
||||
session(agent, id, file(path, mtime), options.cwd ?? '/home/ada/other')
|
||||
)
|
||||
}
|
||||
return { provider, add, list, parse, parseLegacy }
|
||||
}
|
||||
|
||||
function file(path: string, mtimeMs: number): FileWithMtime {
|
||||
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
|
||||
}
|
||||
|
||||
function session(
|
||||
agent: 'opencode' | 'opencode2',
|
||||
sessionId: string,
|
||||
entry: FileWithMtime,
|
||||
cwd: string
|
||||
): AiVaultSession {
|
||||
const accumulator = createAccumulator({
|
||||
agent,
|
||||
file: { ...entry, path: splitOpenCodeSqliteCandidate(entry.path)?.dbPath ?? entry.path },
|
||||
sessionId
|
||||
})
|
||||
accumulator.cwd = cwd
|
||||
updateTimeline(accumulator, entry.modifiedAt)
|
||||
const parsed = finalizeSession(accumulator, 'linux')
|
||||
if (!parsed) {
|
||||
throw new Error('Invalid fixture session')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
describe('remote OpenCode discovery', () => {
|
||||
it('sorts database rows with file-backed agents before applying the global cap', async () => {
|
||||
const { provider, add } = fixture()
|
||||
add('older', 1000)
|
||||
add('newest', 3000)
|
||||
provider.addFile(
|
||||
`${remoteHome}/.claude/projects/project/middle.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'middle',
|
||||
timestamp: new Date(2000).toISOString(),
|
||||
type: 'user',
|
||||
message: { content: 'middle' }
|
||||
}
|
||||
]),
|
||||
2000
|
||||
)
|
||||
const read = vi.spyOn(provider, 'readFile')
|
||||
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 2 })
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['newest', 'middle'])
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:one',
|
||||
executionHostPlatform: 'linux',
|
||||
id: `ssh:one:opencode:newest:${dataDirectory}/opencode.db`
|
||||
})
|
||||
expect(read.mock.calls.every(([path]) => !path.includes('.db#'))).toBe(true)
|
||||
})
|
||||
|
||||
it('retains older scoped rows beyond the recent parse budget', async () => {
|
||||
const { provider, add, list } = fixture()
|
||||
for (let index = 0; index < 10; index++) {
|
||||
add(`other-${index}`, 10000 + index)
|
||||
}
|
||||
add('scoped', 1000, { cwd: '/home/ada/repo/app' })
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
...scanOptions,
|
||||
provider,
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['other-9', 'scoped'])
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ limit: 1003 }))
|
||||
})
|
||||
|
||||
it('drops stale legacy duplicates before limiting while keeping legacy-only history', async () => {
|
||||
const { provider, add, parseLegacy } = fixture()
|
||||
add('migrated', 2000)
|
||||
provider.addFile(`${dataDirectory}/storage/session/project/migrated.json`, '{}', 9000)
|
||||
provider.addFile(`${dataDirectory}/storage/session/project/legacy.json`, '{}', 1000)
|
||||
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 10 })
|
||||
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['migrated', 'legacy'])
|
||||
expect(parseLegacy).toHaveBeenCalledTimes(1)
|
||||
expect(parseLegacy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: expect.stringContaining('/legacy.json') }),
|
||||
'linux'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps v1 and v2 identities separate in a shared database and parse cache', async () => {
|
||||
const { provider, add, parse, list } = fixture()
|
||||
add('same-id', 2000)
|
||||
add('same-id', 2000, { agent: 'opencode2' })
|
||||
add('beta', 1000, { agent: 'opencode2', dbName: 'opencode-next.db' })
|
||||
const first = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
const second = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
expect(first.sessions.map((entry) => entry.agent).sort()).toEqual([
|
||||
'opencode',
|
||||
'opencode2',
|
||||
'opencode2'
|
||||
])
|
||||
expect(second.sessions).toEqual(first.sessions)
|
||||
expect(parse).toHaveBeenCalledTimes(3)
|
||||
expect(list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dbPaths: [`${dataDirectory}/opencode.db`] })
|
||||
)
|
||||
expect(list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dbPaths: [`${dataDirectory}/opencode-next.db`, `${dataDirectory}/opencode.db`],
|
||||
agent: 'opencode2'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidates cached database rows after updates and across hosts', async () => {
|
||||
const { provider, add, parse } = fixture()
|
||||
add('session', 1000)
|
||||
await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
add('session', 2000)
|
||||
const updated = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
const other = await scanRemoteAiVaultSessions({
|
||||
...scanOptions,
|
||||
executionHostId: 'ssh:two',
|
||||
provider
|
||||
})
|
||||
expect(parse).toHaveBeenCalledTimes(3)
|
||||
expect(updated.sessions[0]?.modifiedAt).toBe(new Date(2000).toISOString())
|
||||
expect(other.sessions[0]?.executionHostId).toBe('ssh:two')
|
||||
})
|
||||
|
||||
it('propagates cancellation into the reader and does not cache cancelled parses', async () => {
|
||||
const { provider, add, parse } = fixture()
|
||||
add('session', 1000)
|
||||
const controller = new AbortController()
|
||||
provider.openCode.parse = async (args) => {
|
||||
expect(args.signal).toBe(controller.signal)
|
||||
controller.abort()
|
||||
return parse(args)
|
||||
}
|
||||
await expect(
|
||||
scanRemoteAiVaultSessions({ ...scanOptions, provider, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
provider.openCode.parse = parse
|
||||
await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
expect(parse).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves legacy filesystem providers on their existing fallback', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(`${dataDirectory}/opencode.db`, 'database', 1000)
|
||||
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([])
|
||||
expect(provider.readDirPaths).not.toContain(dataDirectory)
|
||||
})
|
||||
|
||||
it('honors an explicitly configured database symlink without enumerating sibling databases', async () => {
|
||||
const { provider, add, list } = fixture()
|
||||
add('selected', 1000, { dbName: 'opencode-team.db' })
|
||||
add('other', 2000)
|
||||
const selected = `${dataDirectory}/opencode-team.db`
|
||||
Object.assign(provider.openCode, { databasePath: selected })
|
||||
const stat = provider.stat.bind(provider)
|
||||
vi.spyOn(provider, 'stat').mockImplementation(async (path) =>
|
||||
path === selected ? { type: 'symlink', size: 20, mtime: 1000, mtimeMs: 1000 } : stat(path)
|
||||
)
|
||||
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
|
||||
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['selected'])
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ dbPaths: [selected] }))
|
||||
expect(provider.readDirPaths).not.toContain(dataDirectory)
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseMuseSessionContent,
|
||||
parseMuseSessionRemoteContent
|
||||
} from './session-scanner-muse-parser'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import type { RemoteSessionContent } from './remote-session-content-lines'
|
||||
import type { RemoteParserOptions } from './remote-session-scanner-types'
|
||||
|
||||
export function parseMuseRemoteContent(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (typeof content === 'string') {
|
||||
return Promise.resolve(parseMuseSessionContent(file, content, platform, options))
|
||||
}
|
||||
return parseMuseSessionRemoteContent(file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function piParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function primeAgentParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
export function remotePiSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
export function remoteOmpSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
// Remote roots are POSIX regardless of the client platform.
|
||||
export function remotePrimeAgentSessionsSegments(): string[] {
|
||||
return ['.prime', 'agent', 'sessions']
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import type { RelayPlatform } from '../ssh/relay-protocol'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
|
||||
function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] {
|
||||
const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform)
|
||||
return remoteSessionSources(remoteHome, hostPlatform)
|
||||
.filter((source) => source.agent === 'devin')
|
||||
.map((source) => source.rootDir)
|
||||
}
|
||||
|
||||
describe('remoteSessionSources devin transcripts root', () => {
|
||||
it.each([
|
||||
{
|
||||
relayPlatform: 'win32-x64' as const,
|
||||
remoteHome: 'C:/Users/dev',
|
||||
expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts'
|
||||
},
|
||||
{
|
||||
relayPlatform: 'linux-x64' as const,
|
||||
remoteHome: '/home/dev',
|
||||
expected: '/home/dev/.local/share/devin/cli/transcripts'
|
||||
},
|
||||
{
|
||||
relayPlatform: 'darwin-arm64' as const,
|
||||
remoteHome: '/Users/dev',
|
||||
expected: '/Users/dev/.local/share/devin/cli/transcripts'
|
||||
}
|
||||
])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => {
|
||||
expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([
|
||||
expected,
|
||||
expected.replace(/transcripts$/, 'agent_logs')
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,286 +0,0 @@
|
||||
import {
|
||||
ANTIGRAVITY_HISTORY_ROOTS,
|
||||
type AntigravitySessionOrigin
|
||||
} from '../../shared/antigravity-session-origin'
|
||||
import { parseQoderSessionContent } from './session-scanner-qoder-parser'
|
||||
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
|
||||
import type { RemoteSessionContent } from './remote-session-content-lines'
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser'
|
||||
import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
|
||||
import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser'
|
||||
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
|
||||
import { parseCopilotSessionContent } from './session-scanner-copilot-parser'
|
||||
import { parseCursorSessionContent } from './session-scanner-cursor-parser'
|
||||
import { parseHermesSessionContent } from './session-scanner-hermes-parser'
|
||||
import { parseJcodeSessionContent } from './session-scanner-jcode-parser'
|
||||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts'
|
||||
import {
|
||||
ompParser,
|
||||
openClawParser,
|
||||
parseMuseRemoteContent,
|
||||
piParser,
|
||||
primeAgentParser,
|
||||
remoteOmpSessionsSegments,
|
||||
remotePathSegments,
|
||||
remotePiSessionsSegments,
|
||||
remotePrimeAgentSessionsSegments
|
||||
} from './remote-session-scanner-source-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { openClawSessionDirectoryPredicate } from './openclaw-session-layout'
|
||||
import { remoteCodexSources } from './remote-session-scanner-codex-sources'
|
||||
import { remoteClineSource } from './remote-session-scanner-cline-source'
|
||||
import { remoteKiroSource } from './remote-session-scanner-kiro-source'
|
||||
import { remoteDevinSource } from './remote-session-scanner-devin-source'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
type RemoteContentParser<T = string> = (
|
||||
file: FileWithMtime,
|
||||
content: T,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
// Line-based parsers iterate cancellably; whole-document parsers ignore it.
|
||||
signal?: AbortSignal
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
includeAntigravityIdeSessions = false,
|
||||
kiroHomeDir?: string
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
{
|
||||
...jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
// The remote host owns the transcript disk, so the local readdir in the
|
||||
// Claude parser is skipped; the walked listing supplies the sibling
|
||||
// subagent counts instead. Partitioning also prunes the subagent
|
||||
// transcripts themselves, which would otherwise list as phantom
|
||||
// top-level sessions carrying the parent's sessionId.
|
||||
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
|
||||
},
|
||||
{
|
||||
// The execution host owns CodeBuddy's transcripts, including subagent pruning.
|
||||
...jsonlSource(
|
||||
'codebuddy',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.codebuddy', 'projects'],
|
||||
parseCodebuddySessionContent
|
||||
),
|
||||
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
|
||||
},
|
||||
{
|
||||
...jsonlSource(
|
||||
'qoder',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.qoder', 'projects'],
|
||||
parseQoderSessionContent
|
||||
),
|
||||
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
|
||||
},
|
||||
...(includeAntigravityIdeSessions
|
||||
? ANTIGRAVITY_HISTORY_ROOTS
|
||||
: (['antigravity-cli'] as const)
|
||||
).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)),
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.gemini', 'tmp'],
|
||||
['.json', '.jsonl'],
|
||||
parseGeminiSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'copilot',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.copilot', 'session-state'],
|
||||
parseCopilotSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'cursor',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.cursor', 'projects'],
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
remoteClineSource(remoteHome, hostPlatform),
|
||||
remoteKiroSource(remoteHome, hostPlatform, kiroHomeDir),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.hermes', 'sessions'],
|
||||
['.json'],
|
||||
parseHermesSessionContent
|
||||
),
|
||||
remoteDevinSource(remoteHome, hostPlatform),
|
||||
remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'),
|
||||
source(
|
||||
'jcode',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.jcode', 'sessions'],
|
||||
['.json'],
|
||||
parseJcodeSessionContent,
|
||||
(path) => remotePathSegments(path).pop()?.startsWith('session_') === true
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
{
|
||||
...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
|
||||
// Same posture as Claude above: OMP stores task-subagent transcripts in
|
||||
// the session's same-named artifact dir; the walk supplies counts and the
|
||||
// partition keeps the children out of the top-level list (#9330).
|
||||
partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths
|
||||
},
|
||||
jsonlSource(
|
||||
'prime-agent',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
remotePrimeAgentSessionsSegments(),
|
||||
primeAgentParser
|
||||
),
|
||||
jsonlSource(
|
||||
'muse',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.local', 'share', 'muse', 'sessions'],
|
||||
parseMuseRemoteContent,
|
||||
// Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars;
|
||||
// match only the transcript (same predicate as local discovery).
|
||||
(path) => remotePathSegments(path).at(-1) === 'session.jsonl'
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'sessions'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'projects'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
...remoteOpenClawSources(remoteHome, hostPlatform)
|
||||
]
|
||||
}
|
||||
|
||||
function remoteAntigravitySource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
origin: AntigravitySessionOrigin
|
||||
): RemoteSessionSource {
|
||||
const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin)
|
||||
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
|
||||
const parse = async (
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
context: RemoteScannerContext
|
||||
) => {
|
||||
const session = await parseAntigravitySessionContent(
|
||||
file,
|
||||
content,
|
||||
context.hostPlatform.os,
|
||||
parserOptions(context),
|
||||
context.signal
|
||||
)
|
||||
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
|
||||
}
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'),
|
||||
extensions: ['.jsonl'],
|
||||
filePredicate: isAntigravityTranscriptPath,
|
||||
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
|
||||
additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']],
|
||||
parse,
|
||||
parseLines: parse
|
||||
}
|
||||
}
|
||||
|
||||
function source(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
extensions: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean,
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent,
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
|
||||
extensions,
|
||||
filePredicate,
|
||||
directoryPredicate,
|
||||
...remoteSessionDocumentParsers(agent),
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(
|
||||
parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function jsonlSource(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
parseContent: RemoteContentParser<RemoteSessionContent>,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate),
|
||||
parseLines: (file, lines, context) =>
|
||||
Promise.resolve(
|
||||
parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function remoteOpenClawSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return ['.openclaw', '.clawdbot'].map((rootName) => ({
|
||||
...jsonlSource(
|
||||
'openclaw',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
[rootName, 'agents'],
|
||||
openClawParser,
|
||||
(path) => remotePathSegments(path).includes('sessions')
|
||||
),
|
||||
directoryPredicate: openClawSessionDirectoryPredicate
|
||||
}))
|
||||
}
|
||||
|
||||
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
|
||||
return {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
}
|
||||
@@ -1,823 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix remote Cline history' }]
|
||||
}
|
||||
]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
title: 'Fix remote Cline history',
|
||||
cwd: '/home/ada/repo',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
filePath: `${sessionDir}/${sessionId}.json`
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: 'C:/repo/app'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
executionHostPlatform: 'win32',
|
||||
filePath: `${sessionDir}/${sessionId}.json`,
|
||||
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
|
||||
})
|
||||
})
|
||||
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
|
||||
1
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'default-session', cwd: '/home/ada/repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fallback default title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Managed remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
20
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
unlimited: true
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Managed remote title',
|
||||
'Indexed remote title'
|
||||
])
|
||||
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
|
||||
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
|
||||
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'default-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.codex',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
|
||||
})
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'runtime-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses a bridged rollout present in both remote Codex homes to one row', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
|
||||
const transcript = codexTranscript({
|
||||
sessionId: '019f0000-1111-7222-8333-444444444444',
|
||||
title: 'Bridged both-homes session',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T10:00:00.000Z'
|
||||
})
|
||||
// Same rollout name in both homes — the in-distro bridge/backfill hardlink.
|
||||
provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000)
|
||||
provider.addFile(
|
||||
`/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`,
|
||||
transcript,
|
||||
3_000
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:build-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
// Remote lanes have not flipped to the real home: the managed runtime-home
|
||||
// row stays canonical so resume keeps Orca-refreshed auth, as today.
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: '019f0000-1111-7222-8333-444444444444',
|
||||
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses non-Codex transcripts through the same remote scanner', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
|
||||
},
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { model: 'claude-opus-4', content: 'Sure.' }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
title: 'Summarize the remote branch',
|
||||
model: 'claude-opus-4',
|
||||
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers Prime Agent transcripts under the remote home sessions root', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const fixture = primeAgentFixture()
|
||||
provider.addFile(
|
||||
`/home/ada/.prime/agent/sessions/${fixture.fileName}`,
|
||||
[...fixture.seedLines, ...fixture.appendLines].join('\n'),
|
||||
40
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'prime-agent',
|
||||
title: 'prime-agent seed question',
|
||||
model: 'inference/big-model',
|
||||
filePath: `/home/ada/.prime/agent/sessions/${fixture.fileName}`
|
||||
})
|
||||
})
|
||||
|
||||
it('parses only canonical Antigravity transcripts on SSH hosts', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs`
|
||||
provider.addFile(
|
||||
`${logsDir}/transcript.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
source: 'USER_EXPLICIT',
|
||||
type: 'USER_INPUT',
|
||||
created_at: '2026-07-15T11:39:10Z',
|
||||
content: '<USER_REQUEST>Fix remote Antigravity history</USER_REQUEST>'
|
||||
},
|
||||
{
|
||||
source: 'MODEL',
|
||||
type: 'PLANNER_RESPONSE',
|
||||
created_at: '2026-07-15T11:39:12Z',
|
||||
content: 'Done'
|
||||
}
|
||||
]),
|
||||
50
|
||||
)
|
||||
provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51)
|
||||
provider.addFile(
|
||||
`/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`,
|
||||
'not a transcript',
|
||||
52
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.gemini/antigravity-cli/history.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
display: 'Fix remote Antigravity history',
|
||||
timestamp: Date.parse('2026-07-15T11:39:10.100Z'),
|
||||
workspace: '/home/ada/project'
|
||||
}
|
||||
]),
|
||||
53
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scopePaths: ['/home/ada/project']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'antigravity',
|
||||
sessionId,
|
||||
title: 'Fix remote Antigravity history',
|
||||
cwd: '/home/ada/project',
|
||||
messageCount: 2,
|
||||
resumeCommand: `agy --conversation '${sessionId}'`,
|
||||
filePath: `${logsDir}/transcript.jsonl`
|
||||
})
|
||||
expect(
|
||||
provider.readDirPaths.filter((path) =>
|
||||
path.startsWith('/home/ada/.gemini/antigravity-cli/brain')
|
||||
)
|
||||
).toEqual(['/home/ada/.gemini/antigravity-cli/brain'])
|
||||
})
|
||||
|
||||
it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
|
||||
provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1)
|
||||
const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl`
|
||||
provider.addFile(deniedTranscript, 'unreadable', 2)
|
||||
provider.failStat(
|
||||
deniedTranscript,
|
||||
new Error(`EACCES: permission denied, stat '${deniedTranscript}'`)
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
agent: 'antigravity',
|
||||
path: deniedTranscript,
|
||||
message: expect.stringContaining('EACCES')
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('reports non-missing fixed and recursive remote directory failures', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
|
||||
const claudeProjectDir = '/home/ada/.claude/projects/repo'
|
||||
provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1)
|
||||
provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`))
|
||||
provider.failReadDir(
|
||||
claudeProjectDir,
|
||||
new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`)
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agent: 'antigravity',
|
||||
kind: 'host',
|
||||
path: brainDir,
|
||||
message: expect.stringContaining('EACCES')
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
kind: 'host',
|
||||
path: claudeProjectDir,
|
||||
message: expect.stringContaining('ECONNRESET')
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(result.issues).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps missing optional remote directories silent', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
|
||||
provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`))
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes Claude subagent transcripts from remote scans', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Spawn a Task' }] }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
isSidechain: true,
|
||||
agentId: 'abc123',
|
||||
timestamp: '2026-07-04T04:01:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Subagent task prompt' }] }
|
||||
}
|
||||
]),
|
||||
41
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Only the parent session surfaces; the subagent transcript would carry
|
||||
// the parent's sessionId and list as a phantom top-level row.
|
||||
expect(result.sessions.map((session) => session.filePath)).toEqual([
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
])
|
||||
})
|
||||
|
||||
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session.jsonl',
|
||||
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
|
||||
51
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
|
||||
52
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Subagent transcripts must not surface as standalone sessions; they only
|
||||
// contribute recoverable signal to their zero-turn parent.
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'claude',
|
||||
sessionId: 'lost-session',
|
||||
messageCount: 0,
|
||||
subagentTranscriptCount: 2,
|
||||
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('counts remote subagent siblings for Claude sessions with real turns', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'live-session',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Do the thing' }] }
|
||||
}
|
||||
]),
|
||||
60
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
|
||||
61
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
// The walk listing supplies the count for every session (the row badge),
|
||||
// not only zero-turn recoverable ones.
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'live-session',
|
||||
messageCount: 1,
|
||||
subagentTranscriptCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'C:/Users/Ada/.codex/sessions/win.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'win-session', cwd: 'C:/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Windows remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
|
||||
expect(result.sessions[0]?.resumeCommand).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads Antigravity workspace history with Windows remote paths', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb'
|
||||
provider.addFile(
|
||||
`C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
source: 'USER_EXPLICIT',
|
||||
type: 'USER_INPUT',
|
||||
created_at: '2026-07-15T11:39:10.000Z',
|
||||
content: '<USER_REQUEST>Windows Antigravity title</USER_REQUEST>'
|
||||
}
|
||||
]),
|
||||
30
|
||||
)
|
||||
provider.addFile(
|
||||
'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
display: 'Windows Antigravity title',
|
||||
timestamp: Date.parse('2026-07-15T11:39:10.100Z'),
|
||||
workspace: 'C:/repo/app'
|
||||
}
|
||||
]),
|
||||
31
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'antigravity',
|
||||
sessionId,
|
||||
cwd: 'C:/repo/app',
|
||||
executionHostPlatform: 'win32'
|
||||
})
|
||||
})
|
||||
|
||||
it('continues past skipped candidates to fill the remote scan limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/worker.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'worker-session',
|
||||
title: 'Internal worker',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
threadSource: 'agent'
|
||||
}),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/user.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'user-session',
|
||||
title: 'Visible user session',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T03:00:00.000Z'
|
||||
}),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
|
||||
})
|
||||
|
||||
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo/app',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
for (const [sessionId, mtimeMs, hour] of [
|
||||
['other-newest', 50, '05'],
|
||||
['other-newer', 40, '04']
|
||||
] as const) {
|
||||
provider.addFile(
|
||||
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: `2026-07-04T${hour}:00:00.000Z`
|
||||
}),
|
||||
mtimeMs
|
||||
)
|
||||
}
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-newest',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
|
||||
it('caps scoped backfill at the requested limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
for (const [sessionId, mtimeMs] of [
|
||||
['newer-scoped', 30],
|
||||
['older-scoped', 20]
|
||||
] as const) {
|
||||
provider.addFile(
|
||||
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
|
||||
codexTranscript({
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z`
|
||||
}),
|
||||
mtimeMs
|
||||
)
|
||||
}
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'newer-scoped'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function codexTranscript(args: {
|
||||
sessionId: string
|
||||
title: string
|
||||
cwd: string
|
||||
timestamp: string
|
||||
threadSource?: string
|
||||
}): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp: args.timestamp,
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: args.sessionId,
|
||||
cwd: args.cwd,
|
||||
...(args.threadSource ? { thread_source: args.threadSource } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: args.title }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import {
|
||||
candidateFileTime,
|
||||
prioritizeAntigravityTranscriptCandidates
|
||||
} from './antigravity-transcript-candidates'
|
||||
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
|
||||
import { parseRemoteSessionTranscript } from './remote-session-transcript-read'
|
||||
import { BinarySessionTranscriptError } from './remote-session-content-lines'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
codexRolloutHardlinkIdentity,
|
||||
dedupeCodexRolloutFileAliases
|
||||
} from './codex-session-root-dedup'
|
||||
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
|
||||
import {
|
||||
parseRemoteSessionFileCached,
|
||||
remoteSessionCandidateKey,
|
||||
remoteSessionParseHostKey
|
||||
} from './remote-session-parse-cache'
|
||||
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
|
||||
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionFilesystemProvider
|
||||
} from './remote-session-scanner-types'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { mapRemoteScanBatches } from './remote-session-scan-batching'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import { canStopParsingSessions } from './session-scan-cutoff'
|
||||
import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
|
||||
import { remoteOpenCodeSources } from './remote-session-scanner-opencode-source'
|
||||
|
||||
const REMOTE_SCAN_CONCURRENCY = 8
|
||||
const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2
|
||||
// Remote scope membership is only known after a transcript is read, so the scope
|
||||
// backfill carries its own ceiling on remote reads instead of borrowing the
|
||||
// recency cap — under the cap, newer out-of-scope files ate the scope budget and
|
||||
// silently dropped older in-scope sessions the scope contract guarantees.
|
||||
const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000
|
||||
|
||||
export async function scanRemoteAiVaultSessions(args: {
|
||||
provider: RemoteSessionFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
kiroHomeDir?: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
scopePaths?: readonly string[]
|
||||
signal?: AbortSignal
|
||||
}): Promise<AiVaultListResult> {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
const limit = aiVaultScanLimit(args)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
// One ceiling for the whole scan: discovery walks, stats and transcript reads
|
||||
// all queue behind it instead of multiplying into a nested fan-out.
|
||||
const provider = limitRemoteScanFilesystemConcurrency(args.provider)
|
||||
const context: RemoteScannerContext = {
|
||||
provider,
|
||||
executionHostId: args.executionHostId,
|
||||
hostPlatform: args.hostPlatform,
|
||||
signal: args.signal,
|
||||
titleCaches: new Map(),
|
||||
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) =>
|
||||
readRemoteAntigravityIndex(provider, path, args.signal)
|
||||
)
|
||||
}
|
||||
const discoveredCandidates = dedupeCodexRolloutFileAliases(
|
||||
(
|
||||
await mapRemoteScanBatches(
|
||||
[
|
||||
...remoteSessionSources(
|
||||
args.remoteHome,
|
||||
args.hostPlatform,
|
||||
args.includeAntigravityIdeSessions,
|
||||
args.kiroHomeDir
|
||||
),
|
||||
...remoteOpenCodeSources(
|
||||
provider.openCode,
|
||||
limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER +
|
||||
(args.scopePaths?.length ? REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT + 1 : 0)
|
||||
)
|
||||
],
|
||||
REMOTE_SCAN_CONCURRENCY,
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues }),
|
||||
args.signal
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
|
||||
{
|
||||
isCodex: (candidate) => candidate.source.agent === 'codex',
|
||||
getFilePath: (candidate) => candidate.file.path,
|
||||
getCodexHome: (candidate) => candidate.source.codexHome ?? null,
|
||||
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
|
||||
}
|
||||
)
|
||||
|
||||
const candidates = prioritizeAntigravityTranscriptCandidates(
|
||||
discoveredCandidates,
|
||||
(candidate) => candidate.source.agent === 'antigravity'
|
||||
)
|
||||
const parsed = await parseRemoteSessionCandidates({
|
||||
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
|
||||
context,
|
||||
issues,
|
||||
limit
|
||||
})
|
||||
const parsedSessions = dedupeScannedSessions(parsed.sessions)
|
||||
const cappedSessions = parsedSessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
|
||||
const parsedScopeSessions = parsedSessions.filter((session) =>
|
||||
isRemoteSessionInScope(session, scopePaths)
|
||||
)
|
||||
const extraScopeSessions = await scanRemoteInScopeSessions({
|
||||
candidates,
|
||||
context,
|
||||
issues,
|
||||
scopePaths,
|
||||
limit,
|
||||
alreadyParsedCandidateKeys: parsed.parsedCandidateKeys
|
||||
})
|
||||
const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions])
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
|
||||
return {
|
||||
sessions: mergeRemoteSessions(cappedSessions, scopeSessions),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidates(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
limit: number
|
||||
}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set<string> }> {
|
||||
const sessions = new ScannedSessionCollection()
|
||||
const parsedCandidateKeys = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (
|
||||
canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
const remaining = args.candidates.length - index
|
||||
const needed = Math.max(args.limit - sessions.size, 1)
|
||||
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
for (const candidate of batch) {
|
||||
parsedCandidateKeys.add(remoteSessionCandidateKey(candidate))
|
||||
}
|
||||
throwIfAiVaultScanCancelled(args.context.signal)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
for (const session of results) {
|
||||
if (session) {
|
||||
sessions.add(session)
|
||||
}
|
||||
}
|
||||
index += batchSize
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
|
||||
// The loop can terminate on the yield after its final batch, so re-check
|
||||
// rather than letting a cancelled scan return a partial parse as a success.
|
||||
throwIfAiVaultScanCancelled(args.context.signal)
|
||||
return { sessions: [...sessions.values()], parsedCandidateKeys }
|
||||
}
|
||||
|
||||
async function scanRemoteInScopeSessions(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
scopePaths: readonly string[]
|
||||
limit: number
|
||||
alreadyParsedCandidateKeys: ReadonlySet<string>
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = args.candidates.filter(
|
||||
(candidate) => !args.alreadyParsedCandidateKeys.has(remoteSessionCandidateKey(candidate))
|
||||
)
|
||||
const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT)
|
||||
const sessions: AiVaultSession[] = []
|
||||
let index = 0
|
||||
|
||||
// Keep reading newest-first until the scope has its requested number of
|
||||
// sessions; out-of-scope candidates no longer end the search.
|
||||
while (index < bound && sessions.length < args.limit) {
|
||||
const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound)
|
||||
const results = await mapRemoteScanBatches(
|
||||
candidates.slice(index, batchEnd),
|
||||
REMOTE_SCAN_CONCURRENCY,
|
||||
(candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
sessions.push(
|
||||
...results.filter(
|
||||
(session): session is AiVaultSession =>
|
||||
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
|
||||
)
|
||||
)
|
||||
index = batchEnd
|
||||
}
|
||||
|
||||
if (index < candidates.length && sessions.length < args.limit) {
|
||||
recordSessionScanIssue(args.issues, {
|
||||
executionHostId: args.context.executionHostId,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: 'Agent Session History scan',
|
||||
message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.`
|
||||
})
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidate(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
// The read is inside the cached parse: an unchanged transcript must not be
|
||||
// pulled off the remote disk at all, which is the whole cost of #13753.
|
||||
const session = await parseRemoteSessionFileCached({
|
||||
candidate,
|
||||
hostKey: remoteSessionParseHostKey(context),
|
||||
parse: () => parseRemoteSessionTranscript(candidate, context),
|
||||
refreshReusedSession: reusedCodexTitleRefresh(candidate, context)
|
||||
})
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
// Mirror the local rule: every session carries its sibling subagent
|
||||
// transcript count (row badge; recoverable signal at zero turns). The
|
||||
// walk listing supplies it — the parser can't readdir a remote disk.
|
||||
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
|
||||
if (session && subagentTranscriptCount > 0) {
|
||||
return { ...session, subagentTranscriptCount }
|
||||
}
|
||||
return session
|
||||
} catch (err) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (err instanceof BinarySessionTranscriptError) {
|
||||
return null
|
||||
}
|
||||
recordSessionScanIssue(issues, {
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Codex thread names live in `<CODEX_HOME>/session_index.jsonl`, not the
|
||||
// rollout, and are written after it — so a transcript-keyed cache hit would
|
||||
// pin the fallback title forever. Local counterpart:
|
||||
// session-scanner-parse-cache.ts's reuse path.
|
||||
function reusedCodexTitleRefresh(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext
|
||||
): ((session: AiVaultSession) => Promise<AiVaultSession>) | undefined {
|
||||
const codexHome = candidate.source.agent === 'codex' ? candidate.source.codexHome : undefined
|
||||
if (!codexHome) {
|
||||
return undefined
|
||||
}
|
||||
const readIndexedTitle = remoteCodexIndexedTitleReader(codexHome, context)
|
||||
return (session) => refreshCodexTitleFromIndex(session, readIndexedTitle)
|
||||
}
|
||||
|
||||
function mergeRemoteSessions(
|
||||
cappedSessions: AiVaultSession[],
|
||||
scopeSessions: AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (scopeSessions.length === 0) {
|
||||
return cappedSessions
|
||||
}
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
for (const session of cappedSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
for (const session of scopeSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
}
|
||||
|
||||
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
|
||||
const cwd = session.cwd
|
||||
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
|
||||
}
|
||||
|
||||
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
|
||||
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
|
||||
return Boolean(session)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
|
||||
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
|
||||
|
||||
describe('remote sidecar observations', () => {
|
||||
const sessionId = '1786466194549_sidecar'
|
||||
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
|
||||
const messagesPath = `${sessionDir}/${sessionId}.messages.json`
|
||||
|
||||
function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void {
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
messagesPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
sessionId,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }]
|
||||
}),
|
||||
11
|
||||
)
|
||||
}
|
||||
|
||||
const scan = (provider: MemoryRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> =>
|
||||
scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
it('re-parses when a messages-file stat fails rather than serving the cached row', async () => {
|
||||
resetRemoteSessionParseCacheForTests()
|
||||
const provider = new MemoryRemoteProvider()
|
||||
addCline(provider, 'first prompt')
|
||||
expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' })
|
||||
|
||||
// The sidecar changed underneath, and its stat now fails for a reason that
|
||||
// is not "missing": nothing about it may be assumed, so the row is re-read.
|
||||
addCline(provider, 'second prompt')
|
||||
provider.failStat(
|
||||
messagesPath,
|
||||
Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
)
|
||||
|
||||
const refused = await scan(provider)
|
||||
|
||||
expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' })
|
||||
expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath)
|
||||
})
|
||||
|
||||
it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => {
|
||||
resetRemoteSessionParseCacheForTests()
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const first = await scan(provider)
|
||||
const second = await scan(provider)
|
||||
|
||||
expect(first.issues).toEqual([])
|
||||
expect(second.issues).toEqual([])
|
||||
expect(second.sessions[0]?.sessionId).toBe(sessionId)
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { streamedSessionContentLines } from './remote-session-content-lines'
|
||||
import { readStreamedSessionDocument } from './session-document-stream'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
|
||||
describe('stream lifetime and retained document work', () => {
|
||||
it('aborts a newline-free record at the byte ceiling and closes the source', async () => {
|
||||
let closed = false
|
||||
let reads = 0
|
||||
async function* bytes() {
|
||||
const chunk = Buffer.alloc(1024 * 1024, 'x')
|
||||
try {
|
||||
for (; reads < 100;) {
|
||||
reads++
|
||||
yield chunk
|
||||
}
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
const lines = streamedSessionContentLines(bytes())
|
||||
await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit')
|
||||
expect(reads).toBe(11)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('releases the source when a line consumer finishes early', async () => {
|
||||
let closed = false
|
||||
async function* bytes() {
|
||||
try {
|
||||
yield Buffer.from('one\ntwo\n')
|
||||
yield Buffer.from('three\n')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
for await (const line of streamedSessionContentLines(bytes())) {
|
||||
expect(line).toBe('one')
|
||||
break
|
||||
}
|
||||
await vi.waitFor(() => expect(closed).toBe(true))
|
||||
})
|
||||
it('propagates disk failure and closes the source', async () => {
|
||||
let closed = false
|
||||
async function* bytes() {
|
||||
try {
|
||||
yield Buffer.from('one\n')
|
||||
throw new Error('disk read failed')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of streamedSessionContentLines(bytes())) {
|
||||
/* consume */
|
||||
}
|
||||
})()
|
||||
).rejects.toThrow('disk read failed')
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
it('cancellation discards a document fold and releases its source', async () => {
|
||||
const controller = new AbortController()
|
||||
let closed = false
|
||||
async function* bytes() {
|
||||
try {
|
||||
yield Buffer.from('{"messages":[{"role":"user"}')
|
||||
controller.abort()
|
||||
yield Buffer.from(']}')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
readStreamedSessionDocument({
|
||||
bytes: bytes(),
|
||||
arrayKey: 'messages',
|
||||
fields: [],
|
||||
create: () => ({ count: 0 }),
|
||||
consume: (state) => {
|
||||
state.count++
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow()
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
it('holds one filesystem slot for the stream lifetime and releases it on return', async () => {
|
||||
let entered = 0
|
||||
async function* bytes() {
|
||||
entered++
|
||||
yield Buffer.from('a')
|
||||
yield Buffer.from('b')
|
||||
}
|
||||
const provider = limitRemoteScanFilesystemConcurrency(
|
||||
{
|
||||
readDir: async () => [],
|
||||
readFile: async () => ({ content: '', isBinary: false }),
|
||||
stat: async () => ({ size: 0, type: 'file', mtime: 0 }),
|
||||
readTranscriptBytes: bytes
|
||||
},
|
||||
1
|
||||
)
|
||||
const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](),
|
||||
second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]()
|
||||
await first.next()
|
||||
const pending = second.next()
|
||||
await Promise.resolve()
|
||||
expect(entered).toBe(1)
|
||||
await first.return!()
|
||||
await pending
|
||||
expect(entered).toBe(2)
|
||||
await second.return!()
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { streamedSessionContentLines } from './remote-session-content-lines'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
||||
const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export async function parseRemoteSessionTranscript(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (candidate.source.parseCandidate) {
|
||||
return candidate.source.parseCandidate(candidate.file, context)
|
||||
}
|
||||
const sidecar = candidate.file.sidecar
|
||||
const exceedsWholeReadLimit =
|
||||
(candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES ||
|
||||
(typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES)
|
||||
if (
|
||||
exceedsWholeReadLimit &&
|
||||
candidate.source.parseDocument &&
|
||||
!candidate.file.path.endsWith('.jsonl') &&
|
||||
context.provider.readTranscriptBytes
|
||||
) {
|
||||
return candidate.source.parseDocument(
|
||||
candidate.file,
|
||||
context.provider.readTranscriptBytes(candidate.file.path, context.signal),
|
||||
context
|
||||
)
|
||||
}
|
||||
if (
|
||||
exceedsWholeReadLimit &&
|
||||
candidate.file.path.endsWith('.jsonl') &&
|
||||
candidate.source.parseLines &&
|
||||
context.provider.readTranscriptBytes
|
||||
) {
|
||||
return candidate.source.parseLines(
|
||||
candidate.file,
|
||||
streamedSessionContentLines(
|
||||
context.provider.readTranscriptBytes(candidate.file.path, context.signal),
|
||||
context.signal
|
||||
),
|
||||
context
|
||||
)
|
||||
}
|
||||
const read = await context.provider.readFile(candidate.file.path)
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return await candidate.source.parse(candidate.file, read.content, context)
|
||||
}
|
||||
@@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
@@ -15,63 +12,46 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('session scanner cutoff', () => {
|
||||
it.each(['native', 'remote'] as const)(
|
||||
'%s does not sort timestamps at every post-limit candidate',
|
||||
async (host) => {
|
||||
const count = 128
|
||||
const limit = count / 2
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.codexSessionsDir, { recursive: true })
|
||||
for (let index = 0; index < count; index++) {
|
||||
const name = `rollout-session-${index}.jsonl`
|
||||
const content = jsonLines([
|
||||
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: new Date(index).toISOString(),
|
||||
payload: { type: 'user_message', message: 'Check this session' }
|
||||
}
|
||||
])
|
||||
const mtime = 10_000 - index
|
||||
if (host === 'native') {
|
||||
const filePath = join(roots.codexSessionsDir, name)
|
||||
await writeFile(filePath, content)
|
||||
await utimes(filePath, new Date(mtime), new Date(mtime))
|
||||
} else {
|
||||
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime)
|
||||
it('does not sort timestamps at every post-limit candidate', async () => {
|
||||
const count = 128
|
||||
const limit = count / 2
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.codexSessionsDir, { recursive: true })
|
||||
for (let index = 0; index < count; index++) {
|
||||
const name = `rollout-session-${index}.jsonl`
|
||||
const content = jsonLines([
|
||||
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: new Date(index).toISOString(),
|
||||
payload: { type: 'user_message', message: 'Check this session' }
|
||||
}
|
||||
}
|
||||
let numericSorts = 0
|
||||
const originalSort = Array.prototype.sort
|
||||
vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) {
|
||||
if (typeof this[0] === 'number') {
|
||||
numericSorts++
|
||||
}
|
||||
return originalSort.call(this, compare)
|
||||
})
|
||||
const scan = () =>
|
||||
host === 'native'
|
||||
? scanAiVaultSessions({ ...roots, limit })
|
||||
: scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
executionHostId: 'ssh:scan-cutoff',
|
||||
limit
|
||||
})
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
numericSorts = 0
|
||||
const result = await scan()
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((row) => row.sessionId)).toEqual(
|
||||
Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`)
|
||||
)
|
||||
expect(numericSorts).toBe(0)
|
||||
}
|
||||
])
|
||||
const mtime = 10_000 - index
|
||||
const filePath = join(roots.codexSessionsDir, name)
|
||||
await writeFile(filePath, content)
|
||||
await utimes(filePath, new Date(mtime), new Date(mtime))
|
||||
}
|
||||
)
|
||||
let numericSorts = 0
|
||||
const originalSort = Array.prototype.sort
|
||||
vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) {
|
||||
if (typeof this[0] === 'number') {
|
||||
numericSorts++
|
||||
}
|
||||
return originalSort.call(this, compare)
|
||||
})
|
||||
const scan = () => scanAiVaultSessions({ ...roots, limit })
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
numericSorts = 0
|
||||
const result = await scan()
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((row) => row.sessionId)).toEqual(
|
||||
Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`)
|
||||
)
|
||||
expect(numericSorts).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
@@ -15,96 +12,37 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('session scan batch deduplication', () => {
|
||||
it.each(['native', 'remote'] as const)(
|
||||
'%s does not rederive every retained rollout alias after each batch',
|
||||
async (host) => {
|
||||
const count = 128
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.codexSessionsDir, { recursive: true })
|
||||
for (let index = 0; index < count; index++) {
|
||||
const name = `rollout-session-${index}.jsonl`
|
||||
const content = jsonLines([
|
||||
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
|
||||
{ type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } }
|
||||
])
|
||||
if (host === 'native') {
|
||||
await writeFile(join(roots.codexSessionsDir, name), content)
|
||||
} else {
|
||||
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, count - index)
|
||||
}
|
||||
}
|
||||
let aliasChecks = 0
|
||||
const originalTest = RegExp.prototype.test
|
||||
vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) {
|
||||
if (this.source === '^rollout-.+\\.jsonl$') {
|
||||
aliasChecks++
|
||||
}
|
||||
return originalTest.call(this, value)
|
||||
})
|
||||
const scan = () =>
|
||||
host === 'native'
|
||||
? scanAiVaultSessions({ ...roots, unlimited: true })
|
||||
: scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
executionHostId: 'ssh:dedup-batches',
|
||||
unlimited: true
|
||||
})
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
aliasChecks = 0
|
||||
const result = await scan()
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(count)
|
||||
expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count)
|
||||
expect(aliasChecks).toBeLessThanOrEqual(count * 8)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces aliases across remote batches without consuming the unique-session budget', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const managedHome = '/home/ada/.local/share/orca/codex-runtime-home/home'
|
||||
const content = (id: string) =>
|
||||
jsonLines([
|
||||
{ type: 'session_meta', payload: { id, cwd: '/repo/folder' } },
|
||||
{ type: 'event_msg', payload: { type: 'user_message', message: 'Session' } }
|
||||
it('does not rederive every retained rollout alias after each batch', async () => {
|
||||
const count = 128
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(roots.codexSessionsDir, { recursive: true })
|
||||
for (let index = 0; index < count; index++) {
|
||||
const name = `rollout-session-${index}.jsonl`
|
||||
const content = jsonLines([
|
||||
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
|
||||
{ type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } }
|
||||
])
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const name = `rollout-${index}.jsonl`
|
||||
provider.addFile(`/home/ada/.codex/sessions/${name}`, content(`${index}`), 1000 - index)
|
||||
provider.addFile(`${managedHome}/sessions/${name}`, content(`${index}`), 500 - index)
|
||||
await writeFile(join(roots.codexSessionsDir, name), content)
|
||||
}
|
||||
provider.addFile('/home/ada/.codex/sessions/rollout-unique.jsonl', content('unique'), 100)
|
||||
const tooOld = '/home/ada/.codex/sessions/rollout-too-old.jsonl'
|
||||
provider.addFile(tooOld, content('too-old'), 50)
|
||||
const reads = vi.spyOn(provider, 'readFile')
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
executionHostId: 'ssh:batch-replacements',
|
||||
limit: 9
|
||||
let aliasChecks = 0
|
||||
const originalTest = RegExp.prototype.test
|
||||
vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) {
|
||||
if (this.source === '^rollout-.+\\.jsonl$') {
|
||||
aliasChecks++
|
||||
}
|
||||
return originalTest.call(this, value)
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'unique'
|
||||
])
|
||||
expect(result.sessions.slice(0, 8).every((session) => session.codexHome === managedHome)).toBe(
|
||||
true
|
||||
)
|
||||
expect(reads).not.toHaveBeenCalledWith(tooOld)
|
||||
const scan = () => scanAiVaultSessions({ ...roots, unlimited: true })
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
aliasChecks = 0
|
||||
const result = await scan()
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(count)
|
||||
expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count)
|
||||
expect(aliasChecks).toBeLessThanOrEqual(count * 8)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup'
|
||||
@@ -45,32 +42,6 @@ it('lists a session in both default directories once and still fills the scan li
|
||||
expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
|
||||
})
|
||||
|
||||
it.each(['win32-x64', 'linux-x64'] as const)(
|
||||
'deduplicates both remote directories on %s',
|
||||
async (platform) => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const windows = platform === 'win32-x64'
|
||||
const home = windows ? 'C:/Users/ada' : '/home/ada'
|
||||
const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli`
|
||||
provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10)
|
||||
provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20)
|
||||
provider.addFile(
|
||||
`${cliDir}/transcripts/other.json`,
|
||||
transcript('other', '2026-09-18T00:00:00Z'),
|
||||
5
|
||||
)
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
remoteHome: home,
|
||||
executionHostId: 'ssh:devin',
|
||||
hostPlatform: getRemoteHostPlatform(platform),
|
||||
limit: 2
|
||||
})
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
|
||||
expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`)
|
||||
}
|
||||
)
|
||||
|
||||
function session(path: string) {
|
||||
const parsed = parseDevinSessionContent(
|
||||
{ path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() },
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { readFile, mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
|
||||
import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import {
|
||||
createQoderSessionResumeState,
|
||||
parseQoderSessionContent
|
||||
} from './session-scanner-qoder-parser'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
|
||||
import type { TranscriptMessage } from './session-transcript-consumers'
|
||||
|
||||
@@ -67,39 +61,3 @@ it('decodes a real generated and resumed Qoder 1.1.64 session for preview and se
|
||||
cwd: '/tmp/qoder-proof'
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-'))
|
||||
roots.push(root)
|
||||
const options = isolatedScanRoots(root)
|
||||
const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl')
|
||||
const records = [
|
||||
{ type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] },
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'qoder-session',
|
||||
message: { role: 'user', content: 'Qoder folder proof' }
|
||||
}
|
||||
]
|
||||
await writeJsonlFile(path, records)
|
||||
await writeJsonlFile(
|
||||
join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'),
|
||||
records
|
||||
)
|
||||
const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] })
|
||||
const sessions = result.sessions.filter((s) => s.agent === 'qoder')
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' })
|
||||
expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain(
|
||||
join('/home/test', '.qoder', 'projects')
|
||||
)
|
||||
for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) {
|
||||
const source = remoteSessionSources(
|
||||
platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test',
|
||||
getRemoteHostPlatform(platform)
|
||||
).find((s) => s.agent === 'qoder')
|
||||
expect(source).toBeDefined()
|
||||
expect(source?.partitionSubagentTranscripts).toBeDefined()
|
||||
}
|
||||
expect((await stat(path)).size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type * as SessionDedup from './session-root-dedup'
|
||||
import type * as RemoteSessionParseCache from './remote-session-parse-cache'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
||||
const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({
|
||||
@@ -28,16 +27,6 @@ vi.mock('./session-scanner-parse-cache', () => ({
|
||||
}),
|
||||
parseAgentSessionFileCached: async (candidate: { session: AiVaultSession }) => candidate.session
|
||||
}))
|
||||
vi.mock('./remote-session-scanner-sources', () => ({ remoteSessionSources: () => [{}] }))
|
||||
vi.mock('./remote-session-scanner-discovery', () => ({
|
||||
discoverRemoteSourceCandidates: async () => candidates()
|
||||
}))
|
||||
vi.mock('./remote-session-parse-cache', async (original) => ({
|
||||
...(await original<typeof RemoteSessionParseCache>()),
|
||||
remoteSessionParseHostKey: () => 'fixture',
|
||||
parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) =>
|
||||
candidate.session
|
||||
}))
|
||||
vi.mock('./session-root-dedup', async (original) => {
|
||||
const actual = await original<typeof SessionDedup>()
|
||||
return {
|
||||
@@ -50,7 +39,6 @@ vi.mock('./session-root-dedup', async (original) => {
|
||||
})
|
||||
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
|
||||
|
||||
function candidates() {
|
||||
@@ -93,62 +81,42 @@ beforeEach(() => {
|
||||
fixture.visits = 0
|
||||
})
|
||||
|
||||
for (const host of ['local', 'remote'] as const) {
|
||||
const scan = (unlimited: boolean, limit?: number) =>
|
||||
host === 'local'
|
||||
? scanAiVaultSessions({ unlimited, limit })
|
||||
: scanRemoteAiVaultSessions({
|
||||
unlimited,
|
||||
limit,
|
||||
provider: { readDir: vi.fn(), readFile: vi.fn(), stat: vi.fn() },
|
||||
executionHostId: 'local',
|
||||
remoteHome: '/fixture',
|
||||
hostPlatform: {
|
||||
relayPlatform: 'linux-x64',
|
||||
os: 'linux',
|
||||
arch: 'x64',
|
||||
pathFlavor: 'posix',
|
||||
commandDialect: 'posix',
|
||||
pathSeparator: '/',
|
||||
pathDelimiter: ':'
|
||||
}
|
||||
})
|
||||
const scan = (unlimited: boolean, limit?: number) => scanAiVaultSessions({ unlimited, limit })
|
||||
|
||||
it(`${host}: load-all processes deduplication linearly and retains late canonical aliases`, async () => {
|
||||
fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i))
|
||||
fixture.sessions[0] = {
|
||||
...fixture.sessions[0]!,
|
||||
codexHome: '/custom',
|
||||
filePath: '/custom/rollout-0.jsonl'
|
||||
}
|
||||
fixture.sessions.push(session(0))
|
||||
const expected = dedupeScannedSessions(fixture.sessions)
|
||||
fixture.visits = 0
|
||||
const started = performance.now()
|
||||
const result = await scan(true)
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ host, candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n`
|
||||
)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toEqual(expected)
|
||||
expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2)
|
||||
}, 30000)
|
||||
it('load-all processes deduplication linearly and retains late canonical aliases', async () => {
|
||||
fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i))
|
||||
fixture.sessions[0] = {
|
||||
...fixture.sessions[0]!,
|
||||
codexHome: '/custom',
|
||||
filePath: '/custom/rollout-0.jsonl'
|
||||
}
|
||||
fixture.sessions.push(session(0))
|
||||
const expected = dedupeScannedSessions(fixture.sessions)
|
||||
fixture.visits = 0
|
||||
const started = performance.now()
|
||||
const result = await scan(true)
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n`
|
||||
)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toEqual(expected)
|
||||
expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2)
|
||||
}, 30000)
|
||||
|
||||
it(`${host}: capped scans still fill the unique-session budget`, async () => {
|
||||
fixture.sessions = [
|
||||
session(0),
|
||||
...Array.from({ length: 8 }, () => ({
|
||||
...session(0),
|
||||
filePath: '/custom/rollout-0.jsonl',
|
||||
codexHome: '/custom'
|
||||
})),
|
||||
...Array.from({ length: 10 }, (_, i) => session(i + 1))
|
||||
]
|
||||
const result = await scan(false, 10)
|
||||
expect(result.sessions).toHaveLength(10)
|
||||
expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10)
|
||||
})
|
||||
}
|
||||
it('capped scans still fill the unique-session budget', async () => {
|
||||
fixture.sessions = [
|
||||
session(0),
|
||||
...Array.from({ length: 8 }, () => ({
|
||||
...session(0),
|
||||
filePath: '/custom/rollout-0.jsonl',
|
||||
codexHome: '/custom'
|
||||
})),
|
||||
...Array.from({ length: 10 }, (_, i) => session(i + 1))
|
||||
]
|
||||
const result = await scan(false, 10)
|
||||
expect(result.sessions).toHaveLength(10)
|
||||
expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10)
|
||||
})
|
||||
|
||||
it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => {
|
||||
const collection = new ScannedSessionCollection()
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { createGitHandlerRelay } from '../../relay/git-handler-test-harness'
|
||||
import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner'
|
||||
|
||||
let root = ''
|
||||
@@ -73,25 +72,6 @@ async function expectIdentity(identity: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('network SSH configuration with real Git', () => {
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s honors repository SSH wrappers on ordinary fetches',
|
||||
async (host) => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('configured identity')])
|
||||
if (host === 'native') {
|
||||
await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow()
|
||||
} else {
|
||||
const { dispatcher, handler } = createGitHandlerRelay()
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('git.fetch', { worktreePath: repo })
|
||||
).rejects.toThrow()
|
||||
} finally {
|
||||
handler.dispose()
|
||||
}
|
||||
}
|
||||
await expectIdentity('configured identity')
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves command-line SSH configuration ahead of repository configuration', async () => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('repository')])
|
||||
@@ -104,28 +84,6 @@ describe('network SSH configuration with real Git', () => {
|
||||
await expectIdentity('command-line')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('repository')])
|
||||
const command = sshCommand('environment')
|
||||
if (host === 'native') {
|
||||
await expect(
|
||||
gitExecFileAsync(['fetch', 'origin'], {
|
||||
cwd: repo,
|
||||
env: { ...env, GIT_SSH_COMMAND: command }
|
||||
})
|
||||
).rejects.toThrow()
|
||||
} else {
|
||||
vi.stubEnv('GIT_SSH_COMMAND', command)
|
||||
const { dispatcher, handler } = createGitHandlerRelay()
|
||||
try {
|
||||
await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow()
|
||||
} finally {
|
||||
handler.dispose()
|
||||
}
|
||||
}
|
||||
await expectIdentity('environment')
|
||||
})
|
||||
|
||||
it('honors configured global SSH wrappers for streaming clones from a folder', async () => {
|
||||
await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')])
|
||||
const child = await gitSpawnAfterWindowsEnvironmentReady(
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import { readFile, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { isMissingGitBlobPath } from '../../../shared/git-blob-absence'
|
||||
import { readGitBlobAtIndexPath } from './git-blob-read'
|
||||
import { getDiff } from './file-diff'
|
||||
import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation'
|
||||
import { readWorktreeDiffStamp } from './worktree-diff-stamp'
|
||||
|
||||
function gitInit(dir: string): void {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
function gitCommit(dir: string, message: string): void {
|
||||
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
describe('real Git missing paths and failed blob reads', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
|
||||
gitInit(repo)
|
||||
invalidateGitReadCaches()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
invalidateGitReadCaches()
|
||||
await removeGitTempDir(repo)
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function git(args: string[], input?: string): Promise<string> {
|
||||
@@ -39,75 +42,60 @@ describe('real Git missing paths and failed blob reads', () => {
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function diff(host: 'native' | 'relay', staged = false): Promise<unknown> {
|
||||
return host === 'native'
|
||||
? getDiff(repo, 'file.txt', staged)
|
||||
: dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged })
|
||||
function diff(staged = false): Promise<unknown> {
|
||||
return getDiff(repo, 'file.txt', staged)
|
||||
}
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'does not substitute HEAD for a corrupt index blob on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
await git(['add', 'file.txt'])
|
||||
const oid = (await git(['rev-parse', ':file.txt'])).trim()
|
||||
await writeFile(path.join(repo, 'file.txt'), 'working\n')
|
||||
const old = new Date(Date.now() - 10_000)
|
||||
await Promise.all(
|
||||
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
|
||||
)
|
||||
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
|
||||
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
|
||||
exists: false,
|
||||
failed: true
|
||||
})
|
||||
it('does not substitute HEAD for a corrupt index blob', async () => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
await git(['add', 'file.txt'])
|
||||
const oid = (await git(['rev-parse', ':file.txt'])).trim()
|
||||
await writeFile(path.join(repo, 'file.txt'), 'working\n')
|
||||
const old = new Date(Date.now() - 10_000)
|
||||
await Promise.all(
|
||||
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
|
||||
)
|
||||
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
|
||||
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
|
||||
exists: false,
|
||||
failed: true
|
||||
})
|
||||
|
||||
expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
|
||||
if (host === 'native') {
|
||||
expect(settledDiffCache.stats().entries).toBe(0)
|
||||
}
|
||||
await git(['hash-object', '-w', '--stdin'], 'staged\n')
|
||||
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
expect(after?.value).toBe(before?.value)
|
||||
expect(await diff()).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
|
||||
expect(settledDiffCache.stats().entries).toBe(0)
|
||||
await git(['hash-object', '-w', '--stdin'], 'staged\n')
|
||||
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
expect(after?.value).toBe(before?.value)
|
||||
|
||||
expect(await diff(host)).toMatchObject({
|
||||
originalContent: 'staged\n',
|
||||
modifiedContent: 'working\n'
|
||||
})
|
||||
if (host === 'native') {
|
||||
expect(settledDiffCache.stats().entries).toBe(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(await diff()).toMatchObject({
|
||||
originalContent: 'staged\n',
|
||||
modifiedContent: 'working\n'
|
||||
})
|
||||
expect(settledDiffCache.stats().entries).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'retains added paths on an unborn branch on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(host, true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
}
|
||||
)
|
||||
it('retains added paths on an unborn branch', async () => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'retains additions absent from a valid HEAD tree on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(host, true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
}
|
||||
)
|
||||
it('retains additions absent from a valid HEAD tree', async () => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes actual index and tree absence without accepting corrupt objects', async () => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { bulkStageFiles, bulkUnstageFiles } from './staging'
|
||||
import { bulkDiscardChanges } from './discard-changes'
|
||||
|
||||
describe('bulk pathspec stdin on execution hosts', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
gitInit(repo)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
await removeGitTempDir(repo)
|
||||
})
|
||||
|
||||
async function changedFiles(staged: boolean): Promise<string[]> {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'],
|
||||
cwd: repo
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
return result.stdout.split('\0').filter(Boolean).sort()
|
||||
}
|
||||
|
||||
function mutate(
|
||||
host: 'native' | 'relay',
|
||||
action: 'stage' | 'unstage' | 'discard',
|
||||
filePaths: string[]
|
||||
): Promise<unknown> {
|
||||
if (host === 'relay') {
|
||||
const methods = {
|
||||
stage: 'git.bulkStage',
|
||||
unstage: 'git.bulkUnstage',
|
||||
discard: 'git.bulkDiscard'
|
||||
}
|
||||
return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths })
|
||||
}
|
||||
const operations = {
|
||||
stage: bulkStageFiles,
|
||||
unstage: bulkUnstageFiles,
|
||||
discard: bulkDiscardChanges
|
||||
}
|
||||
return operations[action](repo, filePaths)
|
||||
}
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'stages, unstages and discards over 100 literal paths on %s',
|
||||
async (host) => {
|
||||
const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`)
|
||||
selected.push('[k]eep.log', 'space name.txt', '-option.txt')
|
||||
if (process.platform !== 'win32') {
|
||||
selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt')
|
||||
}
|
||||
const allPaths = [...selected, 'keep.log']
|
||||
await Promise.all(
|
||||
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n'))
|
||||
)
|
||||
gitCommit(repo, 'initial')
|
||||
await Promise.all(
|
||||
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n'))
|
||||
)
|
||||
|
||||
await mutate(host, 'stage', [])
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
await mutate(host, 'stage', selected)
|
||||
expect(await changedFiles(true)).toEqual([...selected].sort())
|
||||
await mutate(host, 'unstage', [])
|
||||
expect(await changedFiles(true)).toEqual([...selected].sort())
|
||||
await mutate(host, 'unstage', selected)
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
await mutate(host, 'discard', [])
|
||||
expect(await changedFiles(false)).toEqual([...allPaths].sort())
|
||||
await mutate(host, 'discard', selected)
|
||||
expect(await changedFiles(false)).toEqual(['keep.log'])
|
||||
expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => {
|
||||
await writeFile(path.join(repo, 'one.txt'), 'original\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'one.txt'), 'modified\n')
|
||||
|
||||
await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL')
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,38 +1,36 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { bulkUnstageFiles, unstageFile } from './staging'
|
||||
import { bulkDiscardChanges, discardChanges } from './discard-changes'
|
||||
|
||||
const modes = [
|
||||
{ host: 'native', bulk: false },
|
||||
{ host: 'native', bulk: true },
|
||||
{ host: 'relay', bulk: false },
|
||||
{ host: 'relay', bulk: true }
|
||||
] as const
|
||||
function gitInit(dir: string): void {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
function gitCommit(dir: string, message: string): void {
|
||||
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
const modes = [{ bulk: false }, { bulk: true }] as const
|
||||
|
||||
describe('staging and discard preserve index authority', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
|
||||
gitInit(repo)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
await removeGitTempDir(repo)
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function git(args: string[], input?: string): Promise<string> {
|
||||
@@ -46,19 +44,13 @@ describe('staging and discard preserve index authority', () => {
|
||||
action: 'unstage' | 'discard',
|
||||
filePaths: string[]
|
||||
): Promise<unknown> {
|
||||
if (mode.host === 'relay') {
|
||||
return dispatcher.callRequest(
|
||||
`git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`,
|
||||
{ worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) }
|
||||
)
|
||||
}
|
||||
if (mode.bulk) {
|
||||
return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths)
|
||||
}
|
||||
return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0])
|
||||
}
|
||||
|
||||
it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => {
|
||||
it.each(modes)('preserves staged MM content during bulk=$bulk discard', async (mode) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
@@ -73,7 +65,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n')
|
||||
})
|
||||
|
||||
it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => {
|
||||
it.each(modes)('restores staged additions during bulk=$bulk AM discard', async (mode) => {
|
||||
await writeFile(path.join(repo, 'initial.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'new.txt'), 'staged addition\n')
|
||||
@@ -87,7 +79,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await git(['diff', '--name-only'])).toBe('')
|
||||
})
|
||||
|
||||
it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => {
|
||||
it.each(modes)('unstages selected unborn paths on bulk=$bulk', async (mode) => {
|
||||
await writeFile(path.join(repo, 'selected.txt'), 'selected\n')
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
|
||||
await git(['add', '.'])
|
||||
@@ -98,7 +90,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => {
|
||||
it('unstages both rename paths', async () => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
|
||||
gitCommit(repo, 'initial')
|
||||
@@ -106,32 +98,29 @@ describe('staging and discard preserve index authority', () => {
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n')
|
||||
await git(['add', 'keep.txt'])
|
||||
|
||||
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
|
||||
expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n')
|
||||
expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0')
|
||||
expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'discards a staged rename after unstaging both paths on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await git(['mv', 'old.txt', 'new.txt'])
|
||||
it('discards a staged rename after unstaging both paths', async () => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await git(['mv', 'old.txt', 'new.txt'])
|
||||
|
||||
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt'])
|
||||
|
||||
expect(await git(['status', '--porcelain'])).toBe('')
|
||||
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
|
||||
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
}
|
||||
)
|
||||
expect(await git(['status', '--porcelain'])).toBe('')
|
||||
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
|
||||
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => {
|
||||
it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'base\n')
|
||||
gitCommit(repo, 'initial')
|
||||
const blobs = await Promise.all(
|
||||
|
||||
@@ -4,8 +4,6 @@ import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBranchCompare } from './status'
|
||||
import { branchCompare } from '../../relay/git-handler-ops'
|
||||
import { gitChangeListArgs, parseGitChangeList } from '../../shared/git-change-list'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
@@ -17,22 +15,12 @@ function git(repo: string, args: string[]): string {
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function relayCompare(repo: string, baseRef: string) {
|
||||
return branchCompare(
|
||||
async (args, cwd) => ({ stdout: git(cwd, args), stderr: '' }),
|
||||
repo,
|
||||
baseRef,
|
||||
async (mergeBase, headOid) =>
|
||||
parseGitChangeList(git(repo, gitChangeListArgs(mergeBase, headOid)))
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('getBranchCompare real refs', () => {
|
||||
it('reports equal commits as empty on native and relay despite staged and unstaged changes', async () => {
|
||||
it('reports equal commits as empty despite staged and unstaged changes', async () => {
|
||||
const repo = await mkdtemp(path.join(tmpdir(), 'orca-equal-branch-compare-'))
|
||||
tempRoots.push(repo)
|
||||
git(repo, ['init', '-q'])
|
||||
@@ -46,37 +34,18 @@ describe('getBranchCompare real refs', () => {
|
||||
git(repo, ['add', 'changes.txt'])
|
||||
await writeFile(path.join(repo, 'changes.txt'), 'unstaged\n')
|
||||
|
||||
for (const result of await Promise.all([
|
||||
getBranchCompare(repo, 'base'),
|
||||
relayCompare(repo, 'base')
|
||||
])) {
|
||||
expect(result).toEqual({
|
||||
summary: {
|
||||
baseRef: 'base',
|
||||
baseOid: oid,
|
||||
compareRef: 'feature',
|
||||
headOid: oid,
|
||||
mergeBase: oid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
},
|
||||
entries: []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the merge-base failure for identical blob tips on the relay', async () => {
|
||||
const repo = await mkdtemp(path.join(tmpdir(), 'orca-blob-branch-compare-'))
|
||||
tempRoots.push(repo)
|
||||
git(repo, ['init', '-q'])
|
||||
await writeFile(path.join(repo, 'blob.txt'), 'not a commit\n')
|
||||
const oid = git(repo, ['hash-object', '-w', 'blob.txt'])
|
||||
await writeFile(path.join(repo, '.git', 'HEAD'), `${oid}\n`)
|
||||
|
||||
await expect(relayCompare(repo, oid)).resolves.toMatchObject({
|
||||
summary: { headOid: oid, baseOid: oid, status: 'no-merge-base', mergeBase: null },
|
||||
expect(await getBranchCompare(repo, 'base')).toEqual({
|
||||
summary: {
|
||||
baseRef: 'base',
|
||||
baseOid: oid,
|
||||
compareRef: 'feature',
|
||||
headOid: oid,
|
||||
mergeBase: oid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
},
|
||||
entries: []
|
||||
})
|
||||
})
|
||||
@@ -109,19 +78,14 @@ describe('getBranchCompare real refs', () => {
|
||||
])
|
||||
expect(rawOid).not.toBe(peeledOid)
|
||||
|
||||
for (const result of await Promise.all([
|
||||
getBranchCompare(client, 'origin/tagbase'),
|
||||
relayCompare(client, 'origin/tagbase')
|
||||
])) {
|
||||
expect(result.summary).toMatchObject({
|
||||
baseOid: rawOid,
|
||||
headOid: peeledOid,
|
||||
mergeBase: peeledOid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
})
|
||||
}
|
||||
expect((await getBranchCompare(client, 'origin/tagbase')).summary).toMatchObject({
|
||||
baseOid: rawOid,
|
||||
headOid: peeledOid,
|
||||
mergeBase: peeledOid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Main and relay must publish the same `branchLineTotal` for the same fixture
|
||||
* repo. Both call sites
|
||||
* share `src/shared/git-branch-line-total.ts`; this is the test that catches one
|
||||
* of them wiring it up differently — different flags, a different untracked
|
||||
* source, a different completeness gate.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getStatus } from './status'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Fork point → working tree for the fixture below:
|
||||
// tracked.txt +2 (branch commit only)
|
||||
// partial.txt +1 (staged +2 then one of those lines removed unstaged)
|
||||
// flip.txt 0 (added in the branch commit, removed again in the worktree)
|
||||
// moved.txt 0 (pure rename)
|
||||
// fresh.txt +3 (untracked)
|
||||
// No fixture path here looks like test or generated code, so it is all source.
|
||||
const EXPECTED_TOTAL = {
|
||||
added: 6,
|
||||
removed: 0,
|
||||
test: { added: 0, removed: 0 },
|
||||
generated: { added: 0, removed: 0 }
|
||||
}
|
||||
// Summing the per-area status rows instead would give this — the wrong answer
|
||||
// the shared module exists to avoid.
|
||||
const AREA_ROW_SUM = { added: 5, removed: 2 }
|
||||
|
||||
const relayGit: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => {
|
||||
const { stdout } = await relayGit(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** Returns the merge-base OID the chip is measured against. */
|
||||
async function seedParityFixture(repo: string): Promise<string> {
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n')
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n')
|
||||
await writeFile(path.join(repo, 'renamed.txt'), 'stable\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n')
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt'])
|
||||
// Staged and unstaged hunks land on the same added lines, so an area sum
|
||||
// double-counts them.
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n')
|
||||
runFixtureGit(repo, ['add', 'partial.txt'])
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n')
|
||||
// The branch commit's line, taken back out in the worktree: net zero.
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): {
|
||||
added: number
|
||||
removed: number
|
||||
} {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const entry of entries) {
|
||||
added += entry.added ?? 0
|
||||
removed += entry.removed ?? 0
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
describe('branch line total parity between main and relay', () => {
|
||||
let repo: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('produces identical totals for the same fixture repo', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase })
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
// Both sides model the same worktree, so a parity pass on a mismatched
|
||||
// entry list would be meaningless.
|
||||
expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual(
|
||||
mainStatus.entries.map((entry) => entry.path).sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('agrees on a number no per-area row sum could produce', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM)
|
||||
expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual(
|
||||
AREA_ROW_SUM
|
||||
)
|
||||
expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM)
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
})
|
||||
|
||||
it('omits the total on both sides when no merge base is requested', async () => {
|
||||
await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo)
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo })
|
||||
|
||||
expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false)
|
||||
expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,250 +0,0 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import type * as GitRunner from './runner'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
|
||||
const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({
|
||||
gitStreamStdoutMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
return { ...actual, readFile: readFileMock }
|
||||
})
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GitRunner>()
|
||||
return { ...actual, gitStreamStdout: gitStreamStdoutMock }
|
||||
})
|
||||
|
||||
import { getStatus } from './status'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
|
||||
const BENCH_DELAY_MS = 25
|
||||
const BENCH_SAMPLES = 31
|
||||
const BENCH_WARMUPS = 5
|
||||
const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
type BenchmarkResult = {
|
||||
path: string
|
||||
medianMs: number
|
||||
p95Ms: number
|
||||
minMs: number
|
||||
maxMs: number
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
function percentile(samples: number[], fraction: number): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
return sorted[Math.ceil(sorted.length * fraction) - 1]!
|
||||
}
|
||||
|
||||
describe('git status conflict-read overlap', () => {
|
||||
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
|
||||
|
||||
beforeEach(() => {
|
||||
readFileMock.mockReset()
|
||||
gitStreamStdoutMock.mockReset()
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false })
|
||||
})
|
||||
|
||||
it('starts native status before conflict-marker I/O settles', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
const statusStarted = deferred<void>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
gitStreamStdoutMock.mockImplementation(async () => {
|
||||
statusStarted.resolve()
|
||||
return { stoppedEarly: false }
|
||||
})
|
||||
|
||||
const resultPromise = getStatus('/repo')
|
||||
await statusStarted.promise
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts relay status before conflict-marker I/O settles', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
const statusStarted = deferred<void>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
statusStarted.resolve()
|
||||
return { stoppedEarly: false }
|
||||
}
|
||||
|
||||
const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
await statusStarted.promise
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('observes an early native status rejection while marker I/O remains pending', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
gitStreamStdoutMock.mockRejectedValue(new Error('status failed first'))
|
||||
let settled = false
|
||||
|
||||
const resultPromise = getStatus('/repo').finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a synchronous native status failure fail-soft', async () => {
|
||||
const statusError = new Error('status threw')
|
||||
gitStreamStdoutMock.mockImplementation(() => {
|
||||
throw statusError
|
||||
})
|
||||
|
||||
await expect(getStatus('/repo')).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => {
|
||||
const statusError = new Error(`status ${mode}`)
|
||||
const relayStreamGit = (() => {
|
||||
if (mode === 'throw') {
|
||||
throw statusError
|
||||
}
|
||||
return Promise.reject(statusError)
|
||||
}) as RelayGitStreamExec
|
||||
|
||||
await expect(
|
||||
getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' })
|
||||
})
|
||||
|
||||
it('rethrows the original relay status failure after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
const statusError = new Error('cancelled status')
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
controller.abort(statusError)
|
||||
throw statusError
|
||||
}
|
||||
|
||||
await expect(
|
||||
getStatusOp(
|
||||
relayGit,
|
||||
relayStreamGit,
|
||||
{ worktreePath: '/repo' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
).rejects.toBe(statusError)
|
||||
})
|
||||
|
||||
it('surfaces detector errors without waiting for a hung status read', async () => {
|
||||
const statusStarted = deferred<void>()
|
||||
const relayStreamGit: RelayGitStreamExec = () => {
|
||||
statusStarted.resolve()
|
||||
return new Promise(() => {})
|
||||
}
|
||||
const invalidPath = { toString: () => '/repo' }
|
||||
|
||||
const resultPromise = getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: invalidPath
|
||||
})
|
||||
const rejection = expect(resultPromise).rejects.toThrow(TypeError)
|
||||
await statusStarted.promise
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('keeps detector errors ahead of concurrent status failures', async () => {
|
||||
const statusError = new Error('status failed too')
|
||||
const relayStreamGit = (() => {
|
||||
throw statusError
|
||||
}) as RelayGitStreamExec
|
||||
const invalidPath = { toString: () => '/repo' }
|
||||
|
||||
const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath })
|
||||
await expect(result).rejects.toBeInstanceOf(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describeBench('git status conflict-read overlap benchmark', () => {
|
||||
it('measures native and relay orchestration with matched independent latency', async () => {
|
||||
readFileMock.mockImplementation(async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return 'gitdir: /repo/.git/worktrees/feature\n'
|
||||
})
|
||||
gitStreamStdoutMock.mockImplementation(async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return { stoppedEarly: false }
|
||||
})
|
||||
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return { stoppedEarly: false }
|
||||
}
|
||||
const cases = [
|
||||
{ name: 'native', run: () => getStatus('/repo') },
|
||||
{
|
||||
name: 'relay',
|
||||
run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
}
|
||||
]
|
||||
|
||||
const results: BenchmarkResult[] = []
|
||||
for (const benchmarkCase of cases) {
|
||||
for (let index = 0; index < BENCH_WARMUPS; index += 1) {
|
||||
await benchmarkCase.run()
|
||||
}
|
||||
const samples: number[] = []
|
||||
for (let index = 0; index < BENCH_SAMPLES; index += 1) {
|
||||
const startedAt = performance.now()
|
||||
await benchmarkCase.run()
|
||||
samples.push(performance.now() - startedAt)
|
||||
}
|
||||
results.push({
|
||||
path: benchmarkCase.name,
|
||||
medianMs: Number(percentile(samples, 0.5).toFixed(3)),
|
||||
p95Ms: Number(percentile(samples, 0.95).toFixed(3)),
|
||||
minMs: Number(Math.min(...samples).toFixed(3)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(3))
|
||||
})
|
||||
}
|
||||
|
||||
console.table(results)
|
||||
expect(results).toHaveLength(2)
|
||||
}, 10_000)
|
||||
})
|
||||
@@ -253,7 +253,6 @@ async function performAddWorktree(
|
||||
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
|
||||
}
|
||||
|
||||
// SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep.
|
||||
// Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain
|
||||
// `git push` create+set origin/<branch> (git >=2.37; older clients ignore it). `--local` on a
|
||||
// linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent,
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock('./local-repo-ref-maintenance', () => ({
|
||||
}))
|
||||
|
||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
|
||||
|
||||
const image = process.env.ORCA_GIT_COMPAT_IMAGE
|
||||
const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git'
|
||||
@@ -82,10 +81,10 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('rebase branch reservations with real Git', () => {
|
||||
it.for(['native', 'relay'] as const)(
|
||||
it.for(['native'] as const)(
|
||||
'%s retains auxiliary branches reserved by a paused real rebase --update-refs',
|
||||
{ timeout: 120_000 },
|
||||
async (host, context) => {
|
||||
async (_host, context) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
await writeFile(join(checkout, 'first'), 'first\n')
|
||||
await git(['add', 'first'], checkout)
|
||||
@@ -117,10 +116,7 @@ describe('rebase branch reservations with real Git', () => {
|
||||
/checked out|used by worktree/
|
||||
)
|
||||
const cleanupGit = vi.fn((args: string[], cwd: string) => git(args, cwd))
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
|
||||
: forceDeletePreservedRelayBranch(cleanupGit, repo, 'auxiliary', expected)
|
||||
const remove = () => forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect(cleanupGit.mock.calls.map(([args]) => args)).toEqual([
|
||||
['worktree', 'list', '--porcelain']
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GitCapabilityCache } from '../../shared/git-capability-cache'
|
||||
import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation'
|
||||
import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser'
|
||||
import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
|
||||
const { runner } = vi.hoisted(() => ({ runner: vi.fn() }))
|
||||
vi.mock('./runner', () => ({
|
||||
@@ -18,14 +13,8 @@ vi.mock('./runner', () => ({
|
||||
}))
|
||||
vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() }))
|
||||
|
||||
import { removeWorktree } from './worktree-removal'
|
||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||
import { readWorktreeList } from './worktree-list-reader'
|
||||
import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict'
|
||||
import { clearGitCapabilityStateForTests } from './git-capability-state'
|
||||
import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove'
|
||||
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
|
||||
import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const image = process.env.ORCA_GIT_COMPAT_IMAGE
|
||||
@@ -37,7 +26,6 @@ const dockerUser =
|
||||
: []
|
||||
let root = ''
|
||||
let repo = ''
|
||||
let checkout = ''
|
||||
|
||||
async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> {
|
||||
return image
|
||||
@@ -66,8 +54,6 @@ async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr
|
||||
})
|
||||
}
|
||||
|
||||
const relay: GitExec = (args, cwd) => git(args, cwd)
|
||||
|
||||
async function initializeRepo(cwd: string): Promise<void> {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd))
|
||||
@@ -92,7 +78,6 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-')))
|
||||
repo = join(root, 'repo')
|
||||
checkout = join(root, 'checkout')
|
||||
await initializeRepo(repo)
|
||||
clearGitCapabilityStateForTests()
|
||||
runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd))
|
||||
@@ -103,99 +88,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('worktree safety with the real Git binary', () => {
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s preserves unpublished submodule objects without implicit force',
|
||||
async (host) => {
|
||||
const origin = join(root, 'sub-origin')
|
||||
await initializeRepo(origin)
|
||||
await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub'])
|
||||
await git(['commit', '-qam', 'submodule'])
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout)
|
||||
const sub = join(checkout, 'sub')
|
||||
await git(['checkout', '-qb', 'unpublished'], sub)
|
||||
await writeFile(join(sub, 'local'), 'unpublished work\n')
|
||||
await git(['add', 'local'], sub)
|
||||
await git(
|
||||
[
|
||||
'-c',
|
||||
'user.name=Safety',
|
||||
'-c',
|
||||
'user.email=safety@example.invalid',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
'commit',
|
||||
'-qm',
|
||||
'local'
|
||||
],
|
||||
sub
|
||||
)
|
||||
const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim()
|
||||
await git(['checkout', '--detach', 'HEAD~1'], sub)
|
||||
expect(
|
||||
(await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout
|
||||
).toBe('')
|
||||
|
||||
const remove = (force: boolean) =>
|
||||
host === 'native'
|
||||
? removeWorktree(repo, checkout, force, {
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false }
|
||||
})
|
||||
: removeWorktreeOp(
|
||||
relay,
|
||||
{ worktreePath: checkout, force, deleteBranch: false },
|
||||
new GitCapabilityCache()
|
||||
)
|
||||
await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/)
|
||||
expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit')
|
||||
expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout)
|
||||
await expect(remove(true)).resolves.toEqual({})
|
||||
await expect(readFile(join(checkout, '.git'))).rejects.toThrow()
|
||||
},
|
||||
120_000
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s reads preparation ownership from admin metadata on old porcelain',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '--detach', checkout])
|
||||
const reason = 'orca-create-preparation:v1:12345:retained'
|
||||
await git(['worktree', 'lock', '--reason', reason, checkout])
|
||||
const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('locked'))
|
||||
.join('\n')
|
||||
const capabilities = new GitCapabilityCache()
|
||||
capabilities.rememberUnsupported('worktree-list-z')
|
||||
runner.mockImplementation(async (args: string[], options: { cwd?: string }) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
if (args.includes('-z')) {
|
||||
throw Object.assign(new Error("unknown switch `z'"), {
|
||||
code: 129,
|
||||
stderr: "error: unknown switch `z'"
|
||||
})
|
||||
}
|
||||
return { stdout: oldList, stderr: '' }
|
||||
}
|
||||
return git(args, options.cwd)
|
||||
})
|
||||
const entries =
|
||||
host === 'native'
|
||||
? await readWorktreeList(repo)
|
||||
: await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities)
|
||||
const prepared = entries.find((entry) => entry.path === checkout)
|
||||
expect(prepared).toMatchObject({ locked: true, lockReason: reason })
|
||||
expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true)
|
||||
await rm(checkout, { recursive: true })
|
||||
const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList))
|
||||
expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({
|
||||
locked: true,
|
||||
lockReason: reason
|
||||
})
|
||||
},
|
||||
90_000
|
||||
)
|
||||
|
||||
it.each([
|
||||
['feature', 'Feature'],
|
||||
@@ -226,59 +118,4 @@ describe('worktree safety with the real Git binary', () => {
|
||||
90_000
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s retains detached branches reserved by rebase or bisect',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
|
||||
await git(['checkout', '--detach'], checkout)
|
||||
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'feature', expected, git)
|
||||
: forceDeletePreservedRelayBranch(relay, repo, 'feature', expected)
|
||||
for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) {
|
||||
const file = join(gitDir, ...marker.split('/'))
|
||||
await mkdir(join(file, '..'), { recursive: true })
|
||||
await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n')
|
||||
if (marker === 'BISECT_START') {
|
||||
await writeFile(join(gitDir, 'BISECT_LOG'), '')
|
||||
}
|
||||
await expect(git(['branch', '-D', 'feature'])).rejects.toThrow(
|
||||
/checked out|used by worktree/
|
||||
)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
|
||||
await rm(file)
|
||||
if (marker === 'BISECT_START') {
|
||||
await rm(join(gitDir, 'BISECT_LOG'))
|
||||
}
|
||||
}
|
||||
await expect(remove()).resolves.toBeUndefined()
|
||||
},
|
||||
120_000
|
||||
)
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s restores a deleted ref when detached branch use starts during deletion',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
|
||||
await git(['checkout', '--detach'], checkout)
|
||||
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
|
||||
const racingGit = async (args: string[], cwd: string) => {
|
||||
const result = await git(args, cwd)
|
||||
if (args[0] === 'update-ref' && args[1] === '-d') {
|
||||
await writeFile(join(gitDir, 'BISECT_START'), 'feature\n')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'feature', expected, racingGit)
|
||||
: forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
|
||||
},
|
||||
90_000
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseDocument } from 'yaml'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
|
||||
import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml'
|
||||
import { HermesHookService } from './hook-service'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
|
||||
import { HermesHookService } from './hook-service'
|
||||
|
||||
describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => {
|
||||
|
||||
@@ -2,22 +2,17 @@ import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import { listQuickOpenFiles } from './filesystem-list-files'
|
||||
import { listFilesWithRg } from '../../relay/fs-handler-list-files'
|
||||
import { searchQuickOpenFilePaths } from './filesystem-search-file-paths'
|
||||
import { bundledRipgrepCommand } from '../ripgrep/bundled-ripgrep-path'
|
||||
import { configureRelayBundledRipgrep } from '../../relay/relay-bundled-ripgrep'
|
||||
|
||||
const fixtures: string[] = []
|
||||
beforeEach(() => configureRelayBundledRipgrep(bundledRipgrepCommand()))
|
||||
afterEach(async () => {
|
||||
configureRelayBundledRipgrep(undefined)
|
||||
await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('honors inherited ignores, opt-in links, cycles, retargets and relay parity in real processes', async () => {
|
||||
it('honors inherited ignores, opt-in links, cycles, and retargets in real processes', async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), 'orca-quick-open-options-'))
|
||||
fixtures.push(parent)
|
||||
await mkdir(join(parent, '.git'))
|
||||
@@ -53,8 +48,6 @@ it('honors inherited ignores, opt-in links, cycles, retargets and relay parity i
|
||||
undefined,
|
||||
options
|
||||
)
|
||||
const relay = await listFilesWithRg(root, [], options)
|
||||
expect(local.sort()).toEqual(relay.sort())
|
||||
expect(local.includes('ignored.txt')).toBe(options.includeIgnored)
|
||||
expect(local.includes('linked/linked.md')).toBe(options.followSymlinks)
|
||||
expect(local).not.toContain('cycle/apps/api/.env')
|
||||
@@ -122,13 +115,6 @@ it('validates recent membership independently of top32, ignores, exclusions and
|
||||
getSettings: () => ({}),
|
||||
getFolderWorkspaces: () => []
|
||||
} as unknown as Store
|
||||
const top = await listFilesWithRg(root, [], {
|
||||
searchQuery: 'file',
|
||||
maxResults: 32,
|
||||
includeIgnored: false
|
||||
})
|
||||
expect(top).toHaveLength(32)
|
||||
expect(top).not.toContain('src/file059.ts')
|
||||
const candidates = [
|
||||
'src/file059.ts',
|
||||
'deleted.ts',
|
||||
@@ -148,17 +134,17 @@ it('validates recent membership independently of top32, ignores, exclusions and
|
||||
undefined,
|
||||
options
|
||||
)
|
||||
const relay = await listFilesWithRg(root, ['excluded'], {
|
||||
...options,
|
||||
maxResults: candidates.length
|
||||
})
|
||||
expect(local).toEqual(['src/file059.ts'])
|
||||
expect(relay).toEqual(local)
|
||||
const broad = await listFilesWithRg(root, ['excluded'], {
|
||||
...options,
|
||||
includeIgnored: true,
|
||||
maxResults: candidates.length
|
||||
})
|
||||
const broad = await listQuickOpenFiles(
|
||||
root,
|
||||
store,
|
||||
[join(root, 'excluded')],
|
||||
undefined,
|
||||
candidates.length,
|
||||
undefined,
|
||||
undefined,
|
||||
{ ...options, includeIgnored: true }
|
||||
)
|
||||
expect(broad.sort()).toEqual(['ignored.ts', 'src/file059.ts'])
|
||||
await mkdir(join(root, 'large'))
|
||||
for (let index = 0; index < 20_020; index += 1) {
|
||||
@@ -180,11 +166,4 @@ it('validates recent membership independently of top32, ignores, exclusions and
|
||||
includeIgnored: false
|
||||
})
|
||||
).toEqual([missing])
|
||||
expect(
|
||||
await listFilesWithRg(root, [], {
|
||||
candidatePaths: [missing],
|
||||
includeIgnored: false,
|
||||
maxResults: 1
|
||||
})
|
||||
).toEqual([missing])
|
||||
}, 60_000)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { validateGitExecArgs } from '../../relay/git-exec-validator'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import {
|
||||
@@ -84,20 +83,6 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
|
||||
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
|
||||
})
|
||||
|
||||
it('sends only argv the relay accepts, so SSH cleanup is not silently skipped', async () => {
|
||||
const exec = makeExec()
|
||||
await cleanupUnusedWorktreePushTargetRemoteWithExec(
|
||||
REPO_PATH,
|
||||
'repo-1::/wt/a',
|
||||
forkTarget(),
|
||||
storeOf({ 'repo-1::/wt/a': forkTarget() }),
|
||||
exec
|
||||
)
|
||||
for (const [args] of exec.mock.calls) {
|
||||
expect(() => validateGitExecArgs(args)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a remote Orca did not create (remoteCreated falsy)', async () => {
|
||||
const exec = makeExec()
|
||||
await cleanupUnusedWorktreePushTargetRemoteWithExec(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { validateGitExecArgs } from '../../relay/git-exec-validator'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
|
||||
@@ -234,25 +233,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => {
|
||||
expect(reclaimed).toEqual([])
|
||||
})
|
||||
|
||||
it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => {
|
||||
// Exercise every branch (config probe, for-each-ref probe, and the reclaim itself).
|
||||
const exec = makeExec({
|
||||
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
|
||||
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
|
||||
localBranches: 'main'
|
||||
})
|
||||
await reconcileOrphanedPrRemotesWithExec(
|
||||
REPO_PATH,
|
||||
REPO_ID,
|
||||
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
|
||||
exec,
|
||||
[]
|
||||
)
|
||||
expect(exec.mock.calls.length).toBeGreaterThan(0)
|
||||
for (const [args] of exec.mock.calls) {
|
||||
expect(() => validateGitExecArgs(args)).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileOrphanedPrRemotes rate limiting', () => {
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { validateGitExecArgs } from '../../relay/git-exec-validator'
|
||||
import {
|
||||
getSshGitProviderMock,
|
||||
getActiveMultiplexerMock,
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
gitExecFileAsyncMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
)
|
||||
vi.mock('../git/worktree', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock('../git/repo', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
||||
)
|
||||
vi.mock('../git/git-username', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
|
||||
.resolveLocalGitUsernameMock
|
||||
}))
|
||||
vi.mock('../github/client', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
|
||||
)
|
||||
vi.mock('../source-control/hosted-review', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
|
||||
)
|
||||
vi.mock('../providers/ssh-git-dispatch', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
|
||||
)
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
|
||||
)
|
||||
vi.mock('./worktree-symlinks', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
|
||||
)
|
||||
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
|
||||
vi.mock('../ssh/ssh-target-registry', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
|
||||
)
|
||||
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
|
||||
vi.mock('../setup-runner-script-text', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../worktree-runner-script', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../effective-hook-config', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('./worktree-logic', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../terminal-history-deletion', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
|
||||
)
|
||||
vi.mock('../ports/advertised-url-watcher', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-space-analysis-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
|
||||
)
|
||||
vi.mock('../runtime/worktree-teardown', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
|
||||
)
|
||||
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
|
||||
|
||||
describe('registerWorktreeHandlers', () => {
|
||||
beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
})
|
||||
|
||||
// Was "adds the fork remote ... through git.exec": create used to mint the fork
|
||||
// remote unconditionally. It now defers to first sync (#17828) -- split in two so
|
||||
// each half stays true to a single claim: create stays a no-op, sync still mints.
|
||||
it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
// Why: run every exec through the relay's own validator so a create shape the
|
||||
// relay rejects fails here instead of on the user's SSH host.
|
||||
const exec = vi.fn().mockImplementation(async (args: string[]) => {
|
||||
validateGitExecArgs(args)
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
throw Object.assign(new Error('missing exact ref'), { code: 1 })
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
const provider = {
|
||||
exec,
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/repo-contributor-fix',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/contributor/fix',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() }
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'contributor-fix',
|
||||
branchNameOverride: 'contributor/fix',
|
||||
pushTarget: {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git'
|
||||
}
|
||||
})
|
||||
|
||||
expect(exec).not.toHaveBeenCalledWith(
|
||||
['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'],
|
||||
'/remote/repo'
|
||||
)
|
||||
// fetchRemoteTrackingRef IS called once here, but for create's unrelated
|
||||
// base-ref refresh (origin/main) -- not for the fork remote, which defers.
|
||||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'pr-contributor-orca',
|
||||
'contributor/fix',
|
||||
'refs/remotes/pr-contributor-orca/contributor/fix'
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git'
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is
|
||||
// exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is
|
||||
// "first sync" without needing the sync IPC handlers registered in this harness.
|
||||
it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => {
|
||||
const exec = vi.fn().mockImplementation(async (args: string[]) => {
|
||||
validateGitExecArgs(args)
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
throw new Error('No such remote')
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined)
|
||||
const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined)
|
||||
const target = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git'
|
||||
}
|
||||
|
||||
const result = await materializeWorktreePushTargetRemoteSsh(
|
||||
{ exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider,
|
||||
'/remote/repo',
|
||||
target
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ...target, remoteCreated: true })
|
||||
expect(exec).toHaveBeenCalledWith(
|
||||
['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'],
|
||||
'/remote/repo'
|
||||
)
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'pr-contributor-orca',
|
||||
'contributor/fix',
|
||||
'refs/remotes/pr-contributor-orca/contributor/fix'
|
||||
)
|
||||
expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca')
|
||||
})
|
||||
|
||||
// The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved
|
||||
// cases used to be exercised here because create minted the remote unconditionally.
|
||||
// That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at
|
||||
// create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on
|
||||
// first sync. Coverage for all three moved with it to
|
||||
// worktree-remote-push-target-materialization.test.ts, which calls that function directly.
|
||||
})
|
||||
|
||||
describe('worktree preserved branch fork remotes', () => {
|
||||
beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
})
|
||||
|
||||
it('preserves the branch on remove for worktrees created from an existing local branch', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(removeWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/feature-wt',
|
||||
false,
|
||||
expect.objectContaining({
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: expect.objectContaining({
|
||||
branch: 'feature',
|
||||
head: 'feature',
|
||||
path: '/workspace/feature-wt'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('force-deletes a branch that was preserved by safe worktree removal', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||||
})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
branchName: 'feature/test',
|
||||
expectedHead: 'def456'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ deleted: true })
|
||||
expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
})
|
||||
|
||||
it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
const worktreeId = 'repo-ssh::/remote/feature-wt'
|
||||
const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined }
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: repo.path,
|
||||
head: 'main',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/remote/feature-wt',
|
||||
head: 'def456',
|
||||
branch: 'feature/test',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
removeWorktree: vi.fn().mockResolvedValue({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'def456' }
|
||||
}),
|
||||
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
|
||||
}
|
||||
store.getRepos.mockReturnValue([localRepo, repo])
|
||||
store.getRepo.mockReturnValue(localRepo)
|
||||
store.getWorktreeMeta.mockReturnValue(
|
||||
makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true })
|
||||
)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() })
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' })
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId,
|
||||
branchName: 'feature/test',
|
||||
expectedHead: 'def456',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ deleted: true })
|
||||
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'feature/test',
|
||||
'def456'
|
||||
)
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stale preserved-branch cleanup actions with an old head', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'new456' }
|
||||
})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
branchName: 'feature/test',
|
||||
expectedHead: 'old123'
|
||||
})
|
||||
).rejects.toThrow('No preserved branch cleanup is pending')
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes an unused Orca-created fork remote after deleting its worktree', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
const pushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'feature/from-fork',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||||
remoteCreated: true
|
||||
}
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget })
|
||||
})
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
throw new Error('no branch config')
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an Orca-created fork remote while another worktree still uses it', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
const pushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'feature/from-fork',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||||
remoteCreated: true
|
||||
}
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
|
||||
'repo-1::/workspace/other-wt': makeWorktreeMeta({
|
||||
pushTarget: {
|
||||
...pushTarget,
|
||||
branchName: 'other-branch'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
['remote', 'remove', 'pr-contributor-orca'],
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
const pushTarget = {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'feature/from-fork',
|
||||
remoteUrl: 'https://github.com/contributor/orca.git',
|
||||
remoteCreated: true
|
||||
}
|
||||
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
|
||||
'repo-2::/workspace/other-wt': makeWorktreeMeta({
|
||||
pushTarget: {
|
||||
...pushTarget,
|
||||
branchName: 'other-branch'
|
||||
}
|
||||
})
|
||||
})
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
throw new Error('no branch config')
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
|
||||
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
|
||||
import { isPlainObject } from '../agent-hooks/installer-utils'
|
||||
import { KiroHookService } from './hook-service'
|
||||
import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings'
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api'
|
||||
import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities'
|
||||
import {
|
||||
admitPluginPanelCall,
|
||||
createPluginPanelCallAdmission
|
||||
} from '../../shared/plugins/plugin-panel-call-admission'
|
||||
import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge'
|
||||
import type { MethodHandler } from '../../wsl-guest/dispatcher'
|
||||
import {
|
||||
RELAY_PLUGIN_PANEL_HOST_CALL_METHOD,
|
||||
RELAY_PLUGIN_WORKER_HOST_CALL_METHOD,
|
||||
registerRelayPluginHostCallHandlers
|
||||
} from '../../relay/plugin-host-call-handler'
|
||||
import {
|
||||
executePluginHostCallRequest,
|
||||
type PluginHostCallPolicy,
|
||||
type ResolvePluginHostCallPolicy
|
||||
} from './plugin-host-call-adapter'
|
||||
import type { PluginHostServices } from './plugin-host-methods'
|
||||
|
||||
const PLUGIN_KEY = 'orca-samples.demo'
|
||||
const WORKTREE_ID = 'repo-id::/Users/private/orca'
|
||||
const TERMINAL_ID = 'terminal:local:one'
|
||||
|
||||
type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise<PluginPanelActionOutcome>
|
||||
|
||||
function createServices(): PluginHostServices {
|
||||
return {
|
||||
resolveActiveWorktreeContext: vi.fn().mockResolvedValue({
|
||||
worktreeId: WORKTREE_ID,
|
||||
branch: 'main',
|
||||
displayName: 'Orca',
|
||||
path: '/Users/private/orca'
|
||||
}),
|
||||
listWorktreeTerminals: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]),
|
||||
sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }),
|
||||
dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }),
|
||||
storage: {
|
||||
get: vi.fn().mockReturnValue('stored'),
|
||||
set: vi.fn().mockReturnValue({ ok: true }),
|
||||
delete: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue(['alpha'])
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }),
|
||||
set: vi.fn().mockReturnValue({ ok: true }),
|
||||
delete: vi.fn()
|
||||
},
|
||||
settings: {
|
||||
getAll: vi.fn().mockReturnValue({ theme: 'dark' }),
|
||||
set: vi.fn().mockReturnValue({ ok: true })
|
||||
},
|
||||
subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events)
|
||||
}
|
||||
}
|
||||
|
||||
function createPolicy(
|
||||
grantedCapabilities: readonly PluginCapabilityKind[] | null,
|
||||
services: PluginHostServices = createServices(),
|
||||
audit = { record: vi.fn().mockResolvedValue(undefined) }
|
||||
): PluginHostCallPolicy {
|
||||
return { grantedCapabilities, services, audit }
|
||||
}
|
||||
|
||||
function createAdapters(
|
||||
resolvePolicy: ResolvePluginHostCallPolicy,
|
||||
limits?: { maxBytes?: number; maxMessages?: number; perMs?: number }
|
||||
): Record<string, HostCallAdapter> {
|
||||
const relayHandlers = new Map<string, MethodHandler>()
|
||||
registerRelayPluginHostCallHandlers(
|
||||
{ onRequest: (method, handler) => relayHandlers.set(method, handler) },
|
||||
(context) => (context.clientId === 1 ? PLUGIN_KEY : null),
|
||||
resolvePolicy,
|
||||
{ panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) }
|
||||
)
|
||||
const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 })
|
||||
return {
|
||||
'desktop-main': async (request, viaPanel) => {
|
||||
if (viaPanel) {
|
||||
const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request)
|
||||
if (admissionRefusal) {
|
||||
return admissionRefusal
|
||||
}
|
||||
}
|
||||
return executePluginHostCallRequest({
|
||||
pluginKey: PLUGIN_KEY,
|
||||
request,
|
||||
viaPanel,
|
||||
resolvePolicy
|
||||
})
|
||||
},
|
||||
relay: async (request, viaPanel) => {
|
||||
const registeredMethod = viaPanel
|
||||
? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD
|
||||
: RELAY_PLUGIN_WORKER_HOST_CALL_METHOD
|
||||
return (await relayHandlers.get(registeredMethod)!(request as Record<string, unknown>, {
|
||||
clientId: 1,
|
||||
isStale: () => false
|
||||
})) as PluginPanelActionOutcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const successParams: Record<string, unknown> = {
|
||||
'workspace.readContext': {},
|
||||
'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true },
|
||||
'notifications.show': { title: 'Hello' },
|
||||
'storage.get': { key: 'alpha' },
|
||||
'storage.set': { key: 'alpha', value: 1 },
|
||||
'storage.delete': { key: 'alpha' },
|
||||
'storage.keys': {},
|
||||
'secrets.get': { key: 'token' },
|
||||
'secrets.set': { key: 'token', value: 'secret' },
|
||||
'secrets.delete': { key: 'token' },
|
||||
'settings.get': {},
|
||||
'settings.set': { key: 'theme', value: 'dark' },
|
||||
'events.subscribe': { events: ['worktree.created'] }
|
||||
}
|
||||
|
||||
describe('plugin host main/relay conformance', () => {
|
||||
it('runs a granted success through both transports for all 13 v0 methods', async () => {
|
||||
expect(PLUGIN_HOST_API_V0).toHaveLength(13)
|
||||
expect(Object.keys(successParams).sort()).toEqual(
|
||||
PLUGIN_HOST_API_V0.map((entry) => entry.name).sort()
|
||||
)
|
||||
expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true)
|
||||
expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true)
|
||||
|
||||
for (const spec of PLUGIN_HOST_API_V0) {
|
||||
const policy = createPolicy([spec.capability])
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(policy)
|
||||
const outcomes = await Promise.all(
|
||||
Object.values(createAdapters(resolvePolicy)).map((adapter) =>
|
||||
adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel)
|
||||
)
|
||||
)
|
||||
expect(outcomes, spec.name).toHaveLength(2)
|
||||
expect(outcomes[0], spec.name).toEqual(outcomes[1])
|
||||
expect(outcomes[0], spec.name).toMatchObject({ ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('projects workspace context without host paths on main and relay', async () => {
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read']))
|
||||
for (const adapter of Object.values(createAdapters(resolvePolicy))) {
|
||||
const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true)
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
branch: 'main',
|
||||
displayName: 'Orca',
|
||||
terminals: [{ id: TERMINAL_ID }]
|
||||
}
|
||||
})
|
||||
expect(outcome).not.toHaveProperty('value.path')
|
||||
expect(outcome).not.toHaveProperty('value.worktreeId')
|
||||
}
|
||||
})
|
||||
|
||||
const deniedCases: {
|
||||
name: string
|
||||
request: unknown
|
||||
viaPanel: boolean
|
||||
policy: () => PluginHostCallPolicy
|
||||
code: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'missing or stale consent',
|
||||
request: { method: 'workspace.readContext', params: {} },
|
||||
viaPanel: true,
|
||||
policy: () => createPolicy(null),
|
||||
code: 'consent_required'
|
||||
},
|
||||
{
|
||||
name: 'missing capability',
|
||||
request: { method: 'workspace.readContext', params: {} },
|
||||
viaPanel: true,
|
||||
policy: () => createPolicy([]),
|
||||
code: 'capability_denied'
|
||||
},
|
||||
{
|
||||
name: 'unknown method',
|
||||
request: { method: 'workspace.erase', params: {} },
|
||||
viaPanel: false,
|
||||
policy: () => createPolicy(['workspace:read']),
|
||||
code: 'unknown_method'
|
||||
},
|
||||
{
|
||||
name: 'malformed params',
|
||||
request: {
|
||||
method: 'terminal.sendText',
|
||||
params: { terminalId: TERMINAL_ID, text: '' }
|
||||
},
|
||||
viaPanel: true,
|
||||
policy: () => createPolicy(['terminal:send']),
|
||||
code: 'invalid_params'
|
||||
},
|
||||
{
|
||||
name: 'panel-forbidden method',
|
||||
request: { method: 'storage.get', params: { key: 'alpha' } },
|
||||
viaPanel: true,
|
||||
policy: () => createPolicy(['storage']),
|
||||
code: 'panel_forbidden'
|
||||
},
|
||||
{
|
||||
name: 'malformed result',
|
||||
request: {
|
||||
method: 'notifications.show',
|
||||
params: { title: 'Hello' }
|
||||
},
|
||||
viaPanel: true,
|
||||
policy: () => {
|
||||
const services = createServices()
|
||||
services.dispatchPluginNotification = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean })
|
||||
return createPolicy(['notifications:show'], services)
|
||||
},
|
||||
code: 'action_failed'
|
||||
},
|
||||
{
|
||||
name: 'mutation audit failure',
|
||||
request: {
|
||||
method: 'storage.set',
|
||||
params: { key: 'alpha', value: 1 }
|
||||
},
|
||||
viaPanel: false,
|
||||
policy: () =>
|
||||
createPolicy(['storage'], createServices(), {
|
||||
record: vi.fn().mockRejectedValue(new Error('disk full'))
|
||||
}),
|
||||
code: 'action_failed'
|
||||
}
|
||||
]
|
||||
|
||||
it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => {
|
||||
const outcomes: PluginPanelActionOutcome[] = []
|
||||
for (const adapterName of ['desktop-main', 'relay']) {
|
||||
const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy())
|
||||
const adapter = createAdapters(resolvePolicy)[adapterName]!
|
||||
outcomes.push(await adapter(testCase.request, testCase.viaPanel))
|
||||
}
|
||||
expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code })
|
||||
expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code })
|
||||
expect(outcomes[0]).toEqual(outcomes[1])
|
||||
})
|
||||
|
||||
it('enforces the same per-plugin panel budget on desktop main and relay', async () => {
|
||||
for (const adapterName of ['desktop-main', 'relay']) {
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
|
||||
const adapter = createAdapters(resolvePolicy, {
|
||||
maxMessages: 1,
|
||||
perMs: 10_000
|
||||
})[adapterName]!
|
||||
|
||||
await expect(
|
||||
adapter({ method: 'notifications.show', params: { title: 'first' } }, true)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
await expect(
|
||||
adapter({ method: 'notifications.show', params: { title: 'second' } }, true)
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'rate_limited',
|
||||
error: 'too many panel requests'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('charges malformed and oversized panel traffic before schema parsing', async () => {
|
||||
for (const adapterName of ['desktop-main', 'relay']) {
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
|
||||
const adapter = createAdapters(resolvePolicy, {
|
||||
maxBytes: 128,
|
||||
maxMessages: 2,
|
||||
perMs: 10_000
|
||||
})[adapterName]!
|
||||
|
||||
await expect(
|
||||
adapter({ method: 'notifications.show', unexpected: true }, true)
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
|
||||
await expect(
|
||||
adapter(
|
||||
{
|
||||
method: 'notifications.show',
|
||||
params: { title: 'x'.repeat(256) }
|
||||
},
|
||||
true
|
||||
)
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'invalid_request',
|
||||
error: 'panel message exceeds the size limit'
|
||||
})
|
||||
await expect(
|
||||
adapter({ method: 'notifications.show', params: { title: 'third' } }, true)
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'rate_limited',
|
||||
error: 'too many panel requests'
|
||||
})
|
||||
expect(resolvePolicy).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('binds relay plugin identity to the requesting connection', async () => {
|
||||
const relayHandlers = new Map<string, MethodHandler>()
|
||||
const services = createServices()
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services))
|
||||
const resolveIdentity = vi
|
||||
.fn()
|
||||
.mockImplementation(({ clientId }: { clientId: number }) =>
|
||||
clientId === 7 ? PLUGIN_KEY : null
|
||||
)
|
||||
registerRelayPluginHostCallHandlers(
|
||||
{ onRequest: (method, handler) => relayHandlers.set(method, handler) },
|
||||
resolveIdentity,
|
||||
resolvePolicy
|
||||
)
|
||||
const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)!
|
||||
|
||||
await expect(
|
||||
handler(
|
||||
{ method: 'storage.get', params: { key: 'alpha' } },
|
||||
{ clientId: 7, isStale: () => false }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha')
|
||||
|
||||
await expect(
|
||||
handler(
|
||||
{ method: 'storage.get', params: { key: 'alpha' } },
|
||||
{ clientId: 8, isStale: () => false }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, code: 'unavailable' })
|
||||
expect(resolvePolicy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => {
|
||||
const requests = [
|
||||
{ pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } },
|
||||
{
|
||||
pluginKey: PLUGIN_KEY,
|
||||
method: 'storage.get',
|
||||
params: { key: 'alpha' },
|
||||
grantedCapabilities: ['storage']
|
||||
},
|
||||
{
|
||||
pluginKey: PLUGIN_KEY,
|
||||
method: 'storage.get',
|
||||
params: { key: 'alpha' },
|
||||
viaPanel: false
|
||||
}
|
||||
]
|
||||
for (const request of requests) {
|
||||
for (const adapterName of ['desktop-main', 'relay']) {
|
||||
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage']))
|
||||
const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false)
|
||||
expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' })
|
||||
expect(resolvePolicy).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,6 @@ import { readlink } from 'node:fs/promises'
|
||||
/**
|
||||
* Resolve the current working directory of a local process by pid.
|
||||
*
|
||||
* Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron
|
||||
* main process have separate build graphs, and cross-importing across them
|
||||
* is not a pattern used in this repo. The function is short and pure, and
|
||||
* the duplication is cheaper than reshaping both bundle graphs.
|
||||
*
|
||||
* Tries `/proc/<pid>/cwd` on Linux, falls back to `lsof -d cwd` on macOS.
|
||||
* Returns `''` when neither works (including Windows, where `/proc` is
|
||||
* absent and `lsof` is not native).
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { ReviewDraftContextError } from '../../shared/review-draft-context-error'
|
||||
import { RelayContext } from '../../relay/context'
|
||||
import { RelayDispatcher } from '../../wsl-guest/dispatcher'
|
||||
import { GitHandler } from '../../relay/git-handler'
|
||||
import { createGitTempDir, removeGitTempDir } from '../../relay/git-handler-test-harness'
|
||||
import { gitCommit, gitInit } from '../../relay/git-handler-test-setup'
|
||||
import {
|
||||
encodeJsonRpcFrame,
|
||||
FrameDecoder,
|
||||
MessageType,
|
||||
parseJsonRpcMessage
|
||||
} from '../../wsl-guest/protocol'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { SshGitProvider } from './ssh-git-provider'
|
||||
import { createMockMux } from './ssh-git-provider-test-harness'
|
||||
import { execSshReviewDraft } from './ssh-review-draft-context'
|
||||
|
||||
const input = {
|
||||
base: 'main',
|
||||
currentTitle: 'Existing review title',
|
||||
currentBody: 'Existing review body',
|
||||
currentDraft: true
|
||||
}
|
||||
const mergeBase = 'a'.repeat(40)
|
||||
const disposals: (() => void)[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
disposals.splice(0).forEach((dispose) => dispose())
|
||||
await Promise.all(directories.splice(0).map(removeGitTempDir))
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
function providerForMux(mux: ReturnType<typeof createMockMux>): SshGitProvider {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements every request, stream notification, disposal and notify method used by these provider calls.
|
||||
return new SshGitProvider('review-host', mux as unknown as SshChannelMultiplexer)
|
||||
}
|
||||
|
||||
function createWireProvider() {
|
||||
const mux = createMockMux()
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
||||
>()
|
||||
const notifications = new Map<string, Set<(params: Record<string, unknown>) => void>>()
|
||||
const decoder = new FrameDecoder((frame) => {
|
||||
if (frame.type !== MessageType.Regular) {
|
||||
return
|
||||
}
|
||||
const message = parseJsonRpcMessage(frame.payload)
|
||||
if ('method' in message) {
|
||||
notifications.get(message.method)?.forEach((listener) => listener(message.params ?? {}))
|
||||
return
|
||||
}
|
||||
const request = pending.get(message.id)
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
pending.delete(message.id)
|
||||
if (message.error) {
|
||||
request.reject(Object.assign(new Error(message.error.message), { code: message.error.code }))
|
||||
} else {
|
||||
request.resolve(message.result)
|
||||
}
|
||||
})
|
||||
const dispatcher = new RelayDispatcher((frame) => decoder.feed(frame))
|
||||
const handler = new GitHandler(dispatcher, new RelayContext())
|
||||
let sequence = 0
|
||||
mux.request.mockImplementation((method: string, params: Record<string, unknown>) => {
|
||||
const id = ++sequence
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
|
||||
})
|
||||
})
|
||||
mux.notify.mockImplementation((method: string, params: Record<string, unknown>) => {
|
||||
dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, ++sequence, 0))
|
||||
})
|
||||
mux.onNotificationByMethod.mockImplementation(
|
||||
(method: string, listener: (params: Record<string, unknown>) => void) => {
|
||||
const listeners = notifications.get(method) ?? new Set()
|
||||
notifications.set(method, listeners)
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
)
|
||||
disposals.push(() => {
|
||||
handler.dispose()
|
||||
dispatcher.dispose()
|
||||
pending.forEach((request) => request.reject(new Error('Test relay disposed')))
|
||||
pending.clear()
|
||||
})
|
||||
return { provider: providerForMux(mux), mux, dispatcher, notifications }
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]) {
|
||||
const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 10_000 })
|
||||
if (result.code !== 0) {
|
||||
throw Object.assign(new Error(result.stderr), { code: result.code })
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
async function createReviewFixture(large: boolean) {
|
||||
const root = createGitTempDir()
|
||||
directories.push(root)
|
||||
const globalConfig = join(root, 'global.gitconfig')
|
||||
await writeFile(globalConfig, '')
|
||||
// Newer desktop Git preferences may be rejected while a baseline fixture loads config.
|
||||
vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig)
|
||||
vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1')
|
||||
const remote = join(root, 'remote repo')
|
||||
const worktree = join(root, 'review worktree')
|
||||
await mkdir(remote)
|
||||
gitInit(remote)
|
||||
await git(remote, ['checkout', '-b', 'main'])
|
||||
await writeFile(join(remote, 'README.md'), 'Original content\n')
|
||||
gitCommit(remote, 'Initial base')
|
||||
await git(root, ['clone', remote, worktree])
|
||||
const originalBase = (await git(worktree, ['rev-parse', 'HEAD'])).stdout.trim()
|
||||
await writeFile(join(remote, 'base-only.txt'), 'Advance the remote base after cloning\n')
|
||||
gitCommit(remote, 'Advance base')
|
||||
await git(worktree, ['checkout', '-b', 'feature/review'])
|
||||
await writeFile(join(worktree, 'README.md'), 'Committed review evidence\n')
|
||||
await writeFile(
|
||||
join(worktree, 'new file.txt'),
|
||||
large
|
||||
? 'Committed line with unicode café for a streamed review.\n'.repeat(36_000)
|
||||
: 'New evidence\n'
|
||||
)
|
||||
gitCommit(worktree, 'Add review evidence')
|
||||
await writeFile(join(worktree, 'README.md'), 'Uncommitted content must stay out of the draft\n')
|
||||
return { worktree, originalBase }
|
||||
}
|
||||
|
||||
describe('SSH review draft through the real relay dispatcher', () => {
|
||||
it.each([false, true])('matches complete local context with streamed patch=%s', async (large) => {
|
||||
const { worktree, originalBase } = await createReviewFixture(large)
|
||||
const local = await getPullRequestDraftContext((args) => git(worktree, args), input)
|
||||
expect(local).toMatchObject({
|
||||
branch: 'feature/review',
|
||||
commitSummary: '- Add review evidence',
|
||||
changeSummary: expect.stringContaining('new file.txt'),
|
||||
patch: expect.stringContaining('+Committed review evidence')
|
||||
})
|
||||
await git(worktree, ['update-ref', 'refs/remotes/origin/main', originalBase])
|
||||
const { provider, mux, notifications } = createWireProvider()
|
||||
|
||||
const remote = await getPullRequestDraftContext(
|
||||
(args, options) => execSshReviewDraft(provider, args, worktree, options),
|
||||
input
|
||||
)
|
||||
|
||||
expect(remote).toEqual(local)
|
||||
expect(remote?.patch).not.toContain('Uncommitted content')
|
||||
expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', {
|
||||
worktreePath: worktree,
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(mux.request.mock.calls.filter(([method]) => method === 'git.reviewDiff')).toHaveLength(2)
|
||||
expect(
|
||||
mux.request.mock.calls.some(
|
||||
([method, params]) =>
|
||||
method === 'git.exec' && (params.args[0] === 'fetch' || params.args[0] === 'diff')
|
||||
)
|
||||
).toBe(false)
|
||||
expect([...notifications.values()].every((listeners) => listeners.size === 0)).toBe(true)
|
||||
if (large) {
|
||||
expect(mux.notify).toHaveBeenCalledWith('git.responseAck', expect.any(Object))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function createContextProvider() {
|
||||
const mux = createMockMux()
|
||||
const runRequest = async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === 'git.fetchRemoteTrackingRef') {
|
||||
return undefined
|
||||
}
|
||||
if (method === 'git.reviewDiff') {
|
||||
return { stdout: 'remote evidence', stderr: '' }
|
||||
}
|
||||
const args = params.args
|
||||
if (!Array.isArray(args)) {
|
||||
throw new Error('Missing context Git arguments')
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'branch') {
|
||||
return { stdout: 'feature/review\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'merge-base') {
|
||||
return { stdout: `${mergeBase}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'log') {
|
||||
return { stdout: '- committed evidence\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
}
|
||||
mux.request.mockImplementation(runRequest)
|
||||
return { mux, provider: providerForMux(mux), runRequest }
|
||||
}
|
||||
|
||||
describe('SSH review draft failures and routing', () => {
|
||||
it('rejects an old-host missing diff method instead of returning commit-only context', async () => {
|
||||
const { mux, provider, runRequest } = createContextProvider()
|
||||
mux.request.mockImplementation((method, params) => {
|
||||
if (method === 'git.reviewDiff') {
|
||||
return Promise.reject(Object.assign(new Error('Method not found'), { code: -32601 }))
|
||||
}
|
||||
return runRequest(method, params)
|
||||
})
|
||||
|
||||
await expect(
|
||||
getPullRequestDraftContext(
|
||||
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
|
||||
input
|
||||
)
|
||||
).rejects.toThrow('Reconnect the SSH target')
|
||||
})
|
||||
|
||||
it.each(['SSH connection closed', 'Git request timed out', 'git output exceeded maxBuffer.'])(
|
||||
'rejects incomplete context after %s',
|
||||
async (message) => {
|
||||
const { mux, provider, runRequest } = createContextProvider()
|
||||
mux.request.mockImplementation((method, params) => {
|
||||
if (method === 'git.reviewDiff') {
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
return runRequest(method, params)
|
||||
})
|
||||
const draft = getPullRequestDraftContext(
|
||||
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
|
||||
input
|
||||
)
|
||||
await expect(draft).rejects.toBeInstanceOf(ReviewDraftContextError)
|
||||
await expect(draft).rejects.toThrow(message)
|
||||
}
|
||||
)
|
||||
|
||||
it('stops before reading evidence when an old host lacks the narrow fetch method', async () => {
|
||||
const { mux, provider, runRequest } = createContextProvider()
|
||||
mux.request.mockImplementation((method, params) => {
|
||||
if (method === 'git.fetchRemoteTrackingRef') {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('Method not found: git.fetchRemoteTrackingRef'), { code: -32601 })
|
||||
)
|
||||
}
|
||||
return runRequest(method, params)
|
||||
})
|
||||
await expect(
|
||||
getPullRequestDraftContext(
|
||||
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
|
||||
input
|
||||
)
|
||||
).rejects.toThrow('Fetch before generating PR details failed')
|
||||
expect(mux.request.mock.calls.some(([method]) => method === 'git.reviewDiff')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ stdout: 'patch' },
|
||||
{ stdout: 3, stderr: '' },
|
||||
{ stdout: '', stderr: null }
|
||||
])('refuses malformed remote evidence %j', async (reply) => {
|
||||
const mux = createMockMux()
|
||||
mux.request.mockResolvedValue(reply)
|
||||
await expect(providerForMux(mux).readReviewDiff('/repo', mergeBase, 'patch')).rejects.toThrow(
|
||||
ReviewDraftContextError
|
||||
)
|
||||
})
|
||||
|
||||
it.each([{ timeout: 321 }, { timeoutMs: 654 }, { timeout: 321, timeoutMs: 654 }])(
|
||||
'forwards draft deadlines %j to the narrow diff request',
|
||||
async (options) => {
|
||||
const mux = createMockMux()
|
||||
mux.request.mockResolvedValue({ stdout: 'patch', stderr: '' })
|
||||
await execSshReviewDraft(
|
||||
providerForMux(mux),
|
||||
['diff', '--name-status', `${mergeBase}..HEAD`],
|
||||
'/repo',
|
||||
options
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'git.reviewDiff',
|
||||
{
|
||||
worktreePath: '/repo',
|
||||
mergeBase,
|
||||
format: 'name-status',
|
||||
__streamResponse: true
|
||||
},
|
||||
{ signal: undefined, timeoutMs: options.timeoutMs ?? options.timeout }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps non-draft fetch and diff shapes on the restricted generic path', async () => {
|
||||
const mux = createMockMux()
|
||||
mux.request.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
const provider = providerForMux(mux)
|
||||
for (const args of [
|
||||
['fetch', '--all'],
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/heads/overwrite'],
|
||||
['diff', '--name-status', '--no-ext-diff', `${mergeBase}..HEAD`],
|
||||
['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', `${mergeBase}...HEAD`]
|
||||
]) {
|
||||
await execSshReviewDraft(provider, args, '/repo', { timeoutMs: 456 })
|
||||
expect(mux.request).toHaveBeenLastCalledWith(
|
||||
'git.exec',
|
||||
{
|
||||
args,
|
||||
cwd: '/repo',
|
||||
__streamResponse: true
|
||||
},
|
||||
{ signal: undefined, timeoutMs: 456 }
|
||||
)
|
||||
}
|
||||
expect(mux.request.mock.calls.every(([method]) => method === 'git.exec')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { FILE_METHODS } from './rpc/methods/files'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { pathsExistOnRelay } from '../../relay/fs-path-existence'
|
||||
import { statRelayPath } from '../../relay/fs-path-metadata-requests'
|
||||
let root: string | undefined
|
||||
const connection = 'batch-fixture-host'
|
||||
afterEach(async () => {
|
||||
unregisterSshFilesystemProvider(connection)
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
root = undefined
|
||||
})
|
||||
async function setup(legacy = false) {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-'))
|
||||
const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`)
|
||||
await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture')))
|
||||
const provider = {
|
||||
pathsExist: legacy
|
||||
? undefined
|
||||
: vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })),
|
||||
stat: vi.fn((filePath: string) => statRelayPath({ filePath }))
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here.
|
||||
registerSshFilesystemProvider(connection, provider as never)
|
||||
const resolveTarget = vi.fn(async () => ({
|
||||
worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' },
|
||||
executionHostId: `ssh:${connection}`
|
||||
}))
|
||||
const host = {
|
||||
getRuntimeId: () => 'runtime-fixture',
|
||||
requireStore: vi.fn(() => {
|
||||
throw new Error('Local store should not be read')
|
||||
}),
|
||||
resolveRuntimeFileTarget: resolveTarget
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads.
|
||||
const commands = new RuntimeFileCommands(host as never)
|
||||
const runtime = {
|
||||
getRuntimeId: host.getRuntimeId,
|
||||
pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands),
|
||||
statRuntimeFile: commands.statRuntimeFile.bind(commands)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture.
|
||||
const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS })
|
||||
const dispatch = (relativePaths: string[]) =>
|
||||
dispatcher.dispatch({
|
||||
id: 'batch-1',
|
||||
authToken: 'fixture',
|
||||
method: 'files.pathsExist',
|
||||
params: { worktree: 'id:folder-1', relativePaths }
|
||||
})
|
||||
return { names, provider, resolveTarget, host, dispatch }
|
||||
}
|
||||
it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => {
|
||||
const f = await setup()
|
||||
expect(await f.dispatch(f.names)).toMatchObject({
|
||||
ok: true,
|
||||
result: f.names.map(() => ({ exists: true }))
|
||||
})
|
||||
expect(f.resolveTarget).toHaveBeenCalledTimes(1)
|
||||
expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1')
|
||||
expect(f.provider.pathsExist).toHaveBeenCalledTimes(1)
|
||||
expect(f.provider.stat).not.toHaveBeenCalled()
|
||||
expect(f.host.requireStore).not.toHaveBeenCalled()
|
||||
expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false })
|
||||
expect(f.provider.pathsExist).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('legacy provider preserves all answers through scoped scalar stats', async () => {
|
||||
const f = await setup(true)
|
||||
expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({
|
||||
ok: true,
|
||||
result: [...f.names.map(() => ({ exists: true })), { exists: false }]
|
||||
})
|
||||
expect(f.provider.stat).toHaveBeenCalledTimes(9)
|
||||
expect(f.host.requireStore).not.toHaveBeenCalled()
|
||||
})
|
||||
it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => {
|
||||
const f = await setup()
|
||||
unregisterSshFilesystemProvider(connection)
|
||||
expect(await f.dispatch(f.names)).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('Remote connection dropped') }
|
||||
})
|
||||
expect(f.host.requireStore).not.toHaveBeenCalled()
|
||||
expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false })
|
||||
expect(f.provider.pathsExist).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { validateGitExecArgs } from '../../relay/git-exec-validator'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries'
|
||||
|
||||
@@ -17,42 +16,6 @@ const repo: Repo = {
|
||||
}
|
||||
|
||||
describe('qualified refs in repository searches', () => {
|
||||
it.each(['feature', 'origin/feature'])(
|
||||
'fills legacy pages before the limit for query %s',
|
||||
async (query) => {
|
||||
const exec = vi.fn(async (argv: string[]) => {
|
||||
validateGitExecArgs(argv)
|
||||
return {
|
||||
stdout:
|
||||
argv[0] === 'remote'
|
||||
? 'origin\n'
|
||||
: [
|
||||
'refs/heads/feature/加\0feature/�',
|
||||
'refs/remotes/origin/feature/加\0origin/feature/�',
|
||||
'refs/remotes/origin/feature/one\0origin/feature/one',
|
||||
'refs/remotes/origin/feature/two\0origin/feature/two',
|
||||
'refs/remotes/origin/feature/three\0origin/feature/three'
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
})
|
||||
getProvider.mockReturnValue({ exec })
|
||||
const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo })
|
||||
expect(await queries.search('id:remote-repo', query, 2, false)).toEqual({
|
||||
refs: ['origin/feature/one', 'origin/feature/two'],
|
||||
refDetails: [
|
||||
{ refName: 'origin/feature/one', localBranchName: 'feature/one' },
|
||||
{ refName: 'origin/feature/two', localBranchName: 'feature/two' }
|
||||
],
|
||||
truncated: true
|
||||
})
|
||||
expect(await queries.search('id:remote-repo', query, 2)).toMatchObject({
|
||||
refs: ['refs/heads/feature/加', 'refs/remotes/origin/feature/加'],
|
||||
truncated: true
|
||||
})
|
||||
expect(exec).toHaveBeenCalledWith(expect.arrayContaining(['--count=12']), '/repo')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps folder workspaces outside Git searches', async () => {
|
||||
vi.mocked(getSshGitProvider).mockClear()
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness'
|
||||
import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types'
|
||||
import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods'
|
||||
import { getForegroundProcessName } from '../../relay/pty-shell-utils'
|
||||
|
||||
// #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only
|
||||
// OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`.
|
||||
@@ -41,11 +40,10 @@ async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: numbe
|
||||
})
|
||||
running.push(child)
|
||||
|
||||
// Real foreground read against the real pty: the same helper the relay serves
|
||||
// `pty.getForegroundProcess` with, so corroboration is host-produced here too.
|
||||
// Real foreground read against the real pty, so corroboration is host-produced here too.
|
||||
const runtime = makeTuiIdleRuntime({
|
||||
repoPath: '/tmp/tui-idle-real-pty',
|
||||
getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null)
|
||||
getForegroundProcess: async () => child.process || null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
const graph: RuntimeSyncWindowGraph = {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
getShellLaunchConfig as getDaemonShellLaunchConfig,
|
||||
getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot
|
||||
} from './daemon/shell-ready'
|
||||
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
|
||||
import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready'
|
||||
import { selectShellStartupFeatures } from './shell-startup-features'
|
||||
|
||||
@@ -142,11 +141,6 @@ describePosix('generated shell wrapper files', () => {
|
||||
await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot())
|
||||
})
|
||||
|
||||
it('relay overlay wrappers', async () => {
|
||||
ensureOverlayRestoreWrappers(root)
|
||||
await expectWrapperFiles('relay', root)
|
||||
})
|
||||
|
||||
// Why a rule and not another fixture: `REPLY`, zsh's shared scratch global,
|
||||
// was the wrapper's resolver out-parameter. A user config that constrained it
|
||||
// (`typeset -r REPLY`) aborted the wrapper at its first executable line — on
|
||||
@@ -161,8 +155,7 @@ describePosix('generated shell wrapper files', () => {
|
||||
getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES)
|
||||
},
|
||||
(): string => getDaemonShellReadyWrapperRoot()
|
||||
],
|
||||
['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root]
|
||||
]
|
||||
])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => {
|
||||
generate()
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
|
||||
import { getShellLaunchConfig } from './providers/local-pty-shell-ready'
|
||||
import { selectShellStartupFeatures } from './shell-startup-features'
|
||||
import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
|
||||
@@ -378,44 +377,6 @@ describe.skipIf(process.platform === 'win32')(
|
||||
* why it drifted from the desktop template; now the only differences are the
|
||||
* spec flags, and this pins that the variant still works in a real shell.
|
||||
*/
|
||||
describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => {
|
||||
itWithZsh(
|
||||
'scopes history and restores the remote CLI path without emitting OSC 133',
|
||||
withHome(USER_FILES, async (home) => {
|
||||
const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-'))
|
||||
try {
|
||||
expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true)
|
||||
const scoped = join(home, 'orca-history', 'zsh_history')
|
||||
|
||||
const { output, values } = await runZshPty({
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
HOME: home,
|
||||
ZDOTDIR: join(relayRoot, 'zsh'),
|
||||
ORCA_ORIG_ZDOTDIR: home,
|
||||
ORCA_SHELL_FEATURES: 'overlay,history,ready',
|
||||
ORCA_HISTFILE: scoped,
|
||||
ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin'
|
||||
},
|
||||
commands: ['true'],
|
||||
report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE']
|
||||
})
|
||||
|
||||
expect(values.HISTFILE).toBe(scoped)
|
||||
expect(values.ZDOTDIR).toBe(home)
|
||||
expect(values.ORCA_HISTFILE).toBe('UNSET')
|
||||
expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true)
|
||||
expect(output).toContain(MARKERS.ready)
|
||||
// Remote panes get their command lifecycle from the bash rcfile, so the
|
||||
// zsh variant must stay silent here.
|
||||
expect(output).not.toContain(MARKERS.promptStart)
|
||||
expect(output).not.toContain(MARKERS.commandStart)
|
||||
} finally {
|
||||
rmSync(relayRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => {
|
||||
itWithZsh(
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Real-zsh proof that a wrapper dir holding files from two Orca builds still
|
||||
* loads the user's own zsh config.
|
||||
*
|
||||
* A shared dir used to mean a shell could read one build's `.zshenv` and
|
||||
* another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file
|
||||
* redefined the helpers it called. #15285 removed the hazard for the desktop and
|
||||
* daemon trees by naming each one after a hash of its contents, so two builds
|
||||
* never write the same directory.
|
||||
*
|
||||
* The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready`
|
||||
* — so this is where the scenario is still reachable, and it is now much smaller:
|
||||
* Orca writes one file, and that file hands ZDOTDIR back before anything else
|
||||
* runs. Both halves are pinned here:
|
||||
*
|
||||
* 1. Files an older build left beside the hook are inert — zsh reads .zprofile,
|
||||
* .zshrc and .zlogin from the user's own directory and never from here.
|
||||
* 2. Generation does not delete them. An older build checks for all four before
|
||||
* calling its tree complete; deleting them makes it rewrite its own `.zshenv`
|
||||
* over ours, and an older `.zshenv` points ZDOTDIR at a directory that would
|
||||
* then hold no `.zshrc` at all — the user losing their entire config, which is
|
||||
* the failure this file exists to prevent.
|
||||
*/
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
|
||||
import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness'
|
||||
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
/** The three files an older build wrote alongside its own `.zshenv`. */
|
||||
const OLDER_BUILD_FILES = {
|
||||
'.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n',
|
||||
'.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n',
|
||||
'.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n'
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => {
|
||||
itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-'))
|
||||
const home = makeZshHome({
|
||||
'.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n',
|
||||
'.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n',
|
||||
'.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n'
|
||||
})
|
||||
try {
|
||||
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
|
||||
const zshDir = join(root, 'zsh')
|
||||
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
|
||||
writeFileSync(join(zshDir, name), content)
|
||||
}
|
||||
|
||||
const { values } = await runZshPty({
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
HOME: home,
|
||||
ZDOTDIR: zshDir,
|
||||
ORCA_ORIG_ZDOTDIR: home,
|
||||
ORCA_SHELL_FEATURES: 'history',
|
||||
ORCA_HISTFILE: join(home, 'scoped_history')
|
||||
},
|
||||
report: [
|
||||
'ORCA_TEST_USER_ZPROFILE',
|
||||
'ORCA_TEST_USER_ZSHRC',
|
||||
'ORCA_TEST_STALE_ZPROFILE',
|
||||
'ORCA_TEST_STALE_ZSHRC',
|
||||
'ORCA_TEST_STALE_ZLOGIN',
|
||||
'HISTFILE'
|
||||
]
|
||||
})
|
||||
|
||||
// The user's own files loaded; the older build's leftovers did not.
|
||||
expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1')
|
||||
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
|
||||
expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET')
|
||||
expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET')
|
||||
expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET')
|
||||
expect(values.HISTFILE).toBe(join(home, 'scoped_history'))
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves an older build’s files in place so that build can still use them', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-'))
|
||||
try {
|
||||
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
|
||||
const zshDir = join(root, 'zsh')
|
||||
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
|
||||
writeFileSync(join(zshDir, name), content)
|
||||
}
|
||||
|
||||
// A second generation pass is what an older build's launch would race.
|
||||
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
|
||||
|
||||
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
|
||||
expect(existsSync(join(zshDir, name))).toBe(true)
|
||||
expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content)
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,196 +0,0 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { AgentExecHandler } from './agent-exec-handler'
|
||||
import {
|
||||
RELAY_AGENT_CLOSE_DEADLINE_MS,
|
||||
RelayAgentProcessLifetime
|
||||
} from './relay-agent-process-lifetime'
|
||||
import { createFakeChild, requestContext } from './agent-exec-handler-test-harness'
|
||||
import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher'
|
||||
|
||||
// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess.
|
||||
const { spawnMock, loginShellMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
loginShellMock: vi.fn()
|
||||
}))
|
||||
vi.mock('../main/startup/login-shell-environment', () => ({
|
||||
resolveLoginShellEnvironment: () => loginShellMock()
|
||||
}))
|
||||
vi.mock('child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ChildProcess>()),
|
||||
spawn: (...args: unknown[]) => spawnMock(...args),
|
||||
execFile: vi.fn()
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
const methods = new Map<string, MethodHandler>()
|
||||
const handler = new AgentExecHandler(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods.
|
||||
{
|
||||
onRequest: (name: string, method: MethodHandler) => methods.set(name, method)
|
||||
} as unknown as RelayDispatcher
|
||||
)
|
||||
const exec = (params: Record<string, unknown> = {}) =>
|
||||
methods.get('agent.execNonInteractive')!(
|
||||
{ binary: 'agent', timeoutMs: 1000, ...params },
|
||||
requestContext()
|
||||
)
|
||||
return { handler, exec }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
spawnMock.mockReset()
|
||||
vi.mocked(execFile).mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('waits for every child close, including commands without a cwd and replaced lanes', async () => {
|
||||
const f = fixture()
|
||||
const children = [createFakeChild(), createFakeChild(), createFakeChild()]
|
||||
for (const child of children) {
|
||||
spawnMock.mockReturnValueOnce(child)
|
||||
}
|
||||
const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })]
|
||||
let finished = false
|
||||
const disposal = f.handler.dispose().then(() => {
|
||||
finished = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(finished).toBe(false)
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
for (const child of children) {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
}
|
||||
children[0].emit('close', null)
|
||||
children[2].emit('close', null)
|
||||
await Promise.resolve()
|
||||
expect(finished).toBe(false)
|
||||
children[1].emit('close', null)
|
||||
await disposal
|
||||
expect(finished).toBe(true)
|
||||
await Promise.all(requests)
|
||||
})
|
||||
|
||||
it('refuses execution once disposal begins and after it completes', async () => {
|
||||
const f = fixture()
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const request = f.exec()
|
||||
const disposal = f.handler.dispose()
|
||||
await expect(f.exec()).rejects.toThrow()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
child.emit('close', null)
|
||||
await Promise.all([request, disposal])
|
||||
await expect(f.exec()).rejects.toThrow()
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not spawn when shutdown fences during login-shell resolution, and admits after reopen', async () => {
|
||||
const f = fixture()
|
||||
let resolveShell!: (env: NodeJS.ProcessEnv) => void
|
||||
loginShellMock.mockReturnValueOnce(
|
||||
new Promise<NodeJS.ProcessEnv>((resolve) => {
|
||||
resolveShell = resolve
|
||||
})
|
||||
)
|
||||
const request = f.exec({ shell: true })
|
||||
await f.handler.dispose()
|
||||
resolveShell({})
|
||||
await expect(request).rejects.toThrow('relay_agent_execution_shutdown_fenced')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
|
||||
f.handler.reopen()
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const next = f.exec()
|
||||
child.emit('close', 0)
|
||||
await next
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('kills a child tracked after the fence instead of throwing', async () => {
|
||||
const lifetime = new RelayAgentProcessLifetime()
|
||||
void lifetime.dispose()
|
||||
const child = createFakeChild()
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits.
|
||||
lifetime.track(child as unknown as Parameters<typeof lifetime.track>[0])
|
||||
).not.toThrow()
|
||||
if (process.platform !== 'win32') {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
const disposal = lifetime.dispose()
|
||||
child.emit('close', null)
|
||||
await disposal
|
||||
})
|
||||
|
||||
it('retains timed-out children until close rather than treating RPC settlement as exit', async () => {
|
||||
const f = fixture()
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const request = f.exec({ cwd: '/repo' })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await expect(request).resolves.toMatchObject({ timedOut: true })
|
||||
let finished = false
|
||||
const disposal = f.handler.dispose().then(() => {
|
||||
finished = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(finished).toBe(false)
|
||||
child.emit('error', new Error('late child error'))
|
||||
await Promise.resolve()
|
||||
expect(finished).toBe(false)
|
||||
child.emit('close', null)
|
||||
await disposal
|
||||
expect(finished).toBe(true)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves disposal with no children or only already-closed children', async () => {
|
||||
await fixture().handler.dispose()
|
||||
const f = fixture()
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const request = f.exec()
|
||||
child.emit('close', 0)
|
||||
await request
|
||||
await f.handler.dispose()
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects within the close deadline and re-checks without re-killing on retry', async () => {
|
||||
const f = fixture()
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const request = f.exec({ timeoutMs: 60_000 })
|
||||
const signals = () =>
|
||||
process.platform === 'win32'
|
||||
? vi.mocked(execFile).mock.calls.length
|
||||
: child.kill.mock.calls.length
|
||||
const first = f.handler.dispose()
|
||||
const firstOutcome = first.then(
|
||||
() => 'resolved',
|
||||
(error: Error) => error.message
|
||||
)
|
||||
expect(signals()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS)
|
||||
await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete')
|
||||
const retry = f.handler.dispose()
|
||||
expect(signals()).toBe(1)
|
||||
child.emit('close', null)
|
||||
await expect(retry).resolves.toBeUndefined()
|
||||
await request
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { vi } from 'vitest'
|
||||
import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher'
|
||||
import { AgentExecHandler } from './agent-exec-handler'
|
||||
|
||||
export function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
}
|
||||
|
||||
export type FakeChild = EventEmitter & {
|
||||
pid: number
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { end: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
export function createFakeChild(): FakeChild {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
kill: vi.fn(),
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
stdin: { end: vi.fn() }
|
||||
})
|
||||
}
|
||||
|
||||
export function createHandlers(): Map<string, MethodHandler> {
|
||||
const handlers = new Map<string, MethodHandler>()
|
||||
new AgentExecHandler({
|
||||
onRequest: (method: string, handler: MethodHandler): void => {
|
||||
handlers.set(method, handler)
|
||||
}
|
||||
} as never)
|
||||
return handlers
|
||||
}
|
||||
|
||||
export function requestContext(clientId = 1): RequestContext {
|
||||
return { clientId, isStale: () => false }
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { exec, spawn } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import {
|
||||
createFakeChild,
|
||||
createHandlers,
|
||||
requestContext,
|
||||
withPlatform
|
||||
} from './agent-exec-handler-test-harness'
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcess>()
|
||||
return {
|
||||
...actual,
|
||||
exec: vi.fn(),
|
||||
spawn: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const spawnMock = vi.mocked(spawn)
|
||||
const execMock = vi.mocked(exec)
|
||||
|
||||
describe('AgentExecHandler Windows command spawning', () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
execMock.mockReset()
|
||||
})
|
||||
|
||||
it('resolves bare Windows agent commands to batch shims before spawning', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-'))
|
||||
const originalComSpec = process.env.ComSpec
|
||||
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
||||
try {
|
||||
await withPlatform('win32', async () => {
|
||||
const codexShim = join(tempDir, 'codex.cmd')
|
||||
writeFileSync(codexShim, '@echo off\r\n')
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'codex',
|
||||
args: ['exec', '-s', 'read-only'],
|
||||
cwd: 'C:\\repo',
|
||||
stdin: 'PROMPT',
|
||||
timeoutMs: 5_000,
|
||||
env: { PATH: tempDir }
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`],
|
||||
{
|
||||
cwd: 'C:\\repo',
|
||||
env: expect.objectContaining({ PATH: tempDir }),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
if (originalComSpec === undefined) {
|
||||
delete process.env.ComSpec
|
||||
} else {
|
||||
process.env.ComSpec = originalComSpec
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const handlers = createHandlers()
|
||||
|
||||
const result = await handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'C:\\tools\\agent.cmd',
|
||||
args: ['hello & goodbye'],
|
||||
cwd: 'C:\\repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,536 +0,0 @@
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
|
||||
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ChildProcess>()
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
spawn: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const spawnMock = vi.mocked(spawn)
|
||||
const execFileMock = vi.mocked(execFile)
|
||||
|
||||
type AgentExecResult = { exitCode: number | null; timedOut: boolean }
|
||||
|
||||
const GUARD_OWNED_ENV_RE = /^(?:GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)|WSLENV)$/
|
||||
|
||||
describe('AgentExecHandler', () => {
|
||||
let ambientGuardEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
execFileMock.mockReset()
|
||||
// Why: the guard rewrites these, so an already-guarded runner (Orca guards
|
||||
// its own agent terminals) would not see its ambient values passed through.
|
||||
ambientGuardEnv = {}
|
||||
for (const key of Object.keys(process.env).filter((name) => GUARD_OWNED_ENV_RE.test(name))) {
|
||||
ambientGuardEnv[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const [key, value] of Object.entries(ambientGuardEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('executes a non-interactive command with captured output and stdin', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: ['--flag', 42],
|
||||
cwd: '/repo',
|
||||
stdin: 'PROMPT',
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
child.stdout.emit('data', Buffer.from('message'))
|
||||
child.stderr.emit('data', Buffer.from('warning'))
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
stdout: 'message',
|
||||
stderr: 'warning',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
canceled: false
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith('agent', ['--flag', '42'], {
|
||||
cwd: '/repo',
|
||||
env: expect.objectContaining({
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GCM_INTERACTIVE: 'never'
|
||||
}),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
expect(child.stdin.end).toHaveBeenCalledWith('PROMPT')
|
||||
})
|
||||
|
||||
it('merges caller-supplied provider environment into the spawned command environment', async () => {
|
||||
vi.stubEnv('ORCA_EXEC_INHERITED', 'inherited-value')
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'codex',
|
||||
args: ['exec'],
|
||||
cwd: '/repo',
|
||||
stdin: 'PROMPT',
|
||||
timeoutMs: 5_000,
|
||||
env: {
|
||||
CODEX_HOME: '/managed/codex-home',
|
||||
PATH: '/managed/bin'
|
||||
}
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false
|
||||
})
|
||||
expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], {
|
||||
cwd: '/repo',
|
||||
env: expect.objectContaining({
|
||||
ORCA_EXEC_INHERITED: 'inherited-value',
|
||||
CODEX_HOME: '/managed/codex-home',
|
||||
PATH: '/managed/bin'
|
||||
}),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
})
|
||||
|
||||
it('honors Windows override casing while replacing indexed Git configuration', async () => {
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every child event and stream used by the handler.
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const originalPlatform = process.platform
|
||||
const originalPath = process.env.PATH
|
||||
process.env.PATH = 'inherited-path'
|
||||
process.env.GIT_CONFIG_COUNT = '2'
|
||||
process.env.GIT_CONFIG_KEY_1 = 'stale.key'
|
||||
process.env.GIT_CONFIG_VALUE_1 = 'stale-value'
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent.exe',
|
||||
args: [],
|
||||
env: {
|
||||
Path: 'override-path',
|
||||
token: 'override-token',
|
||||
git_config_count: '1',
|
||||
git_config_key_0: 'http.proxy',
|
||||
git_config_value_0: 'proxy-value'
|
||||
}
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
child.emit('close', 0)
|
||||
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
|
||||
const env = spawnMock.mock.calls[0][2]?.env
|
||||
expect(env).toMatchObject({
|
||||
PATH: 'override-path',
|
||||
TOKEN: 'override-token',
|
||||
GIT_CONFIG_KEY_0: 'http.proxy',
|
||||
GIT_CONFIG_VALUE_0: 'proxy-value'
|
||||
})
|
||||
expect(env).not.toHaveProperty('Path')
|
||||
expect(env).not.toHaveProperty('git_config_count')
|
||||
expect(Object.values(env ?? {})).not.toContain('stale.key')
|
||||
expect(Object.values(env ?? {})).not.toContain('stale-value')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
delete process.env.GIT_CONFIG_COUNT
|
||||
delete process.env.GIT_CONFIG_KEY_1
|
||||
delete process.env.GIT_CONFIG_VALUE_1
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes an unattended marker and applies the full Git guard on the relay host', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: '/bin/bash',
|
||||
args: ['-lc', 'git fetch'],
|
||||
cwd: '/repo',
|
||||
timeoutMs: 5_000,
|
||||
env: { [TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard' }
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
child.emit('close', 0)
|
||||
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
|
||||
|
||||
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
|
||||
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined()
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
|
||||
expect(env.GCM_INTERACTIVE).toBe('never')
|
||||
expect(Object.values(env)).toContain('credential.interactive')
|
||||
expect(Object.values(env)).toContain('credential.guiPrompt')
|
||||
})
|
||||
|
||||
it('guards wrapped agents after atomically replacing inherited indexed config', async () => {
|
||||
const keys = [
|
||||
'GIT_CONFIG_COUNT',
|
||||
'GIT_CONFIG_KEY_0',
|
||||
'GIT_CONFIG_VALUE_0',
|
||||
'GIT_CONFIG_KEY_1',
|
||||
'GIT_CONFIG_VALUE_1'
|
||||
] as const
|
||||
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
|
||||
process.env.GIT_CONFIG_COUNT = '2'
|
||||
process.env.GIT_CONFIG_KEY_0 = 'base.one'
|
||||
process.env.GIT_CONFIG_VALUE_0 = 'one'
|
||||
process.env.GIT_CONFIG_KEY_1 = 'base.two'
|
||||
process.env.GIT_CONFIG_VALUE_1 = 'two'
|
||||
|
||||
try {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'npx',
|
||||
args: ['codex', 'exec'],
|
||||
cwd: '/repo',
|
||||
timeoutMs: 5_000,
|
||||
env: {
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'http.proxy',
|
||||
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
|
||||
}
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
child.emit('close', 0)
|
||||
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
|
||||
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('3')
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
|
||||
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
|
||||
expect(Object.values(env)).not.toContain('base.two')
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
if (saved[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = saved[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the in-flight command for the requested cwd', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: [],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext())
|
||||
).resolves.toEqual({ canceled: true })
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
|
||||
child.emit('close', null)
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
canceled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels only the matching operation lane for a cwd', async () => {
|
||||
const commitChild = createFakeChild()
|
||||
const pullRequestChild = createFakeChild()
|
||||
pullRequestChild.pid = 12346
|
||||
spawnMock
|
||||
.mockReturnValueOnce(commitChild as never)
|
||||
.mockReturnValueOnce(pullRequestChild as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const commit = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: [],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000,
|
||||
operation: 'commit-message'
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
const pullRequest = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: [],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000,
|
||||
operation: 'pull-request-fields'
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')!(
|
||||
{ cwd: '/repo', operation: 'commit-message' },
|
||||
requestContext()
|
||||
)
|
||||
).resolves.toEqual({ canceled: true })
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFileMock).not.toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12346', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(commitChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(pullRequestChild.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
commitChild.emit('close', null)
|
||||
pullRequestChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}')
|
||||
)
|
||||
pullRequestChild.emit('close', 0)
|
||||
|
||||
await expect(commit).resolves.toMatchObject({
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
canceled: true
|
||||
})
|
||||
await expect(pullRequest).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
canceled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('kills the active command when the request aborts', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: [],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
{ clientId: 1, isStale: () => controller.signal.aborted, signal: controller.signal }
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
|
||||
child.emit('close', null)
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
canceled: true
|
||||
})
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a superseded command in the same operation lane', async () => {
|
||||
const firstChild = createFakeChild()
|
||||
const secondChild = createFakeChild()
|
||||
secondChild.pid = 12346
|
||||
spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const first = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: ['first'],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000,
|
||||
operation: 'commit-message'
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
const second = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: ['second'],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000,
|
||||
operation: 'commit-message'
|
||||
},
|
||||
requestContext()
|
||||
)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(execFileMock).not.toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12346', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(secondChild.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')!(
|
||||
{ cwd: '/repo', operation: 'commit-message' },
|
||||
requestContext()
|
||||
)
|
||||
).resolves.toEqual({ canceled: true })
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12346', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(secondChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
|
||||
firstChild.emit('close', null)
|
||||
secondChild.emit('close', null)
|
||||
await expect(first).resolves.toMatchObject({ canceled: true })
|
||||
await expect(second).resolves.toMatchObject({ canceled: true })
|
||||
})
|
||||
|
||||
it('reports when cancellation has no matching in-flight command', async () => {
|
||||
const handlers = createHandlers()
|
||||
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext())
|
||||
).resolves.toEqual({ canceled: false })
|
||||
})
|
||||
|
||||
it('settles timed-out commands even when the killed child does not close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
|
||||
const pending = handlers.get('agent.execNonInteractive')!(
|
||||
{
|
||||
binary: 'agent',
|
||||
args: [],
|
||||
cwd: '/repo',
|
||||
stdin: null,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
requestContext()
|
||||
) as Promise<AgentExecResult>
|
||||
const outcomePromise = pending.then((result) =>
|
||||
result.timedOut ? `timed-out:${result.exitCode}` : 'not-timed-out'
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
|
||||
|
||||
expect(outcome).toBe('timed-out:null')
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
}
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
// Shutdown keeps tracking the child until it physically closes.
|
||||
expect(child.listenerCount('error')).toBe(1)
|
||||
expect(child.listenerCount('close')).toBe(1)
|
||||
child.emit('close', null)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,344 +0,0 @@
|
||||
import { mergeCommandEnvironment } from '../shared/command-environment'
|
||||
import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher'
|
||||
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
|
||||
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
|
||||
import { terminateRelaySubprocessTree } from './subprocess-tree-termination'
|
||||
import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime'
|
||||
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
||||
const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
|
||||
|
||||
function getCmdExePath(): string {
|
||||
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
|
||||
}
|
||||
|
||||
function isWindowsBatchScript(commandPath: string): boolean {
|
||||
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
|
||||
}
|
||||
|
||||
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
|
||||
return /[&|<>^"%!\r\n]/.test(value)
|
||||
}
|
||||
|
||||
function quoteWindowsBatchToken(value: string): string {
|
||||
if (hasUnsafeWindowsBatchSyntax(value)) {
|
||||
throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
|
||||
}
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return binary
|
||||
}
|
||||
if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) {
|
||||
return binary
|
||||
}
|
||||
|
||||
const pathEnv = env.PATH ?? env.Path
|
||||
if (!pathEnv) {
|
||||
return binary
|
||||
}
|
||||
const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary]
|
||||
for (const directory of pathEnv.split(delimiter).filter(Boolean)) {
|
||||
for (const name of names) {
|
||||
const candidate = join(directory, name)
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
function getWindowsSafeSpawn(
|
||||
binary: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv
|
||||
): { spawnCmd: string; spawnArgs: string[] } {
|
||||
const resolvedBinary = resolveWindowsCommand(binary, env)
|
||||
if (!isWindowsBatchScript(resolvedBinary)) {
|
||||
return { spawnCmd: resolvedBinary, spawnArgs: args }
|
||||
}
|
||||
const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ')
|
||||
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] }
|
||||
}
|
||||
|
||||
type ExecParams = {
|
||||
binary: unknown
|
||||
args: unknown
|
||||
cwd: unknown
|
||||
stdin: unknown
|
||||
timeoutMs: unknown
|
||||
env: unknown
|
||||
operation: unknown
|
||||
shell: unknown
|
||||
}
|
||||
|
||||
type CancelParams = {
|
||||
cwd: unknown
|
||||
operation: unknown
|
||||
}
|
||||
|
||||
function laneKeyFor(cwd: string, operation: unknown): string {
|
||||
const op = typeof operation === 'string' && operation ? operation : 'default'
|
||||
return JSON.stringify([op, cwd])
|
||||
}
|
||||
|
||||
type InFlightExec = { child?: ChildProcess; cancel: () => void }
|
||||
|
||||
type ExecResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number | null
|
||||
timedOut: boolean
|
||||
/** Set when the user canceled the exec via `agent.cancelExec`. */
|
||||
canceled?: boolean
|
||||
/** Set when the binary could not be spawned (e.g. ENOENT). */
|
||||
spawnError?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive subprocess exec on the remote host. Used by the AI commit
|
||||
* message generator to spawn agent CLIs (claude, codex, …) with the staged
|
||||
* diff piped via stdin and the output captured to stdout. Distinct from
|
||||
* `pty.spawn` because we want no terminal allocation, no escape sequences,
|
||||
* and a clean exit code instead of an interactive session.
|
||||
*/
|
||||
export class AgentExecHandler {
|
||||
private readonly processLifetime = new RelayAgentProcessLifetime()
|
||||
// Why: commit-message and PR-field generation can run together for one cwd;
|
||||
// operation lanes let cancel target only the user-visible job that stopped.
|
||||
private inFlightByLane = new Map<string, InFlightExec>()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher) {
|
||||
dispatcher.onRequest('agent.execNonInteractive', (p, context) =>
|
||||
this.exec(p as ExecParams, context)
|
||||
)
|
||||
dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams))
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return this.processLifetime.dispose()
|
||||
}
|
||||
|
||||
reopen = (): void => this.processLifetime.reopen()
|
||||
|
||||
private async cancel(params: CancelParams): Promise<{ canceled: boolean }> {
|
||||
const cwd = typeof params.cwd === 'string' ? params.cwd : ''
|
||||
const entry = this.inFlightByLane.get(laneKeyFor(cwd, params.operation))
|
||||
if (!entry) {
|
||||
return { canceled: false }
|
||||
}
|
||||
entry.cancel()
|
||||
return { canceled: true }
|
||||
}
|
||||
|
||||
private async exec(params: ExecParams, context?: RequestContext): Promise<ExecResult> {
|
||||
this.processLifetime.assertAdmission()
|
||||
const binary = typeof params.binary === 'string' ? params.binary : ''
|
||||
if (!binary) {
|
||||
throw new Error('agent.execNonInteractive: binary is required')
|
||||
}
|
||||
const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : []
|
||||
const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined
|
||||
const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null
|
||||
const requestedTimeout =
|
||||
typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS
|
||||
const deadline = Date.now() + Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout))
|
||||
const extraEnv =
|
||||
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
|
||||
? (params.env as Record<string, string>)
|
||||
: null
|
||||
let hostEnv = process.env
|
||||
if (params.shell === true) {
|
||||
const timeoutError = new Error('Profile resolution exceeded the request deadline')
|
||||
const controller = new AbortController()
|
||||
const key = laneKeyFor(cwd ?? '', params.operation)
|
||||
const pending = { cancel: (): void => controller.abort() }
|
||||
this.inFlightByLane.get(key)?.cancel()
|
||||
this.inFlightByLane.set(key, pending)
|
||||
context?.signal?.addEventListener('abort', pending.cancel, { once: true })
|
||||
if (context?.signal?.aborted) {
|
||||
pending.cancel()
|
||||
}
|
||||
try {
|
||||
hostEnv = await new PromiseSettlementWaiters(
|
||||
resolveLoginShellEnvironment({ env: process.env })
|
||||
).wait({
|
||||
signal: controller.signal,
|
||||
timeoutMs: Math.max(1, deadline - Date.now()),
|
||||
createTimeoutError: () => timeoutError
|
||||
})
|
||||
} catch (error) {
|
||||
if (error === timeoutError) {
|
||||
return { stdout: '', stderr: '', exitCode: null, timedOut: true }
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
context?.signal?.removeEventListener('abort', pending.cancel)
|
||||
if (this.inFlightByLane.get(key) === pending) {
|
||||
this.inFlightByLane.delete(key)
|
||||
}
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
|
||||
}
|
||||
}
|
||||
// Why again: shutdown may have fenced while the login shell resolved.
|
||||
this.processLifetime.assertAdmission()
|
||||
if (Date.now() >= deadline) {
|
||||
return { stdout: '', stderr: '', exitCode: null, timedOut: true }
|
||||
}
|
||||
const baseEnv = mergeCommandEnvironment(hostEnv, extraEnv ? {} : undefined, process.platform)
|
||||
const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform)
|
||||
const spawnEnv = Object.fromEntries(
|
||||
Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? hostEnv, overrides)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
// Why: this RPC has no interactive terminal, regardless of which wrapper
|
||||
// launches the agent or hook command.
|
||||
applyTerminalGitCredentialPromptGuard(spawnEnv, {
|
||||
isUnattended: true,
|
||||
platform: process.platform
|
||||
})
|
||||
|
||||
return new Promise<ExecResult>((resolve) => {
|
||||
let child
|
||||
try {
|
||||
const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv)
|
||||
if (Date.now() >= deadline) {
|
||||
resolve({ stdout: '', stderr: '', exitCode: null, timedOut: true })
|
||||
return
|
||||
}
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
cwd,
|
||||
env: spawnEnv,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
} catch (error) {
|
||||
resolve({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
spawnError: errorMessage(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.processLifetime.track(child)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let stderrBytes = 0
|
||||
let timedOut = false
|
||||
let canceled = false
|
||||
let settled = false
|
||||
const laneKey = typeof cwd === 'string' ? laneKeyFor(cwd, params.operation) : ''
|
||||
let entry: InFlightExec | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let detachChildListeners = (): void => {}
|
||||
let detachRequestAbortListener = (): void => {}
|
||||
const finish = (result: ExecResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
detachRequestAbortListener()
|
||||
detachChildListeners()
|
||||
if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) {
|
||||
this.inFlightByLane.delete(laneKey)
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
const cancelCurrent = (): void => {
|
||||
canceled = true
|
||||
terminateRelaySubprocessTree(child)
|
||||
}
|
||||
if (laneKey) {
|
||||
// Why: the relay owns one visible non-interactive job per cwd+operation.
|
||||
// Replacing the lane without canceling the prior child would orphan
|
||||
// that process until timeout because future cancelExec calls reach only
|
||||
// the newest map entry.
|
||||
this.inFlightByLane.get(laneKey)?.cancel()
|
||||
entry = { child, cancel: cancelCurrent }
|
||||
this.inFlightByLane.set(laneKey, entry)
|
||||
}
|
||||
|
||||
const onStdoutData = (chunk: Buffer): void => {
|
||||
stdoutBytes += chunk.byteLength
|
||||
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
||||
terminateRelaySubprocessTree(child)
|
||||
return
|
||||
}
|
||||
stdout += chunk.toString('utf-8')
|
||||
}
|
||||
const onStderrData = (chunk: Buffer): void => {
|
||||
stderrBytes += chunk.byteLength
|
||||
if (stderrBytes > MAX_OUTPUT_BYTES) {
|
||||
terminateRelaySubprocessTree(child)
|
||||
return
|
||||
}
|
||||
stderr += chunk.toString('utf-8')
|
||||
}
|
||||
const onError = (error: Error): void =>
|
||||
finish({ stdout, stderr, exitCode: null, timedOut, spawnError: error.message })
|
||||
const onClose = (code: number | null): void =>
|
||||
finish({ stdout, stderr, exitCode: code, timedOut, canceled })
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
detachChildListeners = () => {
|
||||
child.stdout?.off('data', onStdoutData)
|
||||
child.stderr?.off('data', onStderrData)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
}
|
||||
|
||||
const expireCurrent = (): void => {
|
||||
timedOut = true
|
||||
// Why: wrappers and signal-trapping CLIs require terminating the whole tree.
|
||||
terminateRelaySubprocessTree(child)
|
||||
finish({ stdout, stderr, exitCode: null, timedOut, canceled })
|
||||
}
|
||||
const remainingTimeoutMs = deadline - Date.now()
|
||||
if (remainingTimeoutMs <= 0) {
|
||||
expireCurrent()
|
||||
return
|
||||
}
|
||||
timer = setTimeout(expireCurrent, remainingTimeoutMs)
|
||||
|
||||
if (context?.signal) {
|
||||
if (context.signal.aborted) {
|
||||
cancelCurrent()
|
||||
} else {
|
||||
context.signal.addEventListener('abort', cancelCurrent, { once: true })
|
||||
detachRequestAbortListener = () => {
|
||||
context.signal?.removeEventListener('abort', cancelCurrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdin?.end(stdinPayload ?? undefined)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type * as FileSystem from 'node:fs'
|
||||
import type * as ChildProcess from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
|
||||
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ChildProcess>()),
|
||||
spawn: vi.fn(),
|
||||
execFile: vi.fn()
|
||||
}))
|
||||
vi.mock('../main/startup/login-shell-environment', () => ({
|
||||
resolveLoginShellEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof FileSystem>()
|
||||
return { ...original, existsSync: vi.fn(original.existsSync) }
|
||||
})
|
||||
|
||||
describe('relay headless generation shell environment', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('uses the execution host profile PATH and retains explicit command overrides', async () => {
|
||||
vi.mocked(resolveLoginShellEnvironment).mockResolvedValue({
|
||||
PATH: '/profile/bin:/usr/bin',
|
||||
MODEL: 'profile'
|
||||
})
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake provides the streams and lifecycle used by the relay.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, env: { MODEL: 'override' } },
|
||||
requestContext()
|
||||
)
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalled())
|
||||
child.emit('close', 0)
|
||||
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
|
||||
expect(spawn).toHaveBeenLastCalledWith(
|
||||
'opencode',
|
||||
['run'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ PATH: '/profile/bin:/usr/bin', MODEL: 'override' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not start generation after cancellation during profile resolution', async () => {
|
||||
vi.mocked(spawn).mockClear()
|
||||
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
|
||||
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProfile = resolve
|
||||
})
|
||||
)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
|
||||
requestContext()
|
||||
)
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')?.(
|
||||
{ cwd: '/repo', operation: 'commit-message' },
|
||||
requestContext()
|
||||
)
|
||||
).resolves.toEqual({ canceled: true })
|
||||
resolveProfile({ PATH: '/profile/bin' })
|
||||
await expect(pending).resolves.toMatchObject({ canceled: true })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('relay generation deadline includes profile resolution', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('counts both five-second primary and fallback profile probes in the generation budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
setTimeout(() => resolve({ PATH: '/profile/bin' }), 5_000)
|
||||
}, 5_000)
|
||||
)
|
||||
)
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 12_000 },
|
||||
requestContext()
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1_999)
|
||||
expect(result).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null })
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
})
|
||||
|
||||
it('settles at the request deadline while the shared profile probe remains pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
|
||||
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProfile = resolve
|
||||
})
|
||||
)
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
|
||||
requestContext()
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
try {
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
resolveProfile({ PATH: '/profile/bin' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
}
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['cancel', 'abort'] as const)(
|
||||
'settles %s immediately while the shared profile probe remains pending',
|
||||
async (kind) => {
|
||||
vi.useFakeTimers()
|
||||
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
|
||||
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProfile = resolve
|
||||
})
|
||||
)
|
||||
const handlers = createHandlers()
|
||||
const controller = new AbortController()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{
|
||||
binary: 'opencode',
|
||||
args: ['run'],
|
||||
cwd: '/repo',
|
||||
operation: 'commit-message',
|
||||
shell: true
|
||||
},
|
||||
{ ...requestContext(), signal: controller.signal }
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
if (kind === 'cancel') {
|
||||
await handlers.get('agent.cancelExec')?.(
|
||||
{ cwd: '/repo', operation: 'commit-message' },
|
||||
requestContext()
|
||||
)
|
||||
} else {
|
||||
controller.abort()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
try {
|
||||
expect(result).toMatchObject({ canceled: true, timedOut: false })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
resolveProfile({ PATH: '/profile/bin' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('relay deadline boundaries and profile failures', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('retains the final millisecond of the request budget without resetting the minimum timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 999))
|
||||
)
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
|
||||
requestContext()
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBeUndefined()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(result).toMatchObject({ timedOut: true })
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
})
|
||||
|
||||
it('does not spawn when profile settlement consumes the whole request budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 1_000))
|
||||
)
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
|
||||
requestContext()
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
try {
|
||||
expect(result).toMatchObject({ timedOut: true })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a profile rejection and removes the pending cancellation lane', async () => {
|
||||
const failure = new Error('profile resolution refused')
|
||||
vi.mocked(resolveLoginShellEnvironment).mockRejectedValue(failure)
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
|
||||
requestContext()
|
||||
)
|
||||
await expect(pending).rejects.toBe(failure)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')?.(
|
||||
{ cwd: '/repo', operation: 'commit-message' },
|
||||
requestContext()
|
||||
)
|
||||
).resolves.toEqual({ canceled: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('relay deadline covers synchronous command startup', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.mocked(existsSync).mockReset()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not spawn after a Windows PATH lookup exhausts the request budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
vi.mocked(resolveLoginShellEnvironment).mockImplementation(() => {
|
||||
vi.setSystemTime(999)
|
||||
return Promise.resolve({ PATH: 'C:\\slow-network-bin' })
|
||||
})
|
||||
vi.mocked(existsSync).mockImplementation(() => {
|
||||
vi.setSystemTime(1_001)
|
||||
return false
|
||||
})
|
||||
const child = createFakeChild()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const pending = createHandlers().get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: 'C:\\repo', shell: true, timeoutMs: 1_000 },
|
||||
requestContext()
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
try {
|
||||
expect(existsSync).toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
await expect(pending).resolves.toMatchObject({ timedOut: true, exitCode: null })
|
||||
} finally {
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('times out immediately when spawning consumes the remaining request budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
vi.mocked(existsSync).mockReturnValue(false)
|
||||
const child = createFakeChild()
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
vi.setSystemTime(1_001)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
|
||||
return child as never
|
||||
})
|
||||
const handlers = createHandlers()
|
||||
const pending = handlers.get('agent.execNonInteractive')?.(
|
||||
{ binary: 'opencode', args: ['run'], cwd: '/repo', timeoutMs: 1_000 },
|
||||
requestContext()
|
||||
)
|
||||
let result: unknown
|
||||
void pending?.then((value) => {
|
||||
result = value
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
try {
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(result).toMatchObject({ timedOut: true, exitCode: null })
|
||||
if (process.platform === 'win32') {
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', String(child.pid), '/T', '/F'],
|
||||
expect.any(Function)
|
||||
)
|
||||
} else {
|
||||
expect(child.kill).toHaveBeenCalled()
|
||||
}
|
||||
await expect(
|
||||
handlers.get('agent.cancelExec')?.({ cwd: '/repo' }, requestContext())
|
||||
).resolves.toEqual({ canceled: false })
|
||||
} finally {
|
||||
child.emit('close', 0)
|
||||
await pending
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { RelayAgentHookServer } from '../wsl-guest/agent-hook-server'
|
||||
import { RelayAgentHookRuntime } from './relay-agent-hook-runtime'
|
||||
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
|
||||
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
|
||||
import { makePaneKey } from '../shared/stable-pane-id'
|
||||
import type { PtyHandler, PtySurfaceRetiredListener } from './pty-handler'
|
||||
import type { RelayDispatcher } from '../wsl-guest/dispatcher'
|
||||
import type { ClaudeTerminalEvidence } from '../shared/claude-terminal-interrupt'
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
|
||||
|
||||
function hookMeta(paneKey: string): string {
|
||||
return Buffer.from([paneKey, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f')).toString('base64')
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
server: RelayAgentHookServer,
|
||||
paneKey: string,
|
||||
prompt = 'hi'
|
||||
): Promise<number> {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token,
|
||||
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
|
||||
'X-Orca-Agent-Hook-Meta': hookMeta(paneKey)
|
||||
},
|
||||
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt })
|
||||
})
|
||||
return res.status
|
||||
}
|
||||
|
||||
type CachedPanes = { state: { lastStatusByPaneKey: Map<string, unknown> } }
|
||||
|
||||
function cachedPaneKeys(server: RelayAgentHookServer): string[] {
|
||||
return [...(server as unknown as CachedPanes).state.lastStatusByPaneKey.keys()]
|
||||
}
|
||||
|
||||
describe('relay hook forwarding for a retired pane surface', () => {
|
||||
let dir: string
|
||||
let servers: RelayAgentHookServer[]
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-'))
|
||||
servers = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startServer(options: {
|
||||
forward: (envelope: AgentHookRelayEnvelope) => void
|
||||
isPaneSurfaceRetired?: (paneKey: string) => boolean
|
||||
}): Promise<RelayAgentHookServer> {
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, ...options })
|
||||
servers.push(server)
|
||||
await server.start({ publishEndpoint: false })
|
||||
return server
|
||||
}
|
||||
|
||||
it('forwards and caches a post from a pane that still has a tab', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
})
|
||||
|
||||
it('does not advertise an orphan agent whose tab was closed', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY, 'first')).toBe(204)
|
||||
forward.mockClear()
|
||||
|
||||
// The user closes the tab; the agent inside the pane shell survives and keeps posting.
|
||||
retired.retire(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
// The pre-close status must go too, or a reconnect replays it as a live agent pane.
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not replay a cached status for a pane retired after it was cached', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
forward.mockClear()
|
||||
retired.retire(PANE_KEY)
|
||||
|
||||
expect(server.replayCachedPayloadsForPanes()).toBe(0)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards again once the pane key is bound by a new PTY', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({
|
||||
forward,
|
||||
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
|
||||
})
|
||||
|
||||
retired.retire(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
|
||||
retired.restore(PANE_KEY)
|
||||
expect(await postHook(server, PANE_KEY, 'reopened')).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('an embedder that supplies no retirement source keeps forwarding everything', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = await startServer({ forward })
|
||||
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RelayAgentHookRuntime wiring', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-runtime-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('routes hook admission through the PTY handler and drops the cache on retirement', async () => {
|
||||
const retired = new RetiredPaneSurfaceRegistry()
|
||||
const surfaceRetiredListeners: PtySurfaceRetiredListener[] = []
|
||||
const setClaudeTerminalEvidenceListener =
|
||||
vi.fn<(listener: (paneKey: string, evidence: ClaudeTerminalEvidence) => void) => void>()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this stand-in implements every PtyHandler member the hook runtime registers or reads.
|
||||
const ptyHandler = {
|
||||
addEnvAugmenter: vi.fn(),
|
||||
setAgentPresenceTrigger: vi.fn(),
|
||||
setClaudeTerminalEvidenceListener,
|
||||
setExitListener: vi.fn(),
|
||||
getAgentLaunchToken: () => undefined,
|
||||
setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => {
|
||||
if (listener) {
|
||||
surfaceRetiredListeners.push(listener)
|
||||
}
|
||||
}),
|
||||
isPaneSurfaceRetired: (paneKey: string) => retired.isRetired(paneKey)
|
||||
} as unknown as PtyHandler
|
||||
// Zero attached clients: publishAgentHookEnvelope returns before touching any sink.
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(),
|
||||
activeClientIds: () => [] as number[]
|
||||
} as unknown as RelayDispatcher
|
||||
|
||||
const runtime = new RelayAgentHookRuntime(
|
||||
dispatcher,
|
||||
ptyHandler,
|
||||
join(dir, 'relay.sock'),
|
||||
join(dir, 'hooks')
|
||||
)
|
||||
await runtime.start()
|
||||
try {
|
||||
const server = (runtime as unknown as { hookServer: RelayAgentHookServer }).hookServer
|
||||
expect(await postHook(server, PANE_KEY)).toBe(204)
|
||||
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
|
||||
|
||||
expect(setClaudeTerminalEvidenceListener).toHaveBeenCalledOnce()
|
||||
const observe = setClaudeTerminalEvidenceListener.mock.calls[0]?.[0]
|
||||
if (!observe) {
|
||||
throw new Error('Terminal evidence listener was not registered')
|
||||
}
|
||||
observe(PANE_KEY, { kind: 'title', title: '◐ Task' })
|
||||
observe(PANE_KEY, { kind: 'input', data: '\x1b[27u' })
|
||||
observe(PANE_KEY, { kind: 'title', title: '✳ Task' })
|
||||
expect(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server owns this pane-status map, as checked by cachedPaneKeys above.
|
||||
(server as unknown as CachedPanes).state.lastStatusByPaneKey.get(PANE_KEY)
|
||||
).toMatchObject({
|
||||
payload: { state: 'done', mainAgent: { outcome: 'cancellation' } }
|
||||
})
|
||||
|
||||
// The PTY handler retires the surface: the runtime must drop the cached status immediately,
|
||||
// rather than waiting for a process exit a surviving shell never produces.
|
||||
retired.retire(PANE_KEY)
|
||||
expect(surfaceRetiredListeners).toHaveLength(1)
|
||||
surfaceRetiredListeners[0]({ id: 'pty-1', paneKey: PANE_KEY })
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
|
||||
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
|
||||
expect(cachedPaneKeys(server)).toEqual([])
|
||||
} finally {
|
||||
runtime.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAgentChildWorkAdmission } from '../shared/agent-status-child-work-admission'
|
||||
import { createAgentStatusStore } from '../shared/agent-status-store'
|
||||
import { makeStructuredAgentStatusSubject } from '../shared/agent-status-subject'
|
||||
|
||||
const trustedSubject = makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'ssh:relay-host-a',
|
||||
wslDistro: null,
|
||||
workspaceId: 'folder-workspace-a',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
'session_11111111-1111-4111-8111-111111111111'
|
||||
)
|
||||
|
||||
describe('agent status store relay context', () => {
|
||||
it('instantiates the same shared core and completes an admission/snapshot round-trip', () => {
|
||||
const authority = createAgentStatusStore({ epoch: 'relay-epoch-a', mode: 'authority' })
|
||||
expect(
|
||||
authority.applyMutation({ parent: { subject: trustedSubject, firstObservedAt: 10 } })
|
||||
).not.toBeNull()
|
||||
const admission = createAgentChildWorkAdmission(authority, {
|
||||
mintChildWorkId: () => 'relay-child-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
admission.announce({
|
||||
parent: trustedSubject,
|
||||
provider: 'claude',
|
||||
aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }],
|
||||
fence: { invocationId: 'invocation-1', generation: 1 },
|
||||
lifetime: 'current',
|
||||
kind: 'agent',
|
||||
state: 'working',
|
||||
membership: 'live',
|
||||
observedAt: 20,
|
||||
stoppable: true,
|
||||
provenance: { source: 'transport', producerId: 'relay-fixture' }
|
||||
})
|
||||
).toMatchObject({ accepted: true, childWorkId: 'relay-child-1' })
|
||||
|
||||
const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' })
|
||||
expect(replica.applySnapshot(authority.getSnapshot())).toBe(true)
|
||||
expect(replica.getParent(trustedSubject)?.firstObservedAt).toBe(10)
|
||||
expect(replica.getChildren(trustedSubject)[0]?.childWorkId).toBe('relay-child-1')
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../shared/home-or-filesystem-root', () => ({
|
||||
isTooBroadToPreTrust: () => {
|
||||
throw new Error('homedir unavailable')
|
||||
}
|
||||
}))
|
||||
|
||||
import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn'
|
||||
|
||||
describe('applyRelayAgentWorkspaceTrust when the breadth guard fails', () => {
|
||||
it.each(['claude', 'copilot', 'qoder'] as const)(
|
||||
'skips %s trust and never fails the spawn',
|
||||
async (agent) => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
await expect(
|
||||
applyRelayAgentWorkspaceTrust({ workspacePath: '/srv/wt' }, agent, {}, { wslShell: false })
|
||||
).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,255 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import type * as Os from 'node:os'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentTrustPreset } from '../main/agent-trust-presets'
|
||||
import type { TuiAgent } from '../shared/tui-agent'
|
||||
import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust-test-fixtures'
|
||||
|
||||
const state = vi.hoisted(() => ({ home: '' }))
|
||||
|
||||
// Why: a spawn env without HOME leaves the writers on this process's home, as on a real relay.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Os>()
|
||||
return { ...actual, homedir: () => state.home }
|
||||
})
|
||||
|
||||
import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn'
|
||||
import { buildSshPtySpawnRequest } from '../main/providers/ssh-pty-spawn-request'
|
||||
|
||||
const HOST_SHELL = { wslShell: false }
|
||||
const ORIGINAL_CLAUDE_CONFIG = '{"oauthAccount":{"x":1}}'
|
||||
const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [
|
||||
['claude', 'claude'],
|
||||
['claude-agent-teams', 'claude'],
|
||||
['codex', 'codex'],
|
||||
['cursor', 'cursor'],
|
||||
['copilot', 'copilot'],
|
||||
['qoder', 'qoder'],
|
||||
['qoder-cn', 'qoder-cn']
|
||||
]
|
||||
const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) =>
|
||||
['claude', 'copilot', 'qoder', 'qoder-cn'].includes(preset)
|
||||
)
|
||||
const ALL_PRESETS: AgentTrustPreset[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'copilot',
|
||||
'qoder',
|
||||
'qoder-cn',
|
||||
'antigravity'
|
||||
]
|
||||
|
||||
let root: string
|
||||
let home: string
|
||||
let workspace: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-agent-trust-')))
|
||||
home = join(root, 'home', 'me')
|
||||
mkdirSync(home, { recursive: true })
|
||||
state.home = home
|
||||
writeFileSync(join(home, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 })
|
||||
workspace = join(root, 'wt')
|
||||
mkdirSync(workspace)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function nothingWritten(): boolean {
|
||||
return ALL_PRESETS.every((preset) => !workspaceTrustWritten(home, preset))
|
||||
}
|
||||
|
||||
describe('applyRelayAgentWorkspaceTrust', () => {
|
||||
it.each(AGENTS_WITH_A_RELAY_WRITER)(
|
||||
"writes %s trust on the relay host's own disk",
|
||||
async (agent, preset) => {
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
agent,
|
||||
{ HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(workspaceTrustWritten(home, preset)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it("grants Claude in the remote host's own config, named by the merged spawn env", async () => {
|
||||
const configDir = join(root, 'cfg')
|
||||
mkdirSync(configDir)
|
||||
writeFileSync(join(configDir, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 })
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
'claude',
|
||||
{ CLAUDE_CONFIG_DIR: configDir, HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf-8'))).toEqual({
|
||||
oauthAccount: { x: 1 },
|
||||
projects: { [workspace]: { hasTrustDialogAccepted: true } }
|
||||
})
|
||||
expect(readFileSync(join(home, '.claude.json'), 'utf-8')).toBe(ORIGINAL_CLAUDE_CONFIG)
|
||||
})
|
||||
|
||||
it('never creates a Claude config file Claude has not written', async () => {
|
||||
const emptyHome = join(root, 'empty-home')
|
||||
mkdirSync(emptyHome)
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
'claude',
|
||||
{ HOME: emptyHome },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(existsSync(join(emptyHome, '.claude.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('writes Codex trust into the CODEX_HOME the spawn env names', async () => {
|
||||
const codexHome = join(root, 'codex-home')
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
'codex',
|
||||
{ HOME: home, CODEX_HOME: codexHome },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf-8')).toContain(
|
||||
`[projects."${workspace}"]`
|
||||
)
|
||||
expect(workspaceTrustWritten(home, 'codex')).toBe(false)
|
||||
})
|
||||
|
||||
it("stores a Codex worktree's own path, not the relay home it hangs off, as a local launch does", async () => {
|
||||
const worktree = join(root, 'worktrees', 'feature')
|
||||
linkGitWorktree(home, worktree)
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: worktree },
|
||||
'codex',
|
||||
{ HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8')
|
||||
expect(written).toContain(`[projects."${worktree}"]`)
|
||||
expect(written).not.toContain(`[projects."${home}"]`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['codex', 'codex'],
|
||||
['cursor', 'cursor']
|
||||
] as const)(
|
||||
'trusts the relay home for %s, whose trust there covers only the home',
|
||||
async (agent, preset) => {
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: home },
|
||||
agent,
|
||||
{ HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(workspaceTrustWritten(home, preset)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
const tooBroad: [string, () => string][] = [
|
||||
['the relay home', () => home],
|
||||
['a folder containing the relay home', () => join(root, 'home')],
|
||||
[
|
||||
'a symlink to the relay home',
|
||||
() => {
|
||||
symlinkSync(home, join(workspace, 'home-link'), 'junction')
|
||||
return join(workspace, 'home-link')
|
||||
}
|
||||
]
|
||||
]
|
||||
describe.each(tooBroad)('for %s', (_label, arrange) => {
|
||||
it.each(AGENTS_THAT_INHERIT_TRUST)('writes no %s trust', async (agent) => {
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: arrange() },
|
||||
agent,
|
||||
{ HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(nothingWritten()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(AGENTS_WITH_A_RELAY_WRITER)(
|
||||
"never writes this host's files for %s started in a WSL guest",
|
||||
async (agent) => {
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
agent,
|
||||
{ HOME: home },
|
||||
{ wslShell: true }
|
||||
)
|
||||
expect(nothingWritten()).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Antigravity to ask, since its writer is unverified on SSH hosts', async () => {
|
||||
await applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
'antigravity',
|
||||
{ HOME: home },
|
||||
HOST_SHELL
|
||||
)
|
||||
expect(workspaceTrustWritten(home, 'antigravity')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no request', () => undefined, 'claude'],
|
||||
['a malformed request', () => ({ workspacePath: 42 }), 'claude'],
|
||||
['an earlier-shaped request', () => ({ worktreeRoot: workspace, trusted: true }), 'codex'],
|
||||
['an agent with no trust preset', () => ({ workspacePath: workspace }), 'gemini'],
|
||||
['no declared agent', () => ({ workspacePath: workspace }), undefined]
|
||||
] as const)('writes nothing, so the agent asks, for %s', async (_label, request, agent) => {
|
||||
await applyRelayAgentWorkspaceTrust(request(), agent, { HOME: home }, HOST_SHELL)
|
||||
expect(nothingWritten()).toBe(true)
|
||||
})
|
||||
|
||||
it('never fails the spawn when a writer throws', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const codexHome = join(root, 'codex-home')
|
||||
// Why: a config.toml that cannot be read as a file makes the Codex writer throw.
|
||||
mkdirSync(join(codexHome, 'config.toml'), { recursive: true })
|
||||
await expect(
|
||||
applyRelayAgentWorkspaceTrust(
|
||||
{ workspacePath: workspace },
|
||||
'codex',
|
||||
{ HOME: home, CODEX_HOME: codexHome },
|
||||
HOST_SHELL
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSshPtySpawnRequest', () => {
|
||||
it('forwards the workspace to trust as an optional field an old relay ignores', () => {
|
||||
const request = buildSshPtySpawnRequest({
|
||||
options: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'codex',
|
||||
agentWorkspaceTrust: { workspacePath: '/w' }
|
||||
},
|
||||
supportsCreateOperation: false
|
||||
})
|
||||
expect(request.agentWorkspaceTrust).toEqual({ workspacePath: '/w' })
|
||||
expect(request.launchAgent).toBe('codex')
|
||||
expect(
|
||||
buildSshPtySpawnRequest({ options: { cols: 80, rows: 24 }, supportsCreateOperation: false })
|
||||
).not.toHaveProperty('agentWorkspaceTrust')
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request'
|
||||
import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../shared/tui-agent'
|
||||
import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file'
|
||||
import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline'
|
||||
import {
|
||||
applyWorkspaceTrustOnThisHost,
|
||||
launchedAgentHome
|
||||
} from '../main/execution-host-workspace-trust'
|
||||
|
||||
/**
|
||||
* Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic
|
||||
* rename and refusal rules act on local disk, with no round trip across the SSH link.
|
||||
*/
|
||||
export async function applyRelayAgentWorkspaceTrust(
|
||||
rawRequest: unknown,
|
||||
launchAgent: TuiAgent | undefined,
|
||||
spawnEnv: Record<string, string | undefined>,
|
||||
launch: { wslShell: boolean }
|
||||
): Promise<void> {
|
||||
const request = parseAgentWorkspaceTrustSpawnRequest(rawRequest)
|
||||
const preset = launchAgent ? TUI_AGENT_CONFIG[launchAgent].preflightTrust : undefined
|
||||
// Why: an agent inside a WSL guest reads the guest's files, not this Windows host's, and
|
||||
// Antigravity's writer is unverified on SSH hosts, so it still asks there.
|
||||
if (!request || !preset || preset === 'antigravity' || launch.wslShell) {
|
||||
return
|
||||
}
|
||||
await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => {
|
||||
const keyStyle = process.platform === 'win32' ? 'win32' : 'posix'
|
||||
const homeDir = launchedAgentHome(spawnEnv)
|
||||
return {
|
||||
homes: [homeDir, homedir()],
|
||||
agentHome: homeDir,
|
||||
claudeConfig: () => ({
|
||||
configFile: resolveClaudeGlobalConfigFile({
|
||||
env: spawnEnv,
|
||||
homeDir,
|
||||
style: keyStyle,
|
||||
exists: existsSync
|
||||
}),
|
||||
keyStyle
|
||||
}),
|
||||
codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')],
|
||||
// Why: every write here is on the relay's own disk, so each preset gets the local budget.
|
||||
deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
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 '../wsl-guest/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({
|
||||
homeDirectory: remoteHome,
|
||||
environment: {}
|
||||
}),
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import {
|
||||
searchSessionService,
|
||||
sessionSearchServiceStatus
|
||||
} from '../main/ai-vault-search/session-search-service-registry'
|
||||
import { homedir } from 'node:os'
|
||||
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import {
|
||||
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import {
|
||||
SSH_AI_VAULT_LIST_LIMIT_MAX,
|
||||
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD,
|
||||
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
|
||||
type SshAiVaultRelayListParams
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import type { RelayDispatcher } from '../wsl-guest/dispatcher'
|
||||
import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator'
|
||||
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
||||
|
||||
type AiVaultHandlerOptions = {
|
||||
remoteHome?: string
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
service?: RelayAiVaultServiceApi
|
||||
}
|
||||
|
||||
export class AiVaultHandler {
|
||||
private readonly remoteHome: string
|
||||
private readonly scanCoordinator = new AiVaultScanCoordinator()
|
||||
|
||||
constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) {
|
||||
dispatcher.onRequest('aiVault.searchSessions', (params) =>
|
||||
searchSessionService(params, 'relay')
|
||||
)
|
||||
dispatcher.onRequest('aiVault.searchStatus', (params) =>
|
||||
sessionSearchServiceStatus(params, 'relay')
|
||||
)
|
||||
this.remoteHome = options.remoteHome ?? homedir()
|
||||
const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform()
|
||||
// Why: an OS/arch this build has no path flavor for must not abort relay
|
||||
// startup — leaving the method unregistered soft-disables the feature and
|
||||
// the host falls back to its own SSH filesystem scan.
|
||||
if (!hostPlatform) {
|
||||
relayLogLine(
|
||||
`[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}`
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: same reasoning as an unsupported platform. Throwing here would take
|
||||
// relay startup — and every PTY on the host — down over a Vault wiring bug.
|
||||
const service = options.service
|
||||
if (!service) {
|
||||
relayLogLine('[relay] Agent Session History disabled: service unavailable')
|
||||
return
|
||||
}
|
||||
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
|
||||
this.listSessions(service, params, context.signal)
|
||||
)
|
||||
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
|
||||
this.resolveSessionTitles(service, params, context.signal)
|
||||
)
|
||||
}
|
||||
|
||||
private async resolveSessionTitles(
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
try {
|
||||
return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
// Why: titles are decoration. Degrade like an unresolvable title instead of
|
||||
// failing the RPC, which would surface a raw error on every list row.
|
||||
relayLogLine(
|
||||
`[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return { titles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessions(
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
const params = normalizeSshAiVaultRelayListParams(rawParams)
|
||||
let result: AiVaultListResult
|
||||
try {
|
||||
result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
includeAntigravityIdeSessions: params.includeAntigravityIdeSessions,
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
scopePathsTruncated: params.scopePathsTruncated
|
||||
}),
|
||||
force: params.force,
|
||||
signal,
|
||||
start: (scanSignal) => service.listSessions(params, scanSignal)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
result = {
|
||||
sessions: [],
|
||||
scannedAt: new Date().toISOString(),
|
||||
issues: [
|
||||
{
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: this.remoteHome,
|
||||
message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if (!params.scopePathsTruncated) {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
issues: [
|
||||
...result.issues,
|
||||
{
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
agent: 'codex',
|
||||
kind: 'scope',
|
||||
path: this.remoteHome,
|
||||
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTitleRequests(raw: unknown): AiVaultSessionTitleRequest[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
const requests: AiVaultSessionTitleRequest[] = []
|
||||
for (const value of raw.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const agent = record.agent
|
||||
const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
|
||||
const transcriptPath =
|
||||
typeof record.transcriptPath === 'string' ? record.transcriptPath.trim() : ''
|
||||
if (
|
||||
(agent !== 'claude' && agent !== 'codex') ||
|
||||
!sessionId ||
|
||||
sessionId.length > 512 ||
|
||||
!transcriptPath ||
|
||||
transcriptPath.length > 32_768
|
||||
) {
|
||||
continue
|
||||
}
|
||||
requests.push({ agent, sessionId, transcriptPath })
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
export function normalizeSshAiVaultRelayListParams(
|
||||
params: Record<string, unknown>
|
||||
): SshAiVaultRelayListParams {
|
||||
const rawLimit = params.limit
|
||||
const unlimited = params.unlimited === true
|
||||
const limit =
|
||||
!unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
|
||||
? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX)
|
||||
: undefined
|
||||
const scopePaths = Array.isArray(params.scopePaths)
|
||||
? params.scopePaths
|
||||
.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
typeof path === 'string' &&
|
||||
path.trim().length > 0 &&
|
||||
path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH
|
||||
)
|
||||
: undefined
|
||||
const scopePathsTruncated =
|
||||
params.scopePathsTruncated === true ||
|
||||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
return {
|
||||
...(params.includeAntigravityIdeSessions === true
|
||||
? { includeAntigravityIdeSessions: true }
|
||||
: {}),
|
||||
...(unlimited ? { unlimited: true } : {}),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(params.force === true ? { force: true } : {}),
|
||||
...(scopePaths === undefined ? {} : { scopePaths }),
|
||||
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function currentRelayHostPlatform(): RemoteHostPlatform | null {
|
||||
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultScanIssue } from '../shared/ai-vault-types'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { resetRemoteSessionParseCacheForTests } from '../main/ai-vault/remote-session-parse-cache'
|
||||
import { listOpenCodeSqliteSessions } from '../main/ai-vault/session-scanner-opencode-sqlite-list'
|
||||
import { listOpenCode2SqliteSessions } from '../main/ai-vault/session-scanner-opencode2-sqlite-list'
|
||||
import { parseOpenCodeSqliteSession } from '../main/ai-vault/session-scanner-opencode-sqlite'
|
||||
import { parseOpenCode2SqliteSession } from '../main/ai-vault/session-scanner-opencode2-sqlite'
|
||||
import { writeOpenCodeSqliteDatabase } from '../main/ai-vault/session-scanner-opencode-sqlite-fixture'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import {
|
||||
createRelayOpenCodeReader,
|
||||
openCodeReaderUnavailableMessage,
|
||||
type RelayOpenCodeReaderOptions
|
||||
} from './ai-vault-opencode-reader'
|
||||
|
||||
const directories: string[] = []
|
||||
const disposables: { dispose(): void }[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const disposable of disposables.splice(0)) {
|
||||
disposable.dispose()
|
||||
}
|
||||
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
resetRemoteSessionParseCacheForTests()
|
||||
})
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'orca-relay-opencode-'))
|
||||
directories.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
function factory() {
|
||||
const reader = { list: vi.fn(async () => []), parse: vi.fn(async () => null), dispose: vi.fn() }
|
||||
return {
|
||||
reader,
|
||||
create: vi.fn(
|
||||
(_options: Parameters<NonNullable<RelayOpenCodeReaderOptions['readerFactory']>>[0]) => reader
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('relay OpenCode reader', () => {
|
||||
it('coalesces a persistent reader selected by the provisioned runtime reference', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const executable = join(baseDir, 'runtime', 'bun')
|
||||
await writeFile(
|
||||
join(baseDir, 'opencode-sqlite-runtime.json'),
|
||||
JSON.stringify({ protocol: 1, executable })
|
||||
)
|
||||
const { reader, create } = factory()
|
||||
const probe = vi.fn(() => false)
|
||||
const provider = createRelayAiVaultFilesystemProvider({
|
||||
baseDir,
|
||||
readerFactory: create,
|
||||
canReadSqlite: probe,
|
||||
environment: {
|
||||
HOME: baseDir,
|
||||
NODE_OPTIONS: '--require=bad',
|
||||
XDG_DATA_HOME: join(baseDir, 'data')
|
||||
}
|
||||
})
|
||||
disposables.push(provider)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 10, issues }
|
||||
await Promise.all([provider.openCode?.list(args), provider.openCode?.list(args)])
|
||||
expect(issues).toEqual([])
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executable, args: [join(baseDir, 'opencode-sqlite-reader.cjs')] })
|
||||
)
|
||||
expect(create.mock.calls[0]?.[0]?.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
provider.dispose()
|
||||
expect(reader.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports an unavailable reader once per source and retries after provisioning', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const { create } = factory()
|
||||
const reader = createRelayOpenCodeReader({
|
||||
baseDir,
|
||||
readerFactory: create,
|
||||
canReadSqlite: () => false
|
||||
})
|
||||
disposables.push(reader)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const args = {
|
||||
dbPaths: [join(baseDir, 'opencode.db'), join(baseDir, 'opencode-other.db')],
|
||||
limit: 10,
|
||||
issues
|
||||
}
|
||||
await reader.list(args)
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0]).toMatchObject({
|
||||
agent: 'opencode',
|
||||
kind: 'scope',
|
||||
message: openCodeReaderUnavailableMessage(process.version)
|
||||
})
|
||||
expect(issues[0]?.message).toContain('22.16 or newer')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
await writeFile(
|
||||
join(baseDir, 'opencode-sqlite-runtime.json'),
|
||||
JSON.stringify({ protocol: 1, executable: join(baseDir, 'bun') })
|
||||
)
|
||||
await reader.list({ ...args, issues: [] })
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the current runtime only after a successful SQLite read probe', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const { create } = factory()
|
||||
const reader = createRelayOpenCodeReader({
|
||||
baseDir,
|
||||
readerFactory: create,
|
||||
currentExecutable: process.execPath
|
||||
})
|
||||
disposables.push(reader)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
await reader.list({ dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues })
|
||||
expect(issues).toEqual([])
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ executable: process.execPath }))
|
||||
})
|
||||
|
||||
it('replaces the persistent reader after a repaired runtime reference changes', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const referencePath = join(baseDir, 'opencode-sqlite-runtime.json')
|
||||
await writeFile(
|
||||
referencePath,
|
||||
JSON.stringify({ protocol: 1, executable: join(baseDir, 'old-bun') })
|
||||
)
|
||||
const first = factory()
|
||||
const second = factory()
|
||||
const create = vi
|
||||
.fn<NonNullable<RelayOpenCodeReaderOptions['readerFactory']>>()
|
||||
.mockReturnValueOnce(first.reader)
|
||||
.mockReturnValueOnce(second.reader)
|
||||
const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create })
|
||||
disposables.push(reader)
|
||||
const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues: [] }
|
||||
await reader.list(args)
|
||||
await writeFile(
|
||||
referencePath,
|
||||
JSON.stringify({ protocol: 1, executable: join(baseDir, 'repaired-bun') })
|
||||
)
|
||||
await reader.list(args)
|
||||
expect(first.reader.dispose).toHaveBeenCalledOnce()
|
||||
expect(second.reader.list).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ executable: join(baseDir, 'repaired-bun') })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ protocol: 2, executable: '/runtime/bun' },
|
||||
{ protocol: 1, executable: 'bun' },
|
||||
{ protocol: 1, executable: '/runtime/bun\0bad' }
|
||||
])('refuses an invalid runtime reference: %j', async (reference) => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
await writeFile(join(baseDir, 'opencode-sqlite-runtime.json'), JSON.stringify(reference))
|
||||
const { create } = factory()
|
||||
const reader = createRelayOpenCodeReader({
|
||||
baseDir,
|
||||
readerFactory: create,
|
||||
canReadSqlite: () => true
|
||||
})
|
||||
disposables.push(reader)
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
await reader.list({ dbPaths: ['opencode.db'], limit: 1, issues })
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the host database overrides including relative and in-memory paths', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const data = join(baseDir, 'data')
|
||||
const reader = createRelayOpenCodeReader({
|
||||
baseDir,
|
||||
environment: { XDG_DATA_HOME: data, OPENCODE_DB: 'opencode-team.db' }
|
||||
})
|
||||
expect(reader.dataDirectory).toBe(join(data, 'opencode'))
|
||||
expect(reader.databasePath).toBe(join(data, 'opencode', 'opencode-team.db'))
|
||||
expect(
|
||||
createRelayOpenCodeReader({ environment: { OPENCODE_DB: ':memory:' } }).databasePath
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not start a child for empty or cancelled scans', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const { create } = factory()
|
||||
const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create })
|
||||
disposables.push(reader)
|
||||
await reader.list({ dbPaths: [], limit: 1, issues: [] })
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(
|
||||
reader.list({ dbPaths: ['opencode.db'], limit: 1, issues: [], signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans the host database and legacy JSON through the existing readers', async () => {
|
||||
const baseDir = await temporaryDirectory()
|
||||
const xdg = join(baseDir, 'data')
|
||||
const dbPath = join(xdg, 'opencode', 'opencode-team.db')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{ id: 'migrated', turns: [{ role: 'user', parts: ['Database prompt'] }] }
|
||||
])
|
||||
const sessionDir = join(xdg, 'opencode', 'storage', 'session', 'project')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, 'migrated.json'),
|
||||
JSON.stringify({ id: 'migrated', title: 'Stale title' })
|
||||
)
|
||||
await writeFile(
|
||||
join(sessionDir, 'legacy.json'),
|
||||
JSON.stringify({ id: 'legacy', title: 'Legacy title', directory: '/project' })
|
||||
)
|
||||
const readerFactory: NonNullable<RelayOpenCodeReaderOptions['readerFactory']> = () => ({
|
||||
list: (args) =>
|
||||
args.agent === 'opencode2'
|
||||
? listOpenCode2SqliteSessions(args)
|
||||
: listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }),
|
||||
parse: async (args) =>
|
||||
args.agent === 'opencode2'
|
||||
? parseOpenCode2SqliteSession(args)
|
||||
: parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }),
|
||||
dispose() {}
|
||||
})
|
||||
const provider = createRelayAiVaultFilesystemProvider({
|
||||
baseDir,
|
||||
environment: { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' },
|
||||
readerFactory
|
||||
})
|
||||
disposables.push(provider)
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
remoteHome: baseDir,
|
||||
executionHostId: 'ssh:actual',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((entry) => entry.sessionId).sort()).toEqual(['legacy', 'migrated'])
|
||||
expect(result.sessions.find((entry) => entry.sessionId === 'migrated')).toMatchObject({
|
||||
filePath: dbPath,
|
||||
executionHostId: 'ssh:actual',
|
||||
previewMessages: [expect.objectContaining({ text: 'Database prompt' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import type { RemoteOpenCodeSessionReader } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from '../main/ai-vault/remote-session-file-stat'
|
||||
import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-opencode-parser'
|
||||
import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client'
|
||||
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
|
||||
import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory'
|
||||
import SyncDatabase, { isSqliteAvailable } from '../main/sqlite/sync-database'
|
||||
|
||||
type Reader = Pick<RemoteOpenCodeSessionReader, 'list' | 'parse'> & { dispose(): void }
|
||||
|
||||
export type RelayOpenCodeReaderOptions = {
|
||||
baseDir?: string
|
||||
environment?: NodeJS.ProcessEnv
|
||||
homeDirectory?: string
|
||||
currentExecutable?: string
|
||||
readerFactory?: (options: Parameters<typeof createOpenCodeSqliteProcessClient>[0]) => Reader
|
||||
canReadSqlite?: () => boolean
|
||||
}
|
||||
|
||||
export function createRelayOpenCodeReader(
|
||||
options: RelayOpenCodeReaderOptions = {}
|
||||
): RemoteOpenCodeSessionReader & { dispose(): void } {
|
||||
const environment = options.environment ?? process.env
|
||||
const dataDirectory = resolveOpenCodeDataDirectory(
|
||||
environment,
|
||||
options.homeDirectory ?? homedir()
|
||||
)
|
||||
const override = environment.OPENCODE_DB?.trim()
|
||||
const baseDir = options.baseDir ?? __dirname
|
||||
let reader: Reader | undefined
|
||||
let executable: string | undefined
|
||||
let pending: Promise<Reader> | undefined
|
||||
let disposed = false
|
||||
const getReader = (refreshRuntime = false): Promise<Reader> => {
|
||||
if (disposed) {
|
||||
return Promise.reject(new Error('OpenCode database reader was disposed.'))
|
||||
}
|
||||
if (reader && !refreshRuntime) {
|
||||
return Promise.resolve(reader)
|
||||
}
|
||||
pending ??= (async () => {
|
||||
const configured = await readRuntimeExecutable(join(baseDir, 'opencode-sqlite-runtime.json'))
|
||||
if (!configured && !(options.canReadSqlite ?? canCurrentRuntimeReadSqlite)()) {
|
||||
throw new Error(openCodeReaderUnavailableMessage(process.version))
|
||||
}
|
||||
if (disposed) {
|
||||
throw new Error('OpenCode database reader was disposed.')
|
||||
}
|
||||
const nextExecutable = configured ?? options.currentExecutable ?? process.execPath
|
||||
if (!reader || executable !== nextExecutable) {
|
||||
reader?.dispose()
|
||||
reader = (options.readerFactory ?? createOpenCodeSqliteProcessClient)({
|
||||
executable: nextExecutable,
|
||||
args: [join(baseDir, 'opencode-sqlite-reader.cjs')],
|
||||
env: buildRelayAiVaultServiceEnv(environment)
|
||||
})
|
||||
executable = nextExecutable
|
||||
}
|
||||
return reader
|
||||
})().finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
return {
|
||||
dataDirectory,
|
||||
...(override
|
||||
? {
|
||||
databasePath:
|
||||
override === ':memory:'
|
||||
? null
|
||||
: isAbsolute(override)
|
||||
? override
|
||||
: join(dataDirectory, override)
|
||||
}
|
||||
: {}),
|
||||
async list(args) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (args.dbPaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await (await getReader(true)).list(args)
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
args.issues.push({
|
||||
agent: args.agent ?? 'opencode',
|
||||
kind: 'scope',
|
||||
path: args.dbPaths[0] ?? dataDirectory,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
},
|
||||
async parse(args) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
return (await getReader()).parse(args)
|
||||
},
|
||||
parseLegacy: parseOpenCodeSessionFile,
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
reader?.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeExecutable(path: string): Promise<string | undefined> {
|
||||
let file
|
||||
try {
|
||||
file = await open(path, 'r')
|
||||
} catch (error) {
|
||||
if (isMissingRemoteSessionPathError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
const bytes = Buffer.alloc(16_385)
|
||||
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0)
|
||||
if (bytesRead > 16_384) {
|
||||
throw new Error('OpenCode database runtime reference exceeds its size limit.')
|
||||
}
|
||||
const reference: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'))
|
||||
if (
|
||||
typeof reference !== 'object' ||
|
||||
reference === null ||
|
||||
!('protocol' in reference) ||
|
||||
reference.protocol !== 1 ||
|
||||
!('executable' in reference) ||
|
||||
typeof reference.executable !== 'string' ||
|
||||
!isAbsolute(reference.executable) ||
|
||||
reference.executable.includes('\0')
|
||||
) {
|
||||
throw new Error('OpenCode database runtime reference is invalid.')
|
||||
}
|
||||
return reference.executable
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rung C runs the relay on the host's Node, which may predate the full node:sqlite surface. */
|
||||
export function openCodeReaderUnavailableMessage(nodeVersion: string): string {
|
||||
return (
|
||||
`OpenCode history is unavailable on this host for now: its Node.js ${nodeVersion} has no ` +
|
||||
'complete node:sqlite (22.16 or newer), and Orca has not installed its own Node reader here yet.'
|
||||
)
|
||||
}
|
||||
|
||||
function canCurrentRuntimeReadSqlite(): boolean {
|
||||
// Why: same admission as the host probes, so Node 22.13–22.15 waits for the pinned runtime.
|
||||
if (!isSqliteAvailable()) {
|
||||
return false
|
||||
}
|
||||
let db: SyncDatabase | undefined
|
||||
try {
|
||||
db = new SyncDatabase(':memory:')
|
||||
return db.prepare('SELECT 1 AS ready').get()?.ready === 1
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { AiVaultServiceTestChild } from '../main/ai-vault/session-scanner-service-test-child'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import type { RelayAiVaultServiceChildMessage } from './ai-vault-service-protocol'
|
||||
import type { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
|
||||
const scan = vi.fn<typeof scanRemoteAiVaultSessions>()
|
||||
const disposeProvider = vi.fn<() => void>()
|
||||
|
||||
const result = { sessions: [], issues: [], scannedAt: '2026-10-02T13:00:00.000Z' }
|
||||
let outbound: RelayAiVaultServiceChildMessage[] = []
|
||||
let frames: RelayAiVaultServiceChildMessage[] = []
|
||||
let restoreProcess = (): void => undefined
|
||||
let client: RelayAiVaultServiceClient | undefined
|
||||
|
||||
class BridgeChild extends AiVaultServiceTestChild {
|
||||
override send(message: unknown, callback?: (error: Error | null) => void): boolean {
|
||||
super.send(message, callback)
|
||||
process.emit('message', message)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function deliver(child: BridgeChild): void {
|
||||
const message = outbound.shift()
|
||||
if (!message) {
|
||||
throw new Error('Expected one complete service reply in transit')
|
||||
}
|
||||
child.emit('message', message)
|
||||
}
|
||||
|
||||
async function turn(): Promise<void> {
|
||||
await setImmediate()
|
||||
}
|
||||
|
||||
function createClient(): BridgeChild {
|
||||
const child = new BridgeChild()
|
||||
client = new RelayAiVaultServiceClient({
|
||||
processFactory: () => child.asChildProcess(),
|
||||
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
function currentClient(): RelayAiVaultServiceClient {
|
||||
if (!client) {
|
||||
throw new Error('Expected the owned service client')
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
async function close(child: BridgeChild): Promise<void> {
|
||||
const disposed = client?.dispose()
|
||||
await turn()
|
||||
child.emit('exit', 0)
|
||||
await disposed
|
||||
expect(disposeProvider).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
client = undefined
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
scan.mockReset()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
outbound = []
|
||||
frames = []
|
||||
scan.mockResolvedValue(result)
|
||||
const send = Object.getOwnPropertyDescriptor(process, 'send')
|
||||
const disconnect = Object.getOwnPropertyDescriptor(process, 'disconnect')
|
||||
const messages = new Set(process.listeners('message'))
|
||||
const disconnects = new Set(process.listeners('disconnect'))
|
||||
Object.defineProperty(process, 'send', {
|
||||
configurable: true,
|
||||
value: (message: RelayAiVaultServiceChildMessage) => {
|
||||
outbound.push(message)
|
||||
frames.push(message)
|
||||
return true
|
||||
}
|
||||
})
|
||||
Object.defineProperty(process, 'disconnect', { configurable: true, value: vi.fn() })
|
||||
restoreProcess = () => {
|
||||
for (const listener of process.listeners('message')) {
|
||||
if (!messages.has(listener)) {
|
||||
process.removeListener('message', listener)
|
||||
}
|
||||
}
|
||||
for (const listener of process.listeners('disconnect')) {
|
||||
if (!disconnects.has(listener)) {
|
||||
process.removeListener('disconnect', listener)
|
||||
}
|
||||
}
|
||||
if (send) {
|
||||
Object.defineProperty(process, 'send', send)
|
||||
} else {
|
||||
delete process.send
|
||||
}
|
||||
if (disconnect) {
|
||||
Object.defineProperty(process, 'disconnect', disconnect)
|
||||
} else {
|
||||
delete process.disconnect
|
||||
}
|
||||
}
|
||||
const scanner = await import('../main/ai-vault/remote-session-scanner')
|
||||
vi.spyOn(scanner, 'scanRemoteAiVaultSessions').mockImplementation(scan)
|
||||
const titles = await import('../main/ai-vault/session-title-file-reader')
|
||||
vi.spyOn(titles, 'readAiVaultSessionTitlesFromFiles').mockResolvedValue({ titles: [] })
|
||||
const filesystem = await import('./ai-vault-service-filesystem')
|
||||
const createProvider = filesystem.createRelayAiVaultFilesystemProvider
|
||||
vi.spyOn(filesystem, 'createRelayAiVaultFilesystemProvider').mockImplementation((options) => {
|
||||
const provider = createProvider(options)
|
||||
const dispose = provider.dispose
|
||||
vi.spyOn(provider, 'dispose').mockImplementation(() => {
|
||||
disposeProvider()
|
||||
dispose()
|
||||
})
|
||||
return provider
|
||||
})
|
||||
await import('./ai-vault-service-entry')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.emit('message', { type: 'shutdown' })
|
||||
await turn()
|
||||
restoreProcess()
|
||||
vi.clearAllTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('actual parent cancellation during completed-response transit retains zero completed IDs', async () => {
|
||||
const child = createClient()
|
||||
const errors: unknown[] = []
|
||||
const retained: Set<unknown>[] = []
|
||||
const counts: number[] = []
|
||||
const warm = Promise.withResolvers<typeof result>()
|
||||
scan.mockImplementationOnce(() => warm.promise)
|
||||
const warmController = new AbortController()
|
||||
const warmResponse = currentClient()
|
||||
.listSessions({}, warmController.signal)
|
||||
.catch((error: unknown) => error)
|
||||
deliver(child)
|
||||
await turn()
|
||||
const warmAdd = vi.spyOn(Set.prototype, 'add')
|
||||
try {
|
||||
warmController.abort()
|
||||
const callIndex = warmAdd.mock.calls.findIndex(([value]) => value === 1)
|
||||
const owned = warmAdd.mock.contexts[callIndex]
|
||||
if (!(owned instanceof Set)) {
|
||||
throw new Error('Expected the actual native cancellation set for the admitted warm request')
|
||||
}
|
||||
retained.push(owned)
|
||||
} finally {
|
||||
warmAdd.mockRestore()
|
||||
}
|
||||
errors.push(await warmResponse)
|
||||
warm.resolve(result)
|
||||
await turn()
|
||||
deliver(child)
|
||||
await turn()
|
||||
expect(retained[0]?.size).toBe(0)
|
||||
for (let index = 0; index < 64; index++) {
|
||||
const id = index + 2
|
||||
if (index % 3 !== 2) {
|
||||
scan.mockImplementationOnce(async () => {
|
||||
if (index % 3 === 1) {
|
||||
throw new Error(`scan failure ${id}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const response = (
|
||||
index % 3 === 2
|
||||
? currentClient().resolveSessionTitles([], controller.signal)
|
||||
: currentClient().listSessions({}, controller.signal)
|
||||
).catch((error: unknown) => error)
|
||||
await turn()
|
||||
expect(outbound).toHaveLength(1)
|
||||
expect(outbound[0]).toEqual(
|
||||
index % 3 === 2
|
||||
? { type: 'result', id, operation: 'titles', value: { titles: [] } }
|
||||
: index % 3 === 1
|
||||
? { type: 'error', id, message: `scan failure ${id}` }
|
||||
: { type: 'result', id, operation: 'list', value: result }
|
||||
)
|
||||
const add = vi.spyOn(Set.prototype, 'add')
|
||||
try {
|
||||
controller.abort()
|
||||
const matching = add.mock.calls.flatMap(([value], callIndex) =>
|
||||
value === id ? [callIndex] : []
|
||||
)
|
||||
counts.push(matching.length)
|
||||
for (const callIndex of matching) {
|
||||
const owned = add.mock.contexts[callIndex]
|
||||
if (!(owned instanceof Set)) {
|
||||
throw new Error('Expected the actual native cancellation set')
|
||||
}
|
||||
retained.push(owned)
|
||||
}
|
||||
} finally {
|
||||
add.mockRestore()
|
||||
}
|
||||
errors.push(await response)
|
||||
deliver(child)
|
||||
await turn()
|
||||
}
|
||||
expect(
|
||||
errors.every(
|
||||
(error) =>
|
||||
error instanceof Error &&
|
||||
error.name === 'AbortError' &&
|
||||
error.message === 'The operation was aborted.'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(scan).toHaveBeenCalledTimes(44)
|
||||
expect(
|
||||
child.sent.filter(
|
||||
(message) =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'cancel'
|
||||
)
|
||||
).toEqual(Array.from({ length: 65 }, (_, index) => ({ type: 'cancel', id: index + 1 })))
|
||||
const sizes = [...new Set(retained)].map((owned) => owned.size)
|
||||
await close(child)
|
||||
expect(frames).toEqual([
|
||||
{ type: 'ready', protocol: 1, pid: process.pid },
|
||||
{ type: 'result', id: 1, operation: 'list', value: result },
|
||||
...Array.from({ length: 64 }, (_, index) => {
|
||||
const id = index + 2
|
||||
return index % 3 === 2
|
||||
? { type: 'result', id, operation: 'titles', value: { titles: [] } }
|
||||
: index % 3 === 1
|
||||
? { type: 'error', id, message: `scan failure ${id}` }
|
||||
: { type: 'result', id, operation: 'list', value: result }
|
||||
})
|
||||
])
|
||||
expect(counts).toEqual(Array(64).fill(0))
|
||||
expect(sizes).toEqual([0])
|
||||
})
|
||||
|
||||
it('an admitted active cancellation still aborts its exact scanner signal', async () => {
|
||||
const child = createClient()
|
||||
const pending = Promise.withResolvers<typeof result>()
|
||||
let signal: AbortSignal | undefined
|
||||
scan.mockImplementationOnce((options) => {
|
||||
signal = options.signal
|
||||
return pending.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const response = currentClient()
|
||||
.listSessions({}, controller.signal)
|
||||
.catch((error: unknown) => error)
|
||||
deliver(child)
|
||||
await turn()
|
||||
expect(signal?.aborted).toBe(false)
|
||||
controller.abort()
|
||||
await expect(response).resolves.toMatchObject({ name: 'AbortError' })
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending.resolve(result)
|
||||
await turn()
|
||||
expect(outbound).toEqual([{ type: 'result', id: 1, operation: 'list', value: result }])
|
||||
deliver(child)
|
||||
await close(child)
|
||||
})
|
||||
|
||||
it('a pending queued cancellation still aborts before the queued scanner begins', async () => {
|
||||
const child = createClient()
|
||||
const pending = Promise.withResolvers<typeof result>()
|
||||
const signals: AbortSignal[] = []
|
||||
scan.mockImplementation((options) => {
|
||||
if (!options.signal) {
|
||||
throw new Error('Expected the owned scanner signal')
|
||||
}
|
||||
signals.push(options.signal)
|
||||
return signals.length === 1 ? pending.promise : Promise.resolve(result)
|
||||
})
|
||||
const response = currentClient().listSessions({})
|
||||
deliver(child)
|
||||
await turn()
|
||||
process.emit('message', { type: 'request', id: 999, operation: 'list', params: {} })
|
||||
process.emit('message', { type: 'cancel', id: 999 })
|
||||
expect(signals).toHaveLength(1)
|
||||
pending.resolve(result)
|
||||
await turn()
|
||||
expect(signals).toHaveLength(2)
|
||||
expect(signals[1]?.aborted).toBe(true)
|
||||
expect(outbound).toEqual([
|
||||
{ type: 'result', id: 1, operation: 'list', value: result },
|
||||
{ type: 'result', id: 999, operation: 'list', value: result }
|
||||
])
|
||||
deliver(child)
|
||||
await expect(response).resolves.toEqual(result)
|
||||
deliver(child)
|
||||
await close(child)
|
||||
})
|
||||
|
||||
it('unknown, pre-init and duplicate completed cancels preserve the next live request', async () => {
|
||||
process.emit('message', { type: 'cancel', id: 9000 })
|
||||
const child = createClient()
|
||||
const first = currentClient().listSessions({})
|
||||
deliver(child)
|
||||
await turn()
|
||||
deliver(child)
|
||||
await expect(first).resolves.toEqual(result)
|
||||
for (const id of [1, 1, 9000, 9000]) {
|
||||
process.emit('message', { type: 'cancel', id })
|
||||
}
|
||||
expect(outbound).toEqual([])
|
||||
const live = currentClient().listSessions({})
|
||||
await turn()
|
||||
expect(scan.mock.calls[1]?.[0].signal?.aborted).toBe(false)
|
||||
expect(outbound).toEqual([{ type: 'result', id: 2, operation: 'list', value: result }])
|
||||
deliver(child)
|
||||
await expect(live).resolves.toEqual(result)
|
||||
await close(child)
|
||||
})
|
||||
|
||||
it('shutdown aborts active work and ignores later cancellations and admission', async () => {
|
||||
const child = createClient()
|
||||
const pending = Promise.withResolvers<typeof result>()
|
||||
let signal: AbortSignal | undefined
|
||||
scan.mockImplementationOnce((options) => {
|
||||
signal = options.signal
|
||||
return pending.promise
|
||||
})
|
||||
const response = currentClient().listSessions({})
|
||||
const rejected = expect(response).rejects.toThrow('scan stopped')
|
||||
deliver(child)
|
||||
await turn()
|
||||
process.emit('message', { type: 'shutdown' })
|
||||
expect(signal?.aborted).toBe(true)
|
||||
process.emit('message', { type: 'cancel', id: 1 })
|
||||
process.emit('message', { type: 'cancel', id: 9000 })
|
||||
process.emit('message', { type: 'request', id: 9000, operation: 'list', params: {} })
|
||||
pending.reject(new Error('scan stopped'))
|
||||
await turn()
|
||||
expect(scan).toHaveBeenCalledOnce()
|
||||
expect(outbound).toEqual([{ type: 'error', id: 1, message: 'scan stopped' }])
|
||||
deliver(child)
|
||||
await rejected
|
||||
await close(child)
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000
|
||||
export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000
|
||||
export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000
|
||||
export const RELAY_AI_VAULT_MAX_CALLS = 16
|
||||
export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
export function relayAiVaultAbortError(): Error {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function relayAiVaultError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export function armRelayAiVaultCallTimeout(
|
||||
call: RelayAiVaultServiceCall,
|
||||
onExpired: (timeout: number) => void
|
||||
): void {
|
||||
const timeout =
|
||||
call.request.operation === 'list'
|
||||
? RELAY_AI_VAULT_SCAN_TIMEOUT_MS
|
||||
: RELAY_AI_VAULT_TITLE_TIMEOUT_MS
|
||||
call.timer = setTimeout(() => onExpired(timeout), timeout)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
export function armRelayAiVaultCancellationTimeout(
|
||||
call: RelayAiVaultServiceCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
call.timer = setTimeout(onExpired, 2_000)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
export function createRelayAiVaultServiceCall(args: {
|
||||
request: RelayAiVaultServiceRequest
|
||||
signal?: AbortSignal
|
||||
resolve: RelayAiVaultServiceCall['resolve']
|
||||
reject: RelayAiVaultServiceCall['reject']
|
||||
}): RelayAiVaultServiceCall {
|
||||
return {
|
||||
request: args.request,
|
||||
lane: relayAiVaultServiceLane(args.request.operation),
|
||||
signal: args.signal,
|
||||
forceStart: args.request.operation === 'list' && args.request.params.force === true,
|
||||
resolve: args.resolve,
|
||||
reject: args.reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
settled: false,
|
||||
sent: false,
|
||||
startRetried: false,
|
||||
startAttempt: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cold start that faults before the request reached the sidecar self-heals on
|
||||
* the scheduled respawn. Requeue once; the caller settles when this returns false.
|
||||
*/
|
||||
export function requeueRelayAiVaultServiceStart(
|
||||
call: RelayAiVaultServiceCall,
|
||||
queue: RelayAiVaultServiceCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.settled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function settleRelayAiVaultServiceCall(
|
||||
call: RelayAiVaultServiceCall,
|
||||
value: Error | AiVaultListResult | AiVaultSessionTitlesResult
|
||||
): void {
|
||||
// A cancelled call is settled before its cancel watchdog is armed, so the
|
||||
// timer has to be cleared even when the reject/resolve is already done.
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.settled) {
|
||||
return
|
||||
}
|
||||
call.settled = true
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
call.reject(value)
|
||||
} else {
|
||||
call.resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceCall = {
|
||||
request: RelayAiVaultServiceRequest
|
||||
lane: RelayAiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
forceStart: boolean
|
||||
resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
settled: boolean
|
||||
/** Whether the sidecar received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
startAttempt: number
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceApi = {
|
||||
listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise<AiVaultListResult>
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult>
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceClientOptions = {
|
||||
processFactory: () => ChildProcess
|
||||
init: {
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
now?: () => number
|
||||
idleTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class RelayAiVaultIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function retireRelayAiVaultServiceChild(child: ChildProcess): void {
|
||||
const timer = setTimeout(() => child.kill(), 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => clearTimeout(timer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
}
|
||||
|
||||
/** Orderly shutdown that never holds relay teardown past the kill deadline. */
|
||||
export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise<void> {
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
resolve()
|
||||
}, 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
readyAiVaultServiceChild
|
||||
} from '../main/ai-vault/session-scanner-service-test-child'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state'
|
||||
import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn'
|
||||
|
||||
function createClient(
|
||||
children: AiVaultServiceTestChild[],
|
||||
idleTimeoutMs?: number
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
},
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(20_000 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
idleTimeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] {
|
||||
return child.sent.filter(
|
||||
(message) => (message as { operation?: string }).operation === operation
|
||||
) as { id: number }[]
|
||||
}
|
||||
|
||||
function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number {
|
||||
return relayRequests(child, operation).length
|
||||
}
|
||||
|
||||
function relayRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = relayRequests(child, operation).at(-1)
|
||||
if (!request) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultServiceClient', () => {
|
||||
it('holds list and title calls behind the ready handshake', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({ limit: 20 })
|
||||
const titles = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const listRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: listRequest.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('resolves titles while a scan still occupies the cache lane', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const titles = client.resolveSessionTitles([
|
||||
{ agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' }
|
||||
])
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('does not start queued cache work until cancelled work acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = relayRequestId(child, 'list')
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(relayRequestCount(child, 'list')).toBe(1)
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
id: firstRequest,
|
||||
message: 'aborted'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(relayRequestCount(child, 'list')).toBe(2)
|
||||
void second.catch(() => undefined)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the sidecar received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.listSessions({}, controller.signal)
|
||||
const child = children[0]!
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.listSessions({ limit: 5 })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(next).resolves.toMatchObject({ sessions: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retries a cold-start crash once without faulting the replacement sidecar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS)
|
||||
|
||||
expect(children[1]!.killed).toBe(false)
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('keeps the sidecar alive once a cancelled call acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(second).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('restarts queued work after a sidecar crash with bounded backoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const first = client.listSessions({})
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await expect(first).rejects.toThrow('exited')
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retires the sidecar after the idle bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children, 100)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const request = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: request.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await list
|
||||
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(child.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
await client.dispose()
|
||||
})
|
||||
|
||||
it('resolves the sidecar beside each bundled relay', () => {
|
||||
expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe(
|
||||
'/opt/orca/relay/relay-ai-vault-service.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,318 +0,0 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import {
|
||||
RELAY_AI_VAULT_MAX_CALLS,
|
||||
RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS,
|
||||
armRelayAiVaultCallTimeout,
|
||||
armRelayAiVaultCancellationTimeout,
|
||||
createRelayAiVaultServiceCall,
|
||||
relayAiVaultAbortError,
|
||||
relayAiVaultError,
|
||||
RelayAiVaultIdleRetirement,
|
||||
requeueRelayAiVaultServiceStart,
|
||||
retireRelayAiVaultServiceChild,
|
||||
settleRelayAiVaultServiceCall,
|
||||
shutdownRelayAiVaultServiceChild,
|
||||
type RelayAiVaultServiceApi,
|
||||
type RelayAiVaultServiceCall,
|
||||
type RelayAiVaultServiceClientOptions
|
||||
} from './ai-vault-service-client-state'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
|
||||
export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
|
||||
private child: ChildProcess | null = null
|
||||
private ready: PromiseSettlementWaiters<ChildProcess> | null = null
|
||||
private readyReject: ((error: Error) => void) | null = null
|
||||
private readyTimer: NodeJS.Timeout | null = null
|
||||
private readonly active = new Map<RelayAiVaultServiceLane, RelayAiVaultServiceCall>()
|
||||
private readonly queue: RelayAiVaultServiceCall[] = []
|
||||
private nextId = 1
|
||||
private readonly restartPolicy: RelayAiVaultRestartPolicy
|
||||
private readonly idleRetirement = new RelayAiVaultIdleRetirement()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: RelayAiVaultServiceClientOptions) {
|
||||
this.restartPolicy = new RelayAiVaultRestartPolicy(options.now)
|
||||
}
|
||||
|
||||
listSessions(
|
||||
params: SshAiVaultRelayListParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal)
|
||||
}
|
||||
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return this.request(
|
||||
{ type: 'request', id: this.nextId++, operation: 'titles', requests },
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.idleRetirement.clear()
|
||||
this.clearReadyTimer()
|
||||
this.restartPolicy.dispose()
|
||||
const error = new Error('Relay AI Vault service was disposed.')
|
||||
this.readyReject?.(error)
|
||||
this.readyReject = null
|
||||
this.ready = null
|
||||
for (const call of [...this.active.values(), ...this.queue.splice(0)]) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
await shutdownRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private request<T extends AiVaultListResult | AiVaultSessionTitlesResult>(
|
||||
request: RelayAiVaultServiceRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('Relay AI Vault service was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(relayAiVaultAbortError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) {
|
||||
return Promise.reject(new Error('Relay AI Vault service queue is full.'))
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call = createRelayAiVaultServiceCall({
|
||||
request,
|
||||
signal,
|
||||
resolve: resolve as RelayAiVaultServiceCall['resolve'],
|
||||
reject
|
||||
})
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.disposed || this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
const attempt = ++call.startAttempt
|
||||
const readiness = this.ensureChild(call.forceStart)
|
||||
void readiness
|
||||
.wait({
|
||||
signal: call.signal,
|
||||
createAbortError: relayAiVaultAbortError,
|
||||
onFulfilled: (child) => this.sendCall(child, call)
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (this.active.get(lane) !== call || call.startAttempt !== attempt) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrSettle(call, error)
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void {
|
||||
if (this.active.get(call.lane) !== call || call.settled) {
|
||||
return
|
||||
}
|
||||
armRelayAiVaultCallTimeout(call, (timeout) =>
|
||||
this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`))
|
||||
)
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void {
|
||||
if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(forceStart: boolean): PromiseSettlementWaiters<ChildProcess> {
|
||||
if (this.child && !this.ready) {
|
||||
return new PromiseSettlementWaiters(Promise.resolve(this.child))
|
||||
}
|
||||
if (this.ready) {
|
||||
return this.ready
|
||||
}
|
||||
const startError = this.restartPolicy.startError(forceStart)
|
||||
if (startError) {
|
||||
return new PromiseSettlementWaiters(Promise.reject(startError))
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
return new PromiseSettlementWaiters(Promise.reject(relayAiVaultError(error)))
|
||||
}
|
||||
this.child = child
|
||||
this.ready = new PromiseSettlementWaiters(
|
||||
new Promise<ChildProcess>((resolve, reject) => {
|
||||
this.readyReject = reject
|
||||
// Why: held on the instance so a crash before ready cannot leave the deadline
|
||||
// armed, where it would later fault the healthy replacement sidecar.
|
||||
this.readyTimer = setTimeout(
|
||||
() => this.onFault(new Error('Relay AI Vault service did not become ready.')),
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS
|
||||
)
|
||||
this.readyTimer.unref?.()
|
||||
child.on('message', (message) => {
|
||||
if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') {
|
||||
this.clearReadyTimer()
|
||||
this.ready = null
|
||||
this.readyReject = null
|
||||
resolve(child)
|
||||
return
|
||||
}
|
||||
this.onMessage(message)
|
||||
})
|
||||
})
|
||||
)
|
||||
child.on('error', (error) => this.onFault(error))
|
||||
child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.')))
|
||||
child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`)))
|
||||
child.stderr?.on('data', (chunk: Buffer) =>
|
||||
relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`)
|
||||
)
|
||||
child.send({
|
||||
type: 'init',
|
||||
protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
...this.options.init
|
||||
} satisfies RelayAiVaultServiceInit)
|
||||
return this.ready
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isRelayAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('Relay AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as RelayAiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(
|
||||
call,
|
||||
message.type === 'error' ? new Error(message.message) : message.value
|
||||
)
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: RelayAiVaultServiceCall): void {
|
||||
const index = this.queue.indexOf(call)
|
||||
if (index !== -1) {
|
||||
this.queue.splice(index, 1)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the sidecar gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy sidecar and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
armRelayAiVaultCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
if (!this.child) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
this.detachChild()?.kill()
|
||||
this.clearReadyTimer()
|
||||
this.readyReject?.(error)
|
||||
this.readyReject = null
|
||||
this.ready = null
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrSettle(call, error)
|
||||
}
|
||||
this.restartPolicy.recordFault()
|
||||
if (this.queue.length > 0 && !this.disposed) {
|
||||
this.restartPolicy.scheduleRestart(() => this.pump())
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyTimer(): void {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer)
|
||||
this.readyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private detachChild(): ChildProcess | null {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
child?.removeAllListeners()
|
||||
return child
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
() => {
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
retireRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { resolveAbsoluteDirOverride } from '../shared/absolute-dir-override'
|
||||
import { joinRemotePath } 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 { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceRequest,
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceParentMessage,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('Relay AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
let provider: ReturnType<typeof createRelayAiVaultFilesystemProvider> | null = null
|
||||
let init: RelayAiVaultServiceInit | null = null
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
let shuttingDown = false
|
||||
|
||||
function send(message: RelayAiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
try {
|
||||
if (!init || !provider) {
|
||||
throw new Error('Relay AI Vault service is not initialized.')
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const value = await readAiVaultSessionTitlesFromFiles(request.requests, {
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'titles', value })
|
||||
return
|
||||
}
|
||||
const value = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
remoteHome: init.remoteHome,
|
||||
kiroHomeDir: resolveAbsoluteDirOverride(
|
||||
process.env.KIRO_HOME,
|
||||
joinRemotePath(init.hostPlatform, init.remoteHome, '.kiro')
|
||||
),
|
||||
hostPlatform: init.hostPlatform,
|
||||
limit: request.params.limit,
|
||||
unlimited: request.params.unlimited,
|
||||
includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions,
|
||||
scopePaths: request.params.scopePaths,
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'list', value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
provider?.dispose()
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: RelayAiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
init = raw
|
||||
provider = createRelayAiVaultFilesystemProvider({ homeDirectory: raw.remoteHome })
|
||||
send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!init || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
if (!pending.has(raw.id)) {
|
||||
return
|
||||
}
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (!isRelayAiVaultServiceRequest(raw)) {
|
||||
return
|
||||
}
|
||||
if (pending.size >= 16) {
|
||||
send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' })
|
||||
return
|
||||
}
|
||||
pending.add(raw.id)
|
||||
if (relayAiVaultServiceLane(raw.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => execute(raw))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => execute(raw))
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { spawnRelayAiVaultService } from './ai-vault-service-spawn'
|
||||
|
||||
export function createRelayAiVaultService(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: { remoteHome, hostPlatform },
|
||||
processFactory: spawnRelayAiVaultService
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { readRelayTranscriptBytes } from './ai-vault-transcript-stream'
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { readRelayFileContent } from './fs-handler-file-read'
|
||||
import {
|
||||
createRelayOpenCodeReader,
|
||||
type RelayOpenCodeReaderOptions
|
||||
} from './ai-vault-opencode-reader'
|
||||
|
||||
export function createRelayAiVaultFilesystemProvider(
|
||||
options: RelayOpenCodeReaderOptions = {}
|
||||
): RemoteSessionFilesystemProvider & { dispose(): void } {
|
||||
const openCode = createRelayOpenCodeReader(options)
|
||||
return {
|
||||
openCode,
|
||||
dispose: () => openCode.dispose(),
|
||||
async readDir(dirPath) {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
},
|
||||
readFile: readRelayFileContent,
|
||||
readTranscriptBytes: readRelayTranscriptBytes,
|
||||
async stat(filePath) {
|
||||
const stats = await lstat(filePath)
|
||||
return {
|
||||
size: stats.size,
|
||||
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
|
||||
mtime: stats.mtimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
nlink: stats.nlink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
|
||||
export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1
|
||||
|
||||
export type RelayAiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceRequest =
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'list'
|
||||
params: SshAiVaultRelayListParams
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation']
|
||||
|
||||
/** Title reads must not queue behind a full scan; they back interactive UI. */
|
||||
export function relayAiVaultServiceLane(
|
||||
operation: RelayAiVaultServiceOperation
|
||||
): RelayAiVaultServiceLane {
|
||||
return operation === 'titles' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceParentMessage =
|
||||
| RelayAiVaultServiceInit
|
||||
| RelayAiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type RelayAiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
pid: number
|
||||
}
|
||||
| { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult }
|
||||
| {
|
||||
type: 'result'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
value: AiVaultSessionTitlesResult
|
||||
}
|
||||
| { type: 'error'; id: number; message: string }
|
||||
|
||||
export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'list' || message.operation === 'titles')
|
||||
)
|
||||
}
|
||||
|
||||
export function isRelayAiVaultServiceChildMessage(
|
||||
value: unknown
|
||||
): value is RelayAiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
readyAiVaultServiceChild
|
||||
} from '../main/ai-vault/session-scanner-service-test-child'
|
||||
import type { AiVaultSessionTitleRequest } from '../shared/ai-vault-session-title'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
|
||||
function fixture() {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const processFactory = vi.fn(() => child.asChildProcess())
|
||||
const client = new RelayAiVaultServiceClient({
|
||||
processFactory,
|
||||
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
|
||||
})
|
||||
return { client, child, processFactory }
|
||||
}
|
||||
|
||||
async function collect(): Promise<void> {
|
||||
if (!global.gc) {
|
||||
throw new Error('This regression requires --expose-gc')
|
||||
}
|
||||
for (let turn = 0; turn < 3; turn++) {
|
||||
await setImmediate()
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
|
||||
function titleRequests(): AiVaultSessionTitleRequest[] {
|
||||
return [{ agent: 'claude', sessionId: 'session', transcriptPath: '/home/ada/session.jsonl' }]
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }))
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AI Vault readiness ownership', () => {
|
||||
it('releases canceled request payloads while readiness remains pending', async () => {
|
||||
const { client, child, processFactory } = fixture()
|
||||
const canceled = await (async () => {
|
||||
const refs: WeakRef<AiVaultSessionTitleRequest[]>[] = []
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
const requests = titleRequests()
|
||||
refs.push(new WeakRef(requests))
|
||||
const controller = new AbortController()
|
||||
const result = client.resolveSessionTitles(requests, controller.signal)
|
||||
controller.abort()
|
||||
const error = await result.catch((reason: unknown) => reason)
|
||||
if (!(error instanceof Error) || error.name !== 'AbortError') {
|
||||
throw new Error('Canceled title request did not reject with AbortError')
|
||||
}
|
||||
}
|
||||
return refs
|
||||
})()
|
||||
await collect()
|
||||
expect(canceled.filter((ref) => ref.deref() !== undefined)).toHaveLength(0)
|
||||
expect(child.sent).toHaveLength(1)
|
||||
expect(processFactory).toHaveBeenCalledOnce()
|
||||
|
||||
const live = client.resolveSessionTitles(titleRequests())
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
expect(child.sent.at(-1)).toMatchObject({ type: 'request', id: 1001, operation: 'titles' })
|
||||
child.emit('message', { type: 'result', id: 1001, operation: 'titles', value: { titles: [] } })
|
||||
await expect(live).resolves.toEqual({ titles: [] })
|
||||
const disposed = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposed
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases uncanceled readiness payloads when the client is disposed', async () => {
|
||||
const { client, child } = fixture()
|
||||
const discarded = await (async () => {
|
||||
const requests = titleRequests()
|
||||
const ref = new WeakRef(requests)
|
||||
const result = client.resolveSessionTitles(requests)
|
||||
const rejection = expect(result).rejects.toThrow('disposed')
|
||||
const disposed = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposed
|
||||
await rejection
|
||||
return ref
|
||||
})()
|
||||
await collect()
|
||||
expect(discarded.deref() === undefined).toBe(true)
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init' }), { type: 'shutdown' }])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await expect(client.resolveSessionTitles([])).rejects.toThrow('disposed')
|
||||
})
|
||||
|
||||
it('does not send a call canceled in the turn that readiness arrives', async () => {
|
||||
const { client, child } = fixture()
|
||||
const controller = new AbortController()
|
||||
const canceled = client.resolveSessionTitles(titleRequests(), controller.signal)
|
||||
readyAiVaultServiceChild(child)
|
||||
controller.abort()
|
||||
const live = client.resolveSessionTitles([])
|
||||
await expect(canceled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).toEqual([
|
||||
expect.objectContaining({ type: 'init' }),
|
||||
{ type: 'request', id: 2, operation: 'titles', requests: [] }
|
||||
])
|
||||
child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } })
|
||||
await expect(live).resolves.toEqual({ titles: [] })
|
||||
const disposed = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposed
|
||||
})
|
||||
})
|
||||
|
||||
it('retries an unsent lane after spawn fails while the other lane starts a child', async () => {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const processFactory = vi.fn(() => child.asChildProcess())
|
||||
processFactory.mockImplementationOnce(() => {
|
||||
throw new Error('temporary spawn failure')
|
||||
})
|
||||
const client = new RelayAiVaultServiceClient({
|
||||
processFactory,
|
||||
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
|
||||
})
|
||||
const list = client.listSessions({})
|
||||
const titles = client.resolveSessionTitles([])
|
||||
void list.catch(() => undefined)
|
||||
void titles.catch(() => undefined)
|
||||
try {
|
||||
readyAiVaultServiceChild(child)
|
||||
await vi.waitFor(() => {
|
||||
expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} })
|
||||
})
|
||||
expect(processFactory).toHaveBeenCalledTimes(2)
|
||||
expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} })
|
||||
expect(child.sent).toContainEqual({ type: 'request', id: 2, operation: 'titles', requests: [] })
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: 1,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' }
|
||||
})
|
||||
child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } })
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
} finally {
|
||||
const disposed = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposed
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an earlier readiness rejection after a replacement startup begins', async () => {
|
||||
const first = new AiVaultServiceTestChild()
|
||||
const replacement = new AiVaultServiceTestChild()
|
||||
const processFactory = vi.fn(() => replacement.asChildProcess())
|
||||
processFactory.mockImplementationOnce(() => first.asChildProcess())
|
||||
const client = new RelayAiVaultServiceClient({
|
||||
processFactory,
|
||||
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
|
||||
})
|
||||
const list = client.listSessions({})
|
||||
void list.catch(() => undefined)
|
||||
try {
|
||||
first.emit('exit', 1)
|
||||
// Start the replacement before the old readiness rejection's microtasks run.
|
||||
vi.advanceTimersByTime(250)
|
||||
const titles = client.resolveSessionTitles([])
|
||||
void titles.catch(() => undefined)
|
||||
readyAiVaultServiceChild(replacement)
|
||||
await setImmediate()
|
||||
|
||||
expect(processFactory).toHaveBeenCalledTimes(2)
|
||||
expect(first.sent).toEqual([expect.objectContaining({ type: 'init' })])
|
||||
expect(replacement.sent).toEqual([
|
||||
expect.objectContaining({ type: 'init' }),
|
||||
{ type: 'request', id: 1, operation: 'list', params: {} },
|
||||
{ type: 'request', id: 2, operation: 'titles', requests: [] }
|
||||
])
|
||||
replacement.emit('message', {
|
||||
type: 'result',
|
||||
id: 1,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' }
|
||||
})
|
||||
replacement.emit('message', {
|
||||
type: 'result',
|
||||
id: 2,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
expect(processFactory).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
const disposed = client.dispose()
|
||||
replacement.emit('exit', 0)
|
||||
await disposed
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultRestartPolicy', () => {
|
||||
it('opens the circuit after three faults inside the window and closes it after', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the circuit closed when the faults age out of the window', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 30_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('lets a forced refresh reopen an open circuit', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
}
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
|
||||
expect(policy.startError(true)).toBeNull()
|
||||
// The forced start clears the circuit outright, so the next background
|
||||
// start is no longer refused either.
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('backs off further with each fault and keeps one pending restart', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(restart).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000
|
||||
const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class RelayAiVaultRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
/** A forced refresh is a deliberate user action, so it reopens the circuit. */
|
||||
startError(forceStart: boolean): Error | null {
|
||||
if (forceStart) {
|
||||
this.circuitUntil = 0
|
||||
return null
|
||||
}
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('Relay AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRestart(restart: () => void): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
|
||||
const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnRelayAiVaultService', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('does not hand the sidecar the rest of the remote login environment', () => {
|
||||
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
|
||||
import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority'
|
||||
|
||||
export function relayAiVaultServiceEntryPath(baseDir = __dirname): string {
|
||||
return join(baseDir, 'relay-ai-vault-service.js')
|
||||
}
|
||||
|
||||
export function spawnRelayAiVaultService(): ChildProcess {
|
||||
const child = fork(relayAiVaultServiceEntryPath(), [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildRelayAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
|
||||
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer'
|
||||
import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
|
||||
|
||||
/** The same open handle supplies the probe and stream, including across renames. */
|
||||
export async function* readRelayTranscriptBytes(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { regularFileOnly: true; maxBytes: number }
|
||||
): AsyncGenerator<Buffer> {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (options?.regularFileOnly) {
|
||||
const read = await readNodeFileWithinLimit(path, options.maxBytes, {
|
||||
regularFileOnly: true,
|
||||
signal
|
||||
})
|
||||
if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) {
|
||||
throw new BinarySessionTranscriptError()
|
||||
}
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
yield read.buffer
|
||||
return
|
||||
}
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
|
||||
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
|
||||
if (isBinaryBuffer(probe.subarray(0, bytesRead))) {
|
||||
throw new BinarySessionTranscriptError()
|
||||
}
|
||||
const input = handle.createReadStream({ start: 0, autoClose: false, signal })
|
||||
try {
|
||||
for await (const chunk of input) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (!Buffer.isBuffer(chunk)) {
|
||||
throw new TypeError('Expected transcript byte buffer')
|
||||
}
|
||||
yield chunk
|
||||
}
|
||||
} finally {
|
||||
input.destroy()
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expandTilde } from './context'
|
||||
|
||||
describe('expandTilde', () => {
|
||||
it('expands POSIX-style home paths', () => {
|
||||
expect(expandTilde('~/projects')).toBe(resolve(homedir(), 'projects'))
|
||||
})
|
||||
|
||||
it('expands Windows-style home paths without forcing POSIX separators', () => {
|
||||
expect(expandTilde('~\\projects')).toBe(`${homedir()}\\projects`)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user