fix(vault): read OpenCode SQLite inside WSL and SSH hosts (#23128)

* fix(vault): read OpenCode SQLite on WSL and SSH execution hosts

* fix(vault): bound host setup and preserve cancellation across readers

* fix(vault): keep WSL discovery visible and isolate probe tests

* fix: retry local Vault runtime downloads without reserving remote stages

Preserve verified remote cache reuse and latch only unresolved host work.
Update WSL source-guard and remote dedup test integration.

* fix(queue): discard aborted requests before respawn

* fix(vault): preserve host paths and recover setup after reconnect

* fix(ssh): fence every runtime platform probe across reconnects

* fix(vault): reject setup results from superseded SSH connections

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-26 03:59:38 -07:00
committed by GitHub
co-authored by m4air
parent d20cb69c48
commit da6d483ab9
84 changed files with 4809 additions and 252 deletions
+21
View File
@@ -24,6 +24,7 @@ import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
RELAY_WINDOWS_PROCESS_TREE_FILENAME,
RELAY_OPENCODE_SQLITE_READER_FILENAME,
relayOptionalArtifactFilenames,
isWindowsRelayPlatform,
relayArtifactFilenames
@@ -35,6 +36,13 @@ const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const OPENCODE_SQLITE_READER_ENTRY = join(
ROOT,
'src',
'main',
'ai-vault',
'session-scanner-opencode-sqlite-process-entry.ts'
)
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
ROOT,
'src',
@@ -180,6 +188,19 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
}
})
await build({
entryPoints: [OPENCODE_SQLITE_READER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME),
external: ['electron', 'bun:sqlite'],
sourcemap: false,
minify: true,
define: { 'process.env.NODE_ENV': '"production"' }
})
// Why beside the service: the spawn resolves this child next to its own
// bundle, and a relay host has no desktop out/main to fall back to.
await build({
@@ -13,7 +13,8 @@ import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
isWindowsRelayPlatform,
relayArtifactFilenames
relayArtifactFilenames,
relayOptionalArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
const projectDir = resolve(import.meta.dirname, '../..')
@@ -46,13 +47,22 @@ describe('packaged relay artifact manifest', () => {
const emitted = readdirSync(outDir)
.filter((name) => name !== RELAY_VERSION_FILENAME)
.sort()
expect(emitted).toEqual([...expected].sort())
const optional = relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter(
(filename) => existsSync(join(outDir, filename))
)
expect(emitted).toEqual([...expected, ...optional].sort())
})
it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => {
const outDir = join(relayOutDir, platform)
const hash = createHash('sha256')
for (const filename of relayArtifactFilenames(isWindowsRelayPlatform(platform))) {
const artifacts = [
...relayArtifactFilenames(isWindowsRelayPlatform(platform)),
...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform)).filter((filename) =>
existsSync(join(outDir, filename))
)
]
for (const filename of artifacts) {
hash.update(readFileSync(join(outDir, filename)))
}
const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8')
@@ -76,3 +76,26 @@ it('reads an added, dropped or changed root as a different set', () => {
expect(sameSessionSearchRoots(base, { openclawStateDir: STATE })).toBe(false)
expect(sameSessionSearchRoots(base, { ...base, wslHomeDirs: ['/home/b'] })).toBe(false)
})
it('compares WSL readers structurally without restarting for reordered objects', () => {
const ubuntu = { distro: 'Ubuntu', executable: '/usr/bin/node', readerPath: '/reader.cjs' }
const debian = { distro: 'Debian', error: 'Preparing' }
expect(
sameSessionSearchRoots(
{ wslOpenCodeReaders: [ubuntu, debian] },
{
wslOpenCodeReaders: [
{ error: 'Preparing', distro: 'debian' },
{ readerPath: '/reader.cjs', executable: '/usr/bin/node', distro: 'ubuntu' }
]
}
)
).toBe(true)
expect(sameSessionSearchRoots({}, { wslOpenCodeReaders: [] })).toBe(true)
expect(
sameSessionSearchRoots(
{ wslOpenCodeReaders: [ubuntu] },
{ wslOpenCodeReaders: [{ ...ubuntu, executable: '/new/node' }] }
)
).toBe(false)
})
@@ -153,10 +153,22 @@ export function sameSessionSearchRoots(
}
function comparableRootFields(roots: SessionSearchScanRoots): string[] {
return Object.entries(roots)
const { wslOpenCodeReaders, ...pathRoots } = roots
return Object.entries(pathRoots)
.filter(([, value]) => value !== undefined)
.map(
([key, value]) => `${key}=${JSON.stringify(Array.isArray(value) ? [...value].sort() : value)}`
)
.concat(
(wslOpenCodeReaders ?? []).map(
(reader) =>
`wslOpenCodeReaders=${JSON.stringify([
reader.distro.toLowerCase(),
reader.executable,
reader.readerPath,
reader.error
])}`
)
)
.sort()
}
@@ -15,6 +15,9 @@ vi.mock('./session-scanner-worker-spawn', () => ({
scanAiVaultSessionsInWorker,
resetAiVaultScannerWorkerForTests: vi.fn()
}))
vi.mock('./opencode-wsl-runtime-preparation', () => ({
prepareOpenCodeWslReaders: vi.fn(async () => [])
}))
import { _resetWslCachesForTests, _setWslCachesForTests, listWslDistrosAsync } from '../wsl'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
+3 -1
View File
@@ -8,6 +8,7 @@ import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync }
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import type { AiVaultScanOptions } from './session-scanner-types'
import { prepareOpenCodeWslReaders } from './opencode-wsl-runtime-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
import {
@@ -57,7 +58,7 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi
*/
export async function localAiVaultScanRoots(): Promise<
Required<Pick<AiVaultScanOptions, 'additionalCodexSessionsDirs' | 'wslHomeDirs'>> &
Pick<AiVaultScanOptions, 'executionHostId'>
Pick<AiVaultScanOptions, 'executionHostId' | 'wslOpenCodeReaders'>
> {
const [additionalCodexHomes, wslHomeDirs] = await Promise.all([
filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()),
@@ -66,6 +67,7 @@ export async function localAiVaultScanRoots(): Promise<
return {
additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')),
wslHomeDirs,
wslOpenCodeReaders: await prepareOpenCodeWslReaders(wslHomeDirs),
// Why: this scan is always host-local; callers addressing this host by a
// runtime id get the result restamped at the RPC edge, never rescanned.
executionHostId: LOCAL_EXECUTION_HOST_ID
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime'
import { runProcess } from '../../shared/child-process/run-process'
import type * as preparationModule from './opencode-wsl-runtime-preparation'
const mocks = vi.hoisted(() => ({
exists: vi.fn(() => true),
run: vi.fn(),
running: vi.fn(async (paths: readonly string[]) => [...paths]),
download: vi.fn(async () => 'C:\\cache\\bun'),
bundles: vi.fn((platform: string) => [`C:/relay/${platform}`])
}))
vi.mock('node:fs', () => ({ existsSync: mocks.exists }))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({
getAppPath: () => 'C:/app',
getPath: () => 'C:/profile'
})
}))
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.run }))
vi.mock('../wsl-running-path-filter', () => ({
filterPathsToRunningWslDistrosAsync: mocks.running
}))
vi.mock('../ssh/orcad-bun-runtime-materializer', () => ({
materializeCachedOrcadBunRuntime: mocks.download
}))
vi.mock('../ssh/relay-bundle-paths', () => ({ relayBundleCandidates: mocks.bundles }))
const home = String.raw`\\wsl.localhost\Ubuntu\home\ada`
const success = (stdout: string) => ({ stdout, stderr: '', code: 0, timedOut: false })
let prepare: typeof preparationModule.prepareOpenCodeWslReaders
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
mocks.exists.mockReturnValue(true)
mocks.running.mockImplementation(async (paths) => [...paths])
mocks.run.mockImplementation(async (spec) =>
success(
spec.script?.startsWith('data=')
? 'present'
: spec.program === 'wslpath'
? '/mnt/c/reader.cjs'
: '/usr/bin/node'
)
)
prepare = (await import('./opencode-wsl-runtime-preparation')).prepareOpenCodeWslReaders
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
async function prepared() {
let result = await prepare([home])
await vi.waitFor(async () => {
result = await prepare([home])
expect(result[0]?.executable).toBeDefined()
})
return result
}
describe('WSL SQLite runtime preparation', () => {
it('returns immediately, coalesces distro aliases, and uses an actual guest SQLite probe', async () => {
let release: (value: ReturnType<typeof success>) => void = () => {}
mocks.run.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve
})
)
expect(await prepare([home, String.raw`\\wsl$\ubuntu\home\ada`])).toHaveLength(1)
await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledOnce())
expect((await prepare([home]))[0]?.error).toContain('Preparing')
expect(mocks.run).toHaveBeenCalledOnce()
release(success('present'))
expect(await prepared()).toEqual([
{ distro: 'ubuntu', executable: '/usr/bin/node', readerPath: '/mnt/c/reader.cjs' }
])
expect(mocks.run).toHaveBeenCalledWith(
expect.objectContaining({
program: 'node',
loginPath: 'preferred',
args: ['-e', expect.stringContaining("require('node:sqlite')")]
})
)
expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([
'linux-x64',
'linux-arm64'
])
expect(mocks.download).not.toHaveBeenCalled()
})
it('does not start a stopped distro and retries a failed preparation after cooldown', async () => {
let now = 10_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
mocks.running.mockResolvedValue([])
await prepare([home])
await vi.waitFor(async () => expect((await prepare([home]))[0]?.error).toContain('not running'))
expect(mocks.run).not.toHaveBeenCalled()
mocks.running.mockImplementation(async (paths) => [...paths])
expect((await prepare([home]))[0]?.error).toContain('not running')
now += 30_001
expect((await prepared())[0]?.executable).toBe('/usr/bin/node')
})
it('keeps a working reader during revalidation and prunes completed removed distros', async () => {
let now = 10_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
const ready = await prepared()
now += 600_001
let release: (value: ReturnType<typeof success>) => void = () => {}
mocks.run.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve
})
)
expect(await prepare([home])).toEqual(ready)
await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledTimes(4))
expect(await prepare([home])).toEqual(ready)
mocks.run.mockResolvedValueOnce(success('/mnt/c/repaired-reader.cjs'))
release(success('present'))
await vi.waitFor(async () =>
expect((await prepare([home]))[0]?.readerPath).toBe('/mnt/c/repaired-reader.cjs')
)
expect(await prepare([])).toEqual([])
expect((await prepare([home]))[0]?.error).toContain('Preparing')
await prepared()
})
it('falls back to the pinned proxy runtime, verifies the guest stage, and preserves literal argv', async () => {
const expected = ORCAD_BUN_RELEASE_ASSETS['linux-arm64-musl'].executableSha256
mocks.run.mockImplementation(async (spec) => {
if (spec.script?.startsWith('data=')) {
return success('present')
}
if (spec.program === 'node') {
return { ...success(''), code: 1, stderr: 'no sqlite' }
}
if (spec.program === 'uname') {
return success('aarch64')
}
if (spec.program === 'wslpath') {
return success('/mnt/c/reader $literal.cjs')
}
if (spec.script?.startsWith('getconf')) {
return success('musl libc')
}
if (spec.script?.startsWith('printf')) {
return success('/home/ada $literal')
}
return success('')
})
const result = await prepared()
expect(mocks.download).toHaveBeenCalledWith(
'linux-arm64-musl',
expect.any(String),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
expect(result[0]?.executable).toBe(
`/home/ada $literal/.cache/orca/vault-sqlite/${expected}/bun`
)
expect(mocks.run).toHaveBeenCalledWith(
expect.objectContaining({
args: ['/mnt/c/reader $literal.cjs', result[0]?.executable, expected],
script: expect.stringContaining('actual=$(sha256sum -- "$stage")')
})
)
expect(
mocks.run.mock.calls.every(
([spec]) => spec.timeoutMs <= 15_000 && spec.maxOutputBytes === 16_384
)
).toBe(true)
})
it('rechecks running state after downloading before it copies a runtime', async () => {
mocks.run.mockImplementation(async (spec) => {
if (spec.script?.startsWith('data=')) {
return success('present')
}
if (spec.program === 'node') {
return { ...success(''), code: 1 }
}
if (spec.program === 'uname') {
return success('x86_64')
}
if (spec.program === 'wslpath') {
return success('/mnt/c/reader.cjs')
}
if (spec.script?.startsWith('getconf')) {
return success('glibc 2.31')
}
if (spec.script?.startsWith('printf')) {
return success('/home/ada')
}
return success('')
})
mocks.download.mockImplementationOnce(async () => {
mocks.running.mockResolvedValue([])
return 'C:\\cache\\bun'
})
await prepare([home])
await vi.waitFor(async () => expect((await prepare([home]))[0]?.error).toContain('not running'))
expect(mocks.run.mock.calls.some(([spec]) => spec.script?.includes('mv -f'))).toBe(false)
})
it('ignores native roots and does no work outside Windows', async () => {
expect(await prepare(['C:\\Users\\ada'])).toEqual([])
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
expect(await prepare([home])).toEqual([])
expect(mocks.run).not.toHaveBeenCalled()
})
it('skips runtime setup without a database and rechecks soon after OpenCode starts', async () => {
let now = 10_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
mocks.run.mockResolvedValueOnce(success(''))
await prepare([home])
await vi.waitFor(async () =>
expect((await prepare([home]))[0]?.error).toContain('No OpenCode database')
)
expect(mocks.run).toHaveBeenCalledOnce()
expect(mocks.download).not.toHaveBeenCalled()
expect(mocks.run.mock.calls[0]?.[0].script).toContain('OPENCODE_DB')
expect(mocks.run.mock.calls[0]?.[0].script).toContain('XDG_DATA_HOME')
now += 30_001
expect((await prepared())[0]?.executable).toBe('/usr/bin/node')
expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true })
})
it.skipIf(process.platform === 'win32')(
'keeps the discovery default visible when guest overrides point elsewhere',
async () => {
await prepared()
const script = mocks.run.mock.calls[0]?.[0].script
const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-presence-'))
try {
const data = join(directory, '.local', 'share', 'opencode')
await mkdir(data, { recursive: true })
await writeFile(join(data, 'opencode.db'), '')
const result = await runProcess({
program: '/bin/sh',
args: ['-c', script],
env: {
HOME: directory,
XDG_DATA_HOME: join(directory, 'empty'),
OPENCODE_DB: ':memory:'
}
})
expect(result.code).toBe(0)
expect(result.stdout).toBe('present')
} finally {
await rm(directory, { recursive: true, force: true })
}
}
)
})
@@ -0,0 +1,191 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime'
import { RELAY_OPENCODE_SQLITE_READER_FILENAME } from '../../shared/relay-artifacts'
import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths'
import { relayBundleCandidates } from '../ssh/relay-bundle-paths'
import { materializeCachedOrcadBunRuntime } from '../ssh/orcad-bun-runtime-materializer'
import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target'
import { runWslProcess, type WslSpec } from '../wsl/wsl-runner'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
const preparation = new Map<string, { value: OpenCodeWslRuntime; expires: number }>()
const downloads = new Map<OrcadBunTarget, Promise<string>>()
const PREPARATION_TIMEOUT_MS = 180_000
const SQLITE_PROBE = `const db=new (require('node:sqlite').DatabaseSync)(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)`
/** Only running distro roots enter here; a slow first install must not hold up local history. */
export async function prepareOpenCodeWslReaders(
roots: readonly string[]
): Promise<OpenCodeWslRuntime[]> {
if (process.platform !== 'win32') {
return []
}
const distros = new Map<string, string>()
for (const root of roots) {
const distro = parseWslUncPath(root)?.distro
if (distro) {
distros.set(distro.toLowerCase(), distro)
}
}
for (const [key, entry] of preparation) {
if (!distros.has(key) && Number.isFinite(entry.expires)) {
preparation.delete(key)
}
}
return [...distros].map(([key, distro]) => {
const previous = preparation.get(key)
if (previous && previous.expires > Date.now()) {
return previous.value
}
const entry: { expires: number; value: OpenCodeWslRuntime } = {
expires: Number.POSITIVE_INFINITY,
value: previous?.value.executable
? previous.value
: {
distro,
error: 'Preparing the WSL SQLite reader. Refresh Vault after setup finishes.'
}
}
preparation.set(key, entry)
void prepare(distro).then(
(runtime) => {
entry.expires = Date.now() + (runtime.executable ? 10 * 60_000 : 30_000)
entry.value = runtime
},
(error: unknown) => {
entry.expires = Date.now() + 30_000
entry.value = { distro, error: error instanceof Error ? error.message : String(error) }
}
)
return entry.value
})
}
async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
const deadline = Date.now() + PREPARATION_TIMEOUT_MS
const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS)
const run = async (spec: WslSpec): Promise<string> => {
signal.throwIfAborted()
const running = await waitForPromiseWithSignal(
filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], {
requireConfirmed: true
}),
signal
)
if (running.length === 0) {
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
}
signal.throwIfAborted()
const result = await runWslProcess({
...spec,
distro,
timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())),
maxOutputBytes: 16 * 1024
})
if (result.code !== 0 || result.timedOut) {
throw new Error(`WSL SQLite reader setup failed: ${result.stderr.trim() || 'command failed'}`)
}
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()))
.map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME))
.find(existsSync)
if (!reader) {
throw new Error('The bundled WSL SQLite reader is missing. Reinstall Orca to repair it.')
}
const hasDatabase = await run({
script: [
'data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"',
// WSL discovery still enumerates the default data root independently of guest overrides.
'for db in "$HOME/.local/share/opencode"/opencode.db "$HOME/.local/share/opencode"/opencode-*.db; do if [ -f "$db" ]; then printf present; exit 0; fi; done',
'case "${OPENCODE_DB-}" in',
' :memory:) exit 0 ;;',
' /*) [ ! -f "$OPENCODE_DB" ] || printf present ;;',
' "") for db in "$data"/opencode*.db; do if [ -f "$db" ]; then printf present; break; fi; done ;;',
' *) [ ! -f "$data/$OPENCODE_DB" ] || printf present ;;',
'esac'
].join('\n'),
loginPath: 'none'
})
if (hasDatabase !== 'present') {
return { distro, error: 'No OpenCode database is present in this WSL distro.' }
}
const readerPath = await run({
program: 'wslpath',
args: ['-a', '-u', reader],
loginPath: 'none'
})
let executable: string | null = null
try {
executable = await run({ program: 'node', args: ['-e', SQLITE_PROBE], loginPath: 'preferred' })
} catch {
// Older guest Node remains supported; only the SQLite reader needs this runtime.
}
if (!executable?.startsWith('/')) {
const arch = await run({ program: 'uname', args: ['-m'], loginPath: 'none' })
if (arch !== 'x86_64' && arch !== 'aarch64' && arch !== 'arm64') {
throw new Error(`Unsupported WSL SQLite reader architecture: ${arch}`)
}
const libc = parseOrcadLinuxLibc(
await run({
script:
'getconf GNU_LIBC_VERSION 2>/dev/null || ldd --version 2>&1 || ' +
'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done',
loginPath: 'none'
})
)
const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const
const expected = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256
const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' })
if (!home.startsWith('/')) {
throw new Error('WSL did not provide an absolute home directory.')
}
executable = `${home}/.cache/orca/vault-sqlite/${expected}/bun`
const present = await run({
script: 'if [ -x "$1" ]; then sha256sum -- "$1"; fi',
args: [executable],
loginPath: 'none'
})
if (!present.startsWith(`${expected} `)) {
let download = downloads.get(target)
if (!download) {
download = materializeCachedOrcadBunRuntime(
target,
join(app.getPath('userData'), 'orcad-artifacts'),
{
signal: AbortSignal.timeout(PREPARATION_TIMEOUT_MS)
}
).finally(() => downloads.delete(target))
downloads.set(target, download)
}
const localRuntime = await waitForPromiseWithSignal(download, signal)
const source = await run({
program: 'wslpath',
args: ['-a', '-u', localRuntime],
loginPath: 'none'
})
await run({
script: [
'set -eu; umask 077',
'mkdir -p -- "${2%/*}"',
'stage=$(mktemp "${2}.upload.XXXXXX")',
'trap \'rm -f -- "$stage"\' EXIT',
'cp -- "$1" "$stage"',
'actual=$(sha256sum -- "$stage"); [ "${actual%% *}" = "$3" ]',
'chmod 700 "$stage"',
'mv -f -- "$stage" "$2"'
].join('\n'),
args: [source, executable, expected],
loginPath: 'none'
})
}
}
return { distro, executable, readerPath }
}
@@ -28,7 +28,7 @@ describe('large remote history through real relay filesystem', () => {
await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024))
await writeFile(join(directory, 'good.jsonl'), metadata('good'))
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider(),
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: 'ssh:record-limit',
remoteHome: home,
hostPlatform: platform,
@@ -94,7 +94,7 @@ describe('large remote history through real relay filesystem', () => {
filler.slice(filler.length / 2)
)
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider(),
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: 'ssh:synthetic-17744',
remoteHome: home,
hostPlatform: platform,
@@ -161,7 +161,7 @@ describe('large remote history through real relay filesystem', () => {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(record))
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider(),
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: `ssh:large-${agent}`,
remoteHome: home,
hostPlatform: platform,
@@ -185,7 +185,10 @@ describe('large remote history through real relay filesystem', () => {
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()
const provider = createRelayAiVaultFilesystemProvider({
homeDirectory: home,
environment: {}
})
provider.readTranscriptBytes = () => {
throw new Error('Small file must keep its existing read path')
}
@@ -35,6 +35,10 @@ 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)
@@ -79,7 +83,8 @@ export async function parseRemoteSessionFileCached(args: {
stats?: RemoteSessionParseStats
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
const entry = cache.get(file.path)
const key = remoteSessionCandidateKey(args.candidate)
const entry = cache.get(key)
const unchanged =
entry !== undefined &&
entry.hostKey === args.hostKey &&
@@ -96,7 +101,7 @@ export async function parseRemoteSessionFileCached(args: {
entry.session = await args.refreshReusedSession(entry.session)
}
// Refresh recency without re-parsing so the LRU evicts cold paths first.
storeEntry(file.path, entry)
storeEntry(key, entry)
return entry.session
}
@@ -104,7 +109,7 @@ export async function parseRemoteSessionFileCached(args: {
if (args.stats) {
args.stats.parsed++
}
storeEntry(file.path, {
storeEntry(key, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
hostKey: args.hostKey,
@@ -14,6 +14,7 @@ export function limitRemoteScanFilesystemConcurrency(
): RemoteSessionFilesystemProvider {
const gate = createConcurrencyGate(maxInFlight)
return {
openCode: provider.openCode,
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
readFile: (filePath) => gate(() => provider.readFile(filePath)),
stat: (filePath) => gate(() => provider.stat(filePath)),
@@ -22,6 +22,11 @@ export async function discoverRemoteSourceCandidates(args: {
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)
@@ -0,0 +1,149 @@
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 []
}
}
@@ -0,0 +1,235 @@
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,10 +1,17 @@
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
import type { AiVaultAgent, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { FileWithMtime } from './session-scanner-types'
import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts'
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
import type { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
export type RemoteOpenCodeSessionReader = Pick<OpenCodeSqliteWorkerClient, 'list' | 'parse'> & {
dataDirectory: string
databasePath?: string | null
parseLegacy: (file: FileWithMtime, platform: NodeJS.Platform) => Promise<AiVaultSession | null>
}
export type RemoteScannerContext = {
provider: RemoteSessionFilesystemProvider
@@ -21,6 +28,8 @@ export type RemoteSessionFilesystemProvider = Pick<
> & {
/** Available only beside the execution host's disk; never opens a client path. */
readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable<Buffer>
/** Execution-host database access; absent from remote filesystem RPC providers. */
openCode?: RemoteOpenCodeSessionReader
}
export type RemoteParserOptions = {
@@ -31,6 +40,11 @@ export type RemoteParserOptions = {
export type RemoteSessionSource = {
agent: AiVaultAgent
rootDir: string
discover?: (context: RemoteScannerContext, issues: AiVaultScanIssue[]) => Promise<FileWithMtime[]>
parseCandidate?: (
file: FileWithMtime,
context: RemoteScannerContext
) => Promise<AiVaultSession | null>
// Codex sources only: the CODEX_HOME the root belongs to, so bridged or
// backfilled rollout aliases across remote roots collapse to one canonical row.
codexHome?: string
+17 -8
View File
@@ -16,6 +16,7 @@ import {
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
import {
parseRemoteSessionFileCached,
remoteSessionCandidateKey,
remoteSessionParseHostKey
} from './remote-session-parse-cache'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
@@ -36,6 +37,7 @@ 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
@@ -84,7 +86,14 @@ export async function scanRemoteAiVaultSessions(args: {
const candidates = dedupeCodexRolloutFileAliases(
(
await mapRemoteScanBatches(
remoteSessionSources(args.remoteHome, args.hostPlatform),
[
...remoteSessionSources(args.remoteHome, args.hostPlatform),
...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
@@ -120,7 +129,7 @@ export async function scanRemoteAiVaultSessions(args: {
issues,
scopePaths,
limit,
alreadyParsedFilePaths: parsed.parsedFilePaths
alreadyParsedCandidateKeys: parsed.parsedCandidateKeys
})
const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions])
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
@@ -138,9 +147,9 @@ async function parseRemoteSessionCandidates(args: {
context: RemoteScannerContext
issues: AiVaultScanIssue[]
limit: number
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set<string> }> {
const sessions = new ScannedSessionCollection()
const parsedFilePaths = new Set<string>()
const parsedCandidateKeys = new Set<string>()
let index = 0
while (index < args.candidates.length) {
@@ -153,7 +162,7 @@ async function parseRemoteSessionCandidates(args: {
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
for (const candidate of batch) {
parsedFilePaths.add(candidate.file.path)
parsedCandidateKeys.add(remoteSessionCandidateKey(candidate))
}
throwIfAiVaultScanCancelled(args.context.signal)
const results = await Promise.all(
@@ -171,7 +180,7 @@ async function parseRemoteSessionCandidates(args: {
// 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()], parsedFilePaths }
return { sessions: [...sessions.values()], parsedCandidateKeys }
}
async function scanRemoteInScopeSessions(args: {
@@ -180,14 +189,14 @@ async function scanRemoteInScopeSessions(args: {
issues: AiVaultScanIssue[]
scopePaths: readonly string[]
limit: number
alreadyParsedFilePaths: ReadonlySet<string>
alreadyParsedCandidateKeys: ReadonlySet<string>
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const candidates = args.candidates.filter(
(candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)
(candidate) => !args.alreadyParsedCandidateKeys.has(remoteSessionCandidateKey(candidate))
)
const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT)
const sessions: AiVaultSession[] = []
@@ -9,6 +9,9 @@ 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 ||
@@ -0,0 +1,86 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
const reader = vi.hoisted(() => ({ v1: vi.fn(), v2: vi.fn() }))
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
parseOpenCodeSqliteSessionViaWorker: reader.v1,
parseOpenCode2SqliteSessionViaWorker: reader.v2
}))
beforeEach(() => {
reader.v1.mockReset().mockResolvedValue({ firstUserPrompt: 'full first prompt' })
reader.v2.mockReset().mockResolvedValue({ firstUserPrompt: 'full second-schema prompt' })
})
it('asks the host reader for the full prompt instead of opening SQLite in the calling process', async () => {
expect(
await readAiVaultFirstUserPrompt({
agent: 'opencode',
filePath: '/home/me/opencode.db#session-1'
})
).toEqual({ prompt: 'full first prompt' })
expect(reader.v1).toHaveBeenCalledWith({
dbPath: '/home/me/opencode.db',
sessionId: 'session-1',
platform: process.platform,
fullFirstUserPrompt: true
})
})
it('keeps a WSL database address intact for host routing', async () => {
const dbPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\opencode.db'
expect(
await readAiVaultFirstUserPrompt({
agent: 'opencode',
filePath: dbPath,
sessionId: 'session-1'
})
).toEqual({ prompt: 'full first prompt' })
expect(reader.v1).toHaveBeenCalledWith(
expect.objectContaining({
dbPath,
sessionId: 'session-1',
fullFirstUserPrompt: true
})
)
})
it('supports the v2 schema through the same full-prompt worker contract', async () => {
expect(
await readAiVaultFirstUserPrompt({
agent: 'opencode2',
filePath: '/home/me/opencode.db',
sessionId: 'channel/session-1'
})
).toEqual({ prompt: 'full second-schema prompt' })
expect(reader.v2).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'channel/session-1',
fullFirstUserPrompt: true
})
)
expect(reader.v1).not.toHaveBeenCalled()
})
it('does not substitute local history for a remote session', async () => {
expect(
await readAiVaultFirstUserPrompt({
agent: 'opencode',
filePath: '/home/me/opencode.db',
sessionId: 'session-1',
executionHostId: 'ssh:host'
})
).toEqual({ prompt: null })
expect(reader.v1).not.toHaveBeenCalled()
})
it('degrades a failed guest read to an unavailable prompt', async () => {
reader.v1.mockRejectedValue(new Error('guest stopped'))
expect(
await readAiVaultFirstUserPrompt({
agent: 'opencode',
filePath: '/home/me/opencode.db',
sessionId: 'session-1'
})
).toEqual({ prompt: null })
})
@@ -7,9 +7,14 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/exec
import { wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import {
parseOpenCodeSqliteSessionViaWorker,
parseOpenCode2SqliteSessionViaWorker
} from './session-scanner-opencode-sqlite-worker-spawn'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type { FileWithMtime } from './session-scanner-types'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
import { configureOpenCodeWslReaders } from './session-scanner-opencode-wsl-client'
export type ReadAiVaultFirstUserPromptArgs = {
agent: AiVaultAgent
@@ -17,6 +22,7 @@ export type ReadAiVaultFirstUserPromptArgs = {
sessionId?: string
executionHostId?: ExecutionHostId
codexHome?: string | null
wslOpenCodeReaders?: readonly OpenCodeWslRuntime[]
}
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
@@ -39,6 +45,9 @@ export async function readAiVaultFirstUserPrompt(
if (executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return { prompt: null }
}
if (args.wslOpenCodeReaders) {
configureOpenCodeWslReaders(args.wslOpenCodeReaders)
}
// Why: partial/corrupt transcripts make parsers throw. Resolve null like every
// other unavailable case instead of rejecting the IPC call.
@@ -66,20 +75,24 @@ async function parseSessionForFullFirstUserPrompt(args: {
sessionId?: string
codexHome: string | null
}): Promise<AiVaultSession | null> {
// Why: OpenCode SQLite sessions store filePath as the db path (not db#id).
// Re-parse in-process under full capture so ALS applies and we can read the
// earliest user row (worker list-scan path only joins newest messages).
if (args.agent === 'opencode') {
// Full capture belongs inside the reader, including the guest reader for WSL.
if (args.agent === 'opencode' || args.agent === 'opencode2') {
const parse =
args.agent === 'opencode2'
? parseOpenCode2SqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath)
if (fromSynthetic) {
return parseOpenCodeSqliteSession({
return parse({
fullFirstUserPrompt: true,
dbPath: fromSynthetic.dbPath,
sessionId: fromSynthetic.sessionId,
platform: process.platform
})
}
if (args.sessionId) {
return parseOpenCodeSqliteSession({
return parse({
fullFirstUserPrompt: true,
dbPath: args.filePath,
sessionId: args.sessionId,
platform: process.platform
@@ -1,4 +1,5 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { parseDevinSessionFile } from './session-scanner-devin-parser'
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
import { parseDroidSessionFile } from './session-scanner-droid-parser'
@@ -35,17 +36,32 @@ import type { TranscriptMessageSink } from './session-transcript-consumers'
async function readOpenCodeSqliteCandidate(
sqliteCandidate: { dbPath: string; sessionId: string },
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
messages?: TranscriptMessageSink,
signal?: AbortSignal,
agent?: 'opencode2'
): Promise<AiVaultSession | null> {
const request = { ...sqliteCandidate, platform }
throwIfSignalAborted(signal)
const request = { ...sqliteCandidate, platform, signal }
if (!messages?.active) {
return parseOpenCodeSqliteSessionViaWorker(request)
const parse =
agent === 'opencode2'
? parseOpenCode2SqliteSessionViaWorker
: parseOpenCodeSqliteSessionViaWorker
const session = await parse(request)
throwIfSignalAborted(signal)
return session
}
const capture = await captureOpenCodeSqliteSessionViaWorker(request)
for (const message of capture.messages) {
const capture =
agent === 'opencode2'
? captureOpenCode2SqliteSessionViaWorker
: captureOpenCodeSqliteSessionViaWorker
const result = await capture(request)
for (const message of result.messages) {
throwIfSignalAborted(signal)
messages.push(message)
}
return capture.session
throwIfSignalAborted(signal)
return result.session
}
/**
@@ -61,7 +77,8 @@ async function readOpenCodeSqliteCandidate(
export async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
messages?: TranscriptMessageSink,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
switch (candidate.agent) {
case 'claude':
@@ -88,7 +105,7 @@ export async function parseAgentSessionFile(
// real filesystem paths and fall through to the JSON parser.
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
if (sqliteCandidate) {
return readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages)
return readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages, signal)
}
return parseOpenCodeSessionFile(candidate.file, platform, messages)
}
@@ -98,22 +115,7 @@ export async function parseAgentSessionFile(
// candidate path; there is no legacy file store.
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
if (sqliteCandidate) {
if (messages?.active) {
const capture = await captureOpenCode2SqliteSessionViaWorker({
dbPath: sqliteCandidate.dbPath,
sessionId: sqliteCandidate.sessionId,
platform
})
for (const message of capture.messages) {
messages.push(message)
}
return capture.session
}
return parseOpenCode2SqliteSessionViaWorker({
dbPath: sqliteCandidate.dbPath,
sessionId: sqliteCandidate.sessionId,
platform
})
return readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages, signal, 'opencode2')
}
return null
}
@@ -25,6 +25,7 @@ import {
} from './session-scanner-worker-spawn'
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
import { isWslUncPath } from '../../shared/wsl-paths'
export function shouldUseAiVaultServiceProcess(): boolean {
const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
@@ -70,9 +71,14 @@ export function listAiVaultSubagentSessionsInBackground(
: listLocalAiVaultSubagentSessions(request)
}
export function readAiVaultFirstUserPromptInBackground(
export async function readAiVaultFirstUserPromptInBackground(
request: ReadAiVaultFirstUserPromptArgs
): Promise<ReadAiVaultFirstUserPromptResult> {
if (process.platform === 'win32' && isWslUncPath(request.filePath)) {
const { localAiVaultScanRoots } = await import('./cached-session-list')
const roots = await localAiVaultScanRoots()
request = { ...request, wslOpenCodeReaders: roots.wslOpenCodeReaders ?? [] }
}
return shouldUseAiVaultServiceProcess()
? readAiVaultFirstUserPromptInService(request)
: readAiVaultFirstUserPrompt(request)
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import type { SessionFileDiscovery } from './session-scanner-types'
import type { TranscriptReadOutcome } from './session-transcript-consumers'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
const readers = vi.hoisted(() => ({
parse: vi.fn<typeof workerSpawn.parseOpenCodeSqliteSessionViaWorker>(),
capture: vi.fn<typeof workerSpawn.captureOpenCodeSqliteSessionViaWorker>(),
discover: vi.fn<() => Promise<SessionFileDiscovery[]>>()
}))
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
parseOpenCodeSqliteSessionViaWorker: readers.parse,
parseOpenCode2SqliteSessionViaWorker: readers.parse,
captureOpenCodeSqliteSessionViaWorker: readers.capture,
captureOpenCode2SqliteSessionViaWorker: readers.capture
}))
vi.mock('./session-scanner-source-discovery', () => ({
discoverAiVaultSessionSources: readers.discover
}))
import { scanAiVaultSessions } from './session-scanner'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests
} from './session-transcript-consumers'
const file = {
path: '/fixture/opencode.db#session',
mtimeMs: 1,
modifiedAt: new Date(1).toISOString()
}
const messages = [
{ role: 'user' as const, text: 'First', timestamp: null },
{ role: 'assistant' as const, text: 'Second', timestamp: null }
]
beforeEach(() => {
vi.clearAllMocks()
resetSessionParseCacheForTests()
})
afterEach(() => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
function configure(agent: 'opencode' | 'opencode2') {
readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }])
const accumulator = createAccumulator({ agent, file, sessionId: 'session' })
accumulator.title = 'SQLite session'
return finalizeSession(accumulator, 'linux')
}
function untilAborted(signal: AbortSignal | undefined): Promise<never> {
if (!signal) {
throw new Error('SQLite request did not receive the scan signal')
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => {
it.each(['parse', 'capture'] as const)(
'cancels an active %s and retries the uncached read',
async (mode) => {
const session = configure(agent)
const outcomes: TranscriptReadOutcome[] = []
if (mode === 'capture') {
registerTranscriptConsumer({
beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) })
})
readers.capture.mockImplementationOnce(({ signal }) => untilAborted(signal))
} else {
readers.parse.mockImplementationOnce(({ signal }) => untilAborted(signal))
}
const controller = new AbortController()
const pending = scanAiVaultSessions({ platform: 'linux', signal: controller.signal })
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(readers[mode]).toHaveBeenCalledOnce())
controller.abort(new Error('Cancelled SQLite scan'))
await rejected
if (mode === 'capture') {
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
}
readers.parse.mockResolvedValue(session)
readers.capture.mockResolvedValue({ session, messages })
const retried = await scanAiVaultSessions({ platform: 'linux' })
expect(readers[mode]).toHaveBeenCalledTimes(2)
expect(retried.sessions).toHaveLength(1)
if (mode === 'capture') {
expect(outcomes.at(-1)?.incomplete).toBe(false)
}
}
)
it('marks a partially delivered capture incomplete and never caches it', async () => {
const session = configure(agent)
const controller = new AbortController()
const outcomes: TranscriptReadOutcome[] = []
const received: string[] = []
registerTranscriptConsumer({
beginRead: () => ({
message(message) {
received.push(message.text)
controller.abort()
},
finish: (outcome) => outcomes.push(outcome)
})
})
readers.capture.mockResolvedValue({ session, messages })
await expect(
scanAiVaultSessions({ platform: 'linux', signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
expect(received).toEqual(['First'])
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
resetTranscriptConsumersForTests()
readers.parse.mockResolvedValue(session)
expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1)
expect(readers.parse).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,116 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client'
const mocked = vi.hoisted(() => ({ spawn: vi.fn() }))
vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: mocked.spawn }))
function child() {
const process = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
unref: vi.fn(),
kill: vi.fn()
})
process.stdin.on('data', (data) => {
const request = JSON.parse(data.toString())
if (request.sessionId !== 'block') {
process.stdout.write(`${JSON.stringify({ id: request.id, ok: true, value: null })}\n`)
}
})
return process
}
const args = { dbPath: '/home/ada/opencode.db', sessionId: 'session', platform: 'linux' as const }
const clients: ReturnType<typeof createOpenCodeSqliteProcessClient>[] = []
afterEach(() => {
clients.splice(0).forEach((client) => client.dispose())
vi.restoreAllMocks()
vi.clearAllMocks()
})
function reader(beforeSpawn: (signal: AbortSignal) => Promise<void>, idleTeardownMs = 30_000) {
mocked.spawn.mockImplementation(child)
const result = createOpenCodeSqliteProcessClient({
executable: 'wsl.exe',
args: ['--exec', '/runtime', '/reader'],
beforeSpawn,
idleTeardownMs
})
clients.push(result)
return result
}
describe('SQLite process launch admission', () => {
it('does not probe a reader cancelled before admission starts', async () => {
const admit = vi.fn(async () => {})
const client = reader(admit)
const cancellation = new AbortController()
const pending = client.parse({ ...args, signal: cancellation.signal })
const rejected = expect(pending).rejects.toThrow('cancelled')
cancellation.abort(new Error('cancelled'))
await rejected
await Promise.resolve()
expect(admit).not.toHaveBeenCalled()
expect(mocked.spawn).not.toHaveBeenCalled()
})
it('probes once per process, reuses it for every read, and reprobes after idle expiry', async () => {
const admit = vi.fn(async () => {})
const client = reader(admit, 15)
for (let index = 0; index < 20; index++) {
expect(await client.parse(args)).toBeNull()
}
expect(admit).toHaveBeenCalledOnce()
expect(mocked.spawn).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(mocked.spawn.mock.results[0]?.value.kill).toHaveBeenCalled())
await client.parse(args)
expect(admit).toHaveBeenCalledTimes(2)
expect(mocked.spawn).toHaveBeenCalledTimes(2)
})
it('blocks every queued respawn after the execution host stops', async () => {
const admit = vi.fn(async () => {})
const client = reader(admit)
const active = client.parse({ ...args, sessionId: 'block' }).catch((error: unknown) => error)
const queued = Array.from({ length: 4 }, () =>
client.parse(args).catch((error: unknown) => error)
)
await vi.waitFor(() => expect(mocked.spawn).toHaveBeenCalledOnce())
admit.mockRejectedValue(new Error('Distro is not running'))
mocked.spawn.mock.results[0]?.value.emit('exit', 1)
expect(await active).toMatchObject({ message: expect.stringContaining('exited') })
for (const result of await Promise.all(queued)) {
expect(result).toBeInstanceOf(Error)
}
expect(mocked.spawn).toHaveBeenCalledOnce()
expect(admit.mock.calls.length).toBeGreaterThan(1)
})
it('rejects unavailable admission and cancels a pending probe without a late spawn', async () => {
const refused = reader(async () => {
throw new Error('Running state unavailable')
})
await expect(refused.parse(args)).rejects.toThrow('Running state unavailable')
expect(mocked.spawn).not.toHaveBeenCalled()
let finish = () => {}
const admit = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve
})
)
const waiting = reader(admit)
const cancellation = new AbortController()
const pending = waiting.parse({ ...args, signal: cancellation.signal })
const rejected = expect(pending).rejects.toThrow('cancelled')
await vi.waitFor(() => expect(admit).toHaveBeenCalledOnce())
cancellation.abort(new Error('cancelled'))
await rejected
finish()
await Promise.resolve()
await Promise.resolve()
expect(mocked.spawn).not.toHaveBeenCalled()
})
})
@@ -24,11 +24,14 @@ export function opencodeDiscoveries(
storageDir,
dbPaths: dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path))),
limitPerAgent: limit,
issues
issues,
signal: options.signal
})
),
// Current releases share opencode.db with v1; the worker checks for v2 tables.
paths.then((dbPaths) => discoverOpenCode2Sessions(storageDir, dbPaths, limit, issues))
paths.then((dbPaths) =>
discoverOpenCode2Sessions(storageDir, dbPaths, limit, issues, options.signal)
)
]
})
}
@@ -96,9 +99,10 @@ async function discoverOpenCode2Sessions(
storageDir: string,
dbPaths: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
issues: AiVaultScanIssue[],
signal?: AbortSignal
): Promise<SessionFileDiscovery> {
const files = await listOpenCode2SqliteSessionsViaWorker({ dbPaths, limit, issues })
const files = await listOpenCode2SqliteSessionsViaWorker({ dbPaths, limit, issues, signal })
return {
agent: 'opencode2' as const,
rootDir: storageDir,
@@ -40,6 +40,7 @@ export async function discoverOpenCodeSessions(args: {
dbPaths: readonly string[]
limitPerAgent: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileDiscovery> {
const [fileDiscovery, sqliteCandidates] = await Promise.all([
discoverFiles({
@@ -54,7 +55,8 @@ export async function discoverOpenCodeSessions(args: {
listOpenCodeSqliteSessionsViaWorker({
dbPaths: args.dbPaths,
limit: args.limitPerAgent,
issues: args.issues
issues: args.issues,
signal: args.signal
})
])
@@ -0,0 +1,63 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list'
import {
captureOpenCode2SqliteSession,
parseOpenCode2SqliteSession
} from './session-scanner-opencode2-sqlite'
import type {
OpenCodeSqliteWorkerRequest,
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
export async function handleOpenCodeSqliteRequest(
request: OpenCodeSqliteWorkerRequest
): Promise<OpenCodeSqliteWorkerResponse> {
try {
if (request.kind === 'list') {
const issues: AiVaultScanIssue[] = []
const candidates =
request.agent === 'opencode2'
? await listOpenCode2SqliteSessions({
dbPaths: request.dbPaths,
limit: request.limit ?? Infinity,
issues
})
: await listOpenCodeSqliteSessions({
dbPaths: request.dbPaths,
limit: request.limit ?? Infinity,
issues
})
return { id: request.id, ok: true, value: { candidates, issues } }
}
if (request.kind === 'capture') {
const capture =
request.agent === 'opencode2'
? await captureOpenCode2SqliteSession(request)
: await captureOpenCodeSqliteSession(request)
return { id: request.id, ok: true, value: capture }
}
const parse = async () =>
request.agent === 'opencode2'
? await parseOpenCode2SqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
})
: await parseOpenCodeSqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
})
const session = request.fullFirstUserPrompt
? await withFullFirstUserPromptCapture(parse)
: await parse()
return { id: request.id, ok: true, value: session }
} catch (err) {
return { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
@@ -0,0 +1,154 @@
import { EventEmitter } from 'node:events'
import { spawnProcess } from '../../shared/child-process/run-process'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import type { WorkerRequestTransport } from '../lazy-worker-thread-host'
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import {
createOpenCodeSqliteLineDecoder,
encodeOpenCodeSqliteFrame,
OPENCODE_SQLITE_REQUEST_MAX_BYTES,
OPENCODE_SQLITE_RESPONSE_MAX_BYTES
} from './session-scanner-opencode-sqlite-process-framing'
export type OpenCodeSqliteProcessOptions = {
executable: string
args: readonly string[]
env?: NodeJS.ProcessEnv
cwd?: string
log?: (message: string) => void
requestTimeoutMs?: number
idleTeardownMs?: number
beforeSpawn?: (signal: AbortSignal) => Promise<void>
}
function createProcessTransport(options: OpenCodeSqliteProcessOptions): WorkerRequestTransport {
const child = spawnProcess({
program: options.executable,
args: options.args,
env: options.env,
cwd: options.cwd
})
const events = new EventEmitter()
let retired = false
const fault = (error: Error): void => {
if (!retired) {
events.emit('error', error)
}
}
const decode = createOpenCodeSqliteLineDecoder(OPENCODE_SQLITE_RESPONSE_MAX_BYTES, (line) => {
const response: unknown = JSON.parse(line)
if (
typeof response !== 'object' ||
response === null ||
!('id' in response) ||
!Number.isSafeInteger(response.id) ||
!('ok' in response) ||
typeof response.ok !== 'boolean' ||
(!response.ok && (!('error' in response) || typeof response.error !== 'string'))
) {
throw new Error('Invalid OpenCode SQLite process response.')
}
events.emit('message', response)
})
child.stdout.on('data', (chunk: Buffer) => {
if (retired) {
return
}
try {
decode(chunk)
} catch (error) {
fault(error instanceof Error ? error : new Error(String(error)))
}
})
// Diagnostics never share the framed response channel or accumulate in memory.
child.stderr.resume()
child.on('error', fault)
child.stdin.on('error', fault)
child.stdout.on('error', fault)
child.stderr.on('error', fault)
child.on('exit', (code) => {
if (!retired) {
events.emit('exit', code ?? 1)
}
})
return Object.assign(events, {
postMessage(request: unknown): void {
if (retired || child.stdin.destroyed) {
throw new Error('OpenCode SQLite reader process is unavailable.')
}
child.stdin.write(encodeOpenCodeSqliteFrame(request, OPENCODE_SQLITE_REQUEST_MAX_BYTES))
},
unref(): void {
child.unref()
for (const stream of [child.stdin, child.stdout, child.stderr]) {
if ('unref' in stream && typeof stream.unref === 'function') {
stream.unref()
}
}
},
async terminate(): Promise<number> {
retired = true
// EOF reaches the guest reader even when this process is an SSH/WSL launcher.
child.stdin.end()
child.kill('SIGKILL')
child.stdout.destroy()
child.stderr.destroy()
return 0
}
})
}
export function createOpenCodeSqliteProcessClient(
options: OpenCodeSqliteProcessOptions
): OpenCodeSqliteWorkerClient {
return new OpenCodeSqliteWorkerClient({
workerFactory: () =>
options.beforeSpawn
? createAdmittedProcessTransport(options, options.beforeSpawn)
: createProcessTransport(options),
log: options.log,
requestTimeoutMs: options.requestTimeoutMs,
idleTeardownMs: options.idleTeardownMs
})
}
/** Admission belongs to each process birth, including respawns queued after a failure. */
function createAdmittedProcessTransport(
options: OpenCodeSqliteProcessOptions,
admit: (signal: AbortSignal) => Promise<void>
): WorkerRequestTransport {
const events = new EventEmitter()
const controller = new AbortController()
let transport: WorkerRequestTransport | undefined
const ready = Promise.resolve().then(async () => {
controller.signal.throwIfAborted()
await waitForPromiseWithSignal(admit(controller.signal), controller.signal)
controller.signal.throwIfAborted()
transport = createProcessTransport(options)
transport.on('message', (message) => events.emit('message', message))
transport.on('error', (error) => events.emit('error', error))
transport.on('exit', (code) => events.emit('exit', code))
transport.unref()
return transport
})
return Object.assign(events, {
postMessage(request: unknown): void {
void ready
.then((processTransport) => {
controller.signal.throwIfAborted()
processTransport.postMessage(request)
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
events.emit('error', error instanceof Error ? error : new Error(String(error)))
}
})
},
unref(): void {},
async terminate(): Promise<number> {
controller.abort()
transport?.removeAllListeners()
return transport ? transport.terminate() : 0
}
})
}
@@ -0,0 +1,117 @@
import { isMainThread, parentPort, Worker } from 'node:worker_threads'
import { handleOpenCodeSqliteRequest } from './session-scanner-opencode-sqlite-dispatch'
import type { OpenCodeSqliteWorkerRequest } from './session-scanner-opencode-sqlite-worker-protocol'
import {
createOpenCodeSqliteLineDecoder,
encodeOpenCodeSqliteFrame,
OPENCODE_SQLITE_PROCESS_MAX_TIMEOUT_MS,
OPENCODE_SQLITE_REQUEST_MAX_BYTES,
OPENCODE_SQLITE_RESPONSE_MAX_BYTES
} from './session-scanner-opencode-sqlite-process-framing'
function validRequest(value: unknown): value is OpenCodeSqliteWorkerRequest {
if (
typeof value !== 'object' ||
value === null ||
!('id' in value) ||
!Number.isSafeInteger(value.id) ||
!('kind' in value) ||
('agent' in value && value.agent !== 'opencode2') ||
('timeoutMs' in value &&
(typeof value.timeoutMs !== 'number' ||
!Number.isFinite(value.timeoutMs) ||
value.timeoutMs <= 0))
) {
return false
}
if (value.kind === 'list') {
return (
'dbPaths' in value &&
Array.isArray(value.dbPaths) &&
value.dbPaths.every((path) => typeof path === 'string') &&
'limit' in value &&
(value.limit === null ||
(typeof value.limit === 'number' && Number.isSafeInteger(value.limit) && value.limit >= 0))
)
}
return (
(value.kind === 'parse' || value.kind === 'capture') &&
'dbPath' in value &&
typeof value.dbPath === 'string' &&
'sessionId' in value &&
typeof value.sessionId === 'string' &&
'platform' in value &&
typeof value.platform === 'string' &&
[
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
].includes(value.platform) &&
(!('fullFirstUserPrompt' in value) || typeof value.fullFirstUserPrompt === 'boolean')
)
}
if (!isMainThread) {
const port = parentPort
if (!port) {
throw new Error('Missing OpenCode SQLite process worker port.')
}
port.on('message', (request: OpenCodeSqliteWorkerRequest) => {
void handleOpenCodeSqliteRequest(request).then((response) => {
try {
port.postMessage(encodeOpenCodeSqliteFrame(response, OPENCODE_SQLITE_RESPONSE_MAX_BYTES))
} catch {
port.postMessage(
`${JSON.stringify({
id: request.id,
ok: false,
error: 'OpenCode SQLite response exceeds its byte limit.'
})}\n`
)
}
})
})
} else {
// Keep stdin and the hard deadline responsive while SQLite blocks its worker.
const worker = new Worker(__filename)
let activeId: number | null = null
let deadline: ReturnType<typeof setTimeout> | undefined
const stop = (code: number): never => process.exit(code)
worker.on('error', () => stop(1))
worker.on('exit', () => stop(1))
worker.on('message', (line: string) => {
clearTimeout(deadline)
activeId = null
process.stdout.write(line)
})
const decode = createOpenCodeSqliteLineDecoder(OPENCODE_SQLITE_REQUEST_MAX_BYTES, (line) => {
const request: unknown = JSON.parse(line)
if (!validRequest(request) || activeId !== null) {
return stop(1)
}
activeId = request.id
deadline = setTimeout(
() => stop(124),
Math.min(request.timeoutMs ?? 30_000, OPENCODE_SQLITE_PROCESS_MAX_TIMEOUT_MS)
)
worker.postMessage(request)
})
process.stdin.on('data', (chunk: Buffer) => {
try {
decode(chunk)
} catch {
stop(1)
}
})
process.stdin.on('end', () => stop(0))
process.stdin.on('error', () => stop(1))
process.stdout.on('error', () => stop(1))
}
@@ -0,0 +1,25 @@
import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines'
export const OPENCODE_SQLITE_REQUEST_MAX_BYTES = 1024 * 1024
export const OPENCODE_SQLITE_RESPONSE_MAX_BYTES = 32 * 1024 * 1024
export const OPENCODE_SQLITE_PROCESS_MAX_TIMEOUT_MS = 60_000
export function createOpenCodeSqliteLineDecoder(
maxBytes: number,
onLine: (line: string) => void
): (chunk: Buffer) => void {
const lines = new SearchSubprocessLineAccumulator(maxBytes)
return (chunk) => {
if (!lines.push(chunk, onLine)) {
throw new Error('OpenCode SQLite transport frame exceeds its byte limit.')
}
}
}
export function encodeOpenCodeSqliteFrame(value: unknown, maxBytes: number): string {
const line = JSON.stringify(value)
if (Buffer.byteLength(line) > maxBytes) {
throw new Error('OpenCode SQLite transport frame exceeds its byte limit.')
}
return `${line}\n`
}
@@ -0,0 +1,253 @@
import { build } from 'esbuild'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import * as processRunner from '../../shared/child-process/run-process'
import { runProcess, spawnProcess } from '../../shared/child-process/run-process'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts'
import SyncDatabase from '../sqlite/sync-database'
import { appendTurns, writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'
import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client'
import {
OPENCODE_SQLITE_REQUEST_MAX_BYTES,
OPENCODE_SQLITE_RESPONSE_MAX_BYTES
} from './session-scanner-opencode-sqlite-process-framing'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const bun =
process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform))
const directory = mkdtempSync(join(tmpdir(), 'orca-opencode-process-'))
const entry = join(directory, 'reader.cjs')
const dbPath = join(directory, 'opencode.db')
const oversizedPath = join(directory, 'oversized.db')
const prompt = 'A complete first prompt. '.repeat(200)
let writer: SyncDatabase
beforeAll(async () => {
await build({
entryPoints: ['src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
external: ['bun:sqlite'],
outfile: entry,
logLevel: 'silent'
})
writeOpenCodeSqliteDatabase(dbPath, [{ id: 'wal-session', turns: [] }])
writer = new SyncDatabase(dbPath)
writer.pragma('journal_mode = WAL')
writer.pragma('wal_autocheckpoint = 0')
appendTurns(writer, { id: 'wal-session', turns: [{ role: 'user', parts: [prompt] }] }, Date.now())
writeOpenCodeSqliteDatabase(oversizedPath, [
{
id: 'oversized',
turns: Array.from({ length: 130 }, () => ({
role: 'user' as const,
parts: ['x'.repeat(256 * 1024)]
}))
}
])
})
afterAll(() => {
writer?.close()
rmSync(directory, { recursive: true, force: true })
})
for (const [runtime, executable] of [
['Node', process.execPath],
['Bun', bun]
]) {
describe.skipIf(!existsSync(executable))(`OpenCode SQLite process under ${runtime}`, () => {
it('uses the deployed runtime and reads live WAL, full prompts and captures in one persistent child', async () => {
if (runtime === 'Bun') {
expect((await runProcess({ program: executable, args: ['--version'] })).stdout.trim()).toBe(
ORCAD_BUN_VERSION
)
}
expect(existsSync(`${dbPath}-wal`)).toBe(true)
const spawn = vi.spyOn(processRunner, 'spawnProcess')
const client = createOpenCodeSqliteProcessClient({
executable,
args: [entry],
cwd: directory,
beforeSpawn: async () => {}
})
try {
const issues: AiVaultScanIssue[] = []
const listed = await client.list({ dbPaths: [dbPath], limit: 5, issues })
expect(issues).toEqual([])
expect(listed).toHaveLength(1)
expect(await client.list({ dbPaths: [dbPath], limit: Infinity, issues })).toHaveLength(1)
const args = { dbPath, sessionId: 'wal-session', platform: process.platform }
expect((await client.parse(args))?.firstUserPrompt).toBeUndefined()
expect((await client.parse({ ...args, fullFirstUserPrompt: true }))?.firstUserPrompt).toBe(
prompt.trim()
)
expect((await client.capture(args)).messages).toEqual([
expect.objectContaining({ role: 'user', text: prompt })
])
expect(spawn).toHaveBeenCalledOnce()
} finally {
client.dispose()
spawn.mockRestore()
}
})
it('fails oversized query responses without truncating a transcript and remains usable', async () => {
const client = createOpenCodeSqliteProcessClient({ executable, args: [entry] })
try {
await expect(
client.capture({
dbPath: oversizedPath,
sessionId: 'oversized',
platform: process.platform
})
).rejects.toThrow('response exceeds its byte limit')
expect(
await client.parse({ dbPath, sessionId: 'wal-session', platform: process.platform })
).not.toBeNull()
} finally {
client.dispose()
}
})
it('exits on parent EOF even while SQLite is busy, and enforces its own hard deadline', async () => {
const lockedPath = join(directory, `locked-${runtime}.db`)
writeOpenCodeSqliteDatabase(lockedPath, [{ id: 'locked', turns: [] }])
const lock = new SyncDatabase(lockedPath)
lock.exec('BEGIN EXCLUSIVE')
try {
const request = `${JSON.stringify({
id: 1,
kind: 'parse',
dbPath: lockedPath,
sessionId: 'locked',
platform: process.platform,
timeoutMs: 100
})}\n`
const child = spawnProcess({ program: executable, args: [entry] })
child.stderr.resume()
child.stdout.resume()
const exit = new Promise<number | null>((resolveExit) => child.once('exit', resolveExit))
child.stdin.on('error', () => {})
child.stdin.write(request)
expect(await exit).toBe(124)
const eof = await runProcess({
program: executable,
args: [entry],
input: request,
timeoutMs: 2_000
})
expect(eof.timedOut).toBe(false)
expect(eof.code).toBe(0)
const client = createOpenCodeSqliteProcessClient({ executable, args: [entry] })
const cancellation = new AbortController()
try {
const pending = client.parse({
dbPath: lockedPath,
sessionId: 'locked',
platform: process.platform,
signal: cancellation.signal
})
const rejected = expect(pending).rejects.toThrow('SQL read cancelled')
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100))
cancellation.abort(new Error('SQL read cancelled'))
await rejected
} finally {
client.dispose()
}
} finally {
lock.exec('ROLLBACK')
lock.close()
}
})
it('rejects oversized request frames before running a query', async () => {
const result = await runProcess({
program: executable,
args: [entry],
input: 'x'.repeat(OPENCODE_SQLITE_REQUEST_MAX_BYTES + 1),
timeoutMs: 2_000
})
expect(result.code).toBe(1)
expect(result.stdout).toBe('')
})
})
}
describe('OpenCode SQLite process retirement', () => {
const args = { dbPath, sessionId: 'wal-session', platform: process.platform }
it('closes the child at idle expiry and creates one new reader for the next request', async () => {
const spawn = vi.spyOn(processRunner, 'spawnProcess')
const client = createOpenCodeSqliteProcessClient({
executable: process.execPath,
args: [entry],
idleTeardownMs: 20
})
try {
expect(await client.parse(args)).not.toBeNull()
const firstChild = spawn.mock.results[0]?.value
expect(firstChild).toBeDefined()
await vi.waitFor(() => expect(firstChild.signalCode).toBe('SIGKILL'))
expect(await client.parse(args)).not.toBeNull()
expect(spawn).toHaveBeenCalledTimes(2)
} finally {
client.dispose()
spawn.mockRestore()
}
})
it('cancels an active read, enforces queue limits, and fails closed after disposal', async () => {
const client = createOpenCodeSqliteProcessClient({
executable: process.execPath,
args: ['-e', 'process.stdin.resume();setInterval(()=>{},1000)'],
requestTimeoutMs: 5_000
})
const signal = new AbortController()
const active = client.parse({ ...args, signal: signal.signal }).catch((error: unknown) => error)
const queued = Array.from({ length: 64 }, () =>
client.parse(args).catch((error: unknown) => error)
)
await expect(client.parse(args)).rejects.toThrow('queue is full')
signal.abort(new Error('read cancelled'))
expect(await active).toMatchObject({ message: 'read cancelled' })
client.dispose()
expect(await Promise.all(queued)).toHaveLength(64)
await expect(client.parse(args)).rejects.toThrow('disposed')
})
it('retires a silent process at timeout and rejects bounded stdout/input overflow', async () => {
const silent = createOpenCodeSqliteProcessClient({
executable: process.execPath,
args: ['-e', 'process.stdin.resume();setInterval(()=>{},1000)'],
requestTimeoutMs: 100
})
try {
await expect(silent.parse(args)).rejects.toThrow('timed out')
} finally {
silent.dispose()
}
const noisy = createOpenCodeSqliteProcessClient({
executable: process.execPath,
args: [
'-e',
`process.stdin.once('data',()=>process.stdout.write('x'.repeat(${OPENCODE_SQLITE_RESPONSE_MAX_BYTES + 1})));`
]
})
try {
await expect(noisy.parse(args)).rejects.toThrow('byte limit')
await expect(
noisy.parse({ ...args, dbPath: 'x'.repeat(OPENCODE_SQLITE_REQUEST_MAX_BYTES) })
).rejects.toThrow('byte limit')
} finally {
noisy.dispose()
}
})
})
@@ -52,16 +52,24 @@ function sessionReadFailure(err: unknown): Error {
* SQLite work onto the main thread.
*/
export class OpenCodeSqliteWorkerClient {
private readonly requestTimeoutMs: number | undefined
private readonly requests: WorkerThreadRequestQueue<
OpenCodeSqliteWorkerRequest,
OpenCodeSqliteWorkerResponse
>
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
constructor(options: {
workerFactory: WorkerThreadFactory
log?: (message: string) => void
idleTeardownMs?: number
requestTimeoutMs?: number
}) {
this.requestTimeoutMs = options.requestTimeoutMs
const log = options.log ?? ((message: string) => console.warn(message))
this.requests = new WorkerThreadRequestQueue({
factory: options.workerFactory,
idleTeardownMs: IDLE_TEARDOWN_MS,
idleTeardownMs: options.idleTeardownMs ?? IDLE_TEARDOWN_MS,
queueCap: { maxQueuedCalls: 64, describeFull: () => 'OpenCode SQLite reader queue is full.' },
maxConsecutiveDeaths: MAX_CONSECUTIVE_DEATHS,
createUnavailableError: (message) => new OpenCodeSqliteWorkerUnavailableError(message),
describeTimeout: (timeoutMs) => `OpenCode SQLite worker timed out after ${timeoutMs}ms`,
@@ -91,6 +99,7 @@ export class OpenCodeSqliteWorkerClient {
limit: number
issues: AiVaultScanIssue[]
agent?: 'opencode2'
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
if (args.dbPaths.length === 0) {
return []
@@ -102,14 +111,18 @@ export class OpenCodeSqliteWorkerClient {
id,
kind: 'list',
dbPaths: args.dbPaths,
limit: args.limit,
limit: Number.isFinite(args.limit) ? args.limit : null,
...(args.agent ? { agent: args.agent } : {})
}),
LIST_TIMEOUT_MS
LIST_TIMEOUT_MS,
args.signal
)) as OpenCodeSqliteListValue
args.issues.push(...value.issues)
return value.candidates
} catch (err) {
if (args.signal?.aborted) {
throw err
}
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
// Kinded: a whole source failed, not a transcript.
args.issues.push({
@@ -144,22 +157,26 @@ export class OpenCodeSqliteWorkerClient {
* worker timeout/crash so the scanner records a per-session scan issue.
*/
async parse(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
agent?: 'opencode2'
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
try {
const value = await this.dispatch(
(id) => ({
id,
kind: 'parse',
...(args.fullFirstUserPrompt ? { fullFirstUserPrompt: true } : {}),
dbPath: args.dbPath,
sessionId: args.sessionId,
platform: args.platform,
...(args.agent ? { agent: args.agent } : {})
}),
PARSE_TIMEOUT_MS
PARSE_TIMEOUT_MS,
args.signal
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
return value as AiVaultSession | null
@@ -185,6 +202,7 @@ export class OpenCodeSqliteWorkerClient {
sessionId: string
platform: NodeJS.Platform
agent?: 'opencode2'
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
try {
const value = await this.dispatch(
@@ -196,7 +214,8 @@ export class OpenCodeSqliteWorkerClient {
platform: args.platform,
...(args.agent ? { agent: args.agent } : {})
}),
CAPTURE_TIMEOUT_MS
CAPTURE_TIMEOUT_MS,
args.signal
)
return parseOpenCodeSqliteCaptureValue(value)
} catch (err) {
@@ -204,11 +223,21 @@ export class OpenCodeSqliteWorkerClient {
}
}
dispose(): void {
this.requests.dispose()
}
private async dispatch(
buildRequest: (id: number) => OpenCodeSqliteWorkerRequest,
timeoutMs: number
timeoutMs: number,
signal?: AbortSignal
): Promise<unknown> {
const response = await this.requests.dispatch(buildRequest, timeoutMs)
const deadline = this.requestTimeoutMs ?? timeoutMs
const response = await this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
signal
)
if (!response.ok) {
throw new Error(response.error)
}
@@ -1,75 +1,14 @@
import { parentPort } from 'node:worker_threads'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list'
import {
captureOpenCode2SqliteSession,
parseOpenCode2SqliteSession
} from './session-scanner-opencode2-sqlite'
import type {
OpenCodeSqliteWorkerRequest,
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
// Why (#8864): OpenCode SQLite reads use synchronous node:sqlite. Running them
// on this worker thread keeps the multi-GB-DB scan off the Electron main-process
// event loop. The client dispatches one request at a time, so this loop stays
// serial; imports must remain electron-free (see the worker-protocol note).
import type { OpenCodeSqliteWorkerRequest } from './session-scanner-opencode-sqlite-worker-protocol'
import { handleOpenCodeSqliteRequest } from './session-scanner-opencode-sqlite-dispatch'
if (!parentPort) {
throw new Error('OpenCode SQLite worker must run with a parent port.')
}
const port = parentPort
async function handleRequest(
request: OpenCodeSqliteWorkerRequest
): Promise<OpenCodeSqliteWorkerResponse> {
try {
if (request.kind === 'list') {
const issues: AiVaultScanIssue[] = []
const candidates =
request.agent === 'opencode2'
? await listOpenCode2SqliteSessions({
dbPaths: request.dbPaths,
limit: request.limit,
issues
})
: await listOpenCodeSqliteSessions({
dbPaths: request.dbPaths,
limit: request.limit,
issues
})
return { id: request.id, ok: true, value: { candidates, issues } }
}
if (request.kind === 'capture') {
const capture =
request.agent === 'opencode2'
? await captureOpenCode2SqliteSession(request)
: await captureOpenCodeSqliteSession(request)
return { id: request.id, ok: true, value: capture }
}
const session =
request.agent === 'opencode2'
? await parseOpenCode2SqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
})
: await parseOpenCodeSqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
})
return { id: request.id, ok: true, value: session }
} catch (err) {
return { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
port.on('message', (request: OpenCodeSqliteWorkerRequest) => {
void handleRequest(request).then((response) => {
void handleOpenCodeSqliteRequest(request).then((response) => {
try {
port.postMessage(response)
} catch {
@@ -10,7 +10,7 @@ export type OpenCodeSqliteListRequest = {
id: number
kind: 'list'
dbPaths: readonly string[]
limit: number
limit: number | null
/** When 'opencode2', lists from the v2 channel-scoped DB schema (session_v2). */
agent?: 'opencode2'
}
@@ -18,6 +18,7 @@ export type OpenCodeSqliteListRequest = {
export type OpenCodeSqliteParseRequest = {
id: number
kind: 'parse'
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
@@ -38,10 +39,11 @@ export type OpenCodeSqliteCaptureRequest = {
agent?: 'opencode2'
}
export type OpenCodeSqliteWorkerRequest =
export type OpenCodeSqliteWorkerRequest = (
| OpenCodeSqliteListRequest
| OpenCodeSqliteParseRequest
| OpenCodeSqliteCaptureRequest
) & { timeoutMs?: number }
// The list leg returns candidates plus the issues it accumulated; the worker
// mutates a local array and hands it back so the caller can merge it into the
@@ -2,9 +2,19 @@ import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import type { SessionFileCandidate } from './session-scanner-types'
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import {
mapOpenCodeWslSession,
openCodeWslClient,
openCodeWslPath
} from './session-scanner-opencode-wsl-client'
// Why: resolve the built worker entry + own the process-wide shared client so
// the client class stays free of Electron (require'd lazily here) and the
@@ -53,8 +63,9 @@ export function listOpenCodeSqliteSessionsViaWorker(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
return getSharedClient().list(args)
return listForHost(args)
}
/**
@@ -65,8 +76,9 @@ export function listOpenCode2SqliteSessionsViaWorker(args: {
dbPaths: readonly string[]
limit: number
issues: AiVaultScanIssue[]
signal?: AbortSignal
}): Promise<SessionFileCandidate[]> {
return getSharedClient().list({ ...args, agent: 'opencode2' })
return listForHost({ ...args, agent: 'opencode2' })
}
/**
@@ -77,19 +89,23 @@ export function listOpenCode2SqliteSessionsViaWorker(args: {
* @returns The parsed session, or `null` when it does not exist.
*/
export function parseOpenCodeSqliteSessionViaWorker(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return getSharedClient().parse(args)
return parseForHost(args)
}
export function parseOpenCode2SqliteSessionViaWorker(args: {
fullFirstUserPrompt?: boolean
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return getSharedClient().parse({ ...args, agent: 'opencode2' })
return parseForHost({ ...args, agent: 'opencode2' })
}
/**
@@ -104,14 +120,100 @@ export function captureOpenCodeSqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
return getSharedClient().capture(args)
return captureForHost(args)
}
export function captureOpenCode2SqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
signal?: AbortSignal
}): Promise<OpenCodeSqliteCaptureValue> {
return getSharedClient().capture({ ...args, agent: 'opencode2' })
return captureForHost({ ...args, agent: 'opencode2' })
}
async function listForHost(
args: Parameters<OpenCodeSqliteWorkerClient['list']>[0]
): Promise<SessionFileCandidate[]> {
throwIfSignalAborted(args.signal)
const native: string[] = []
const groups = new Map<string, { distro: string; paths: Map<string, string> }>()
for (const path of args.dbPaths) {
const wsl = openCodeWslPath(path)
if (!wsl) {
native.push(path)
continue
}
const key = wsl.distro.toLowerCase()
const group = groups.get(key) ?? { distro: wsl.distro, paths: new Map<string, string>() }
group.paths.set(wsl.linuxPath, path)
groups.set(key, group)
}
const candidates = await Promise.all([
native.length ? getSharedClient().list({ ...args, dbPaths: native }) : Promise.resolve([]),
...[...groups.values()].map(async ({ distro, paths }) => {
const first = paths.values().next().value!
const issues: AiVaultScanIssue[] = []
try {
const client = await openCodeWslClient(distro, first, args.signal)
const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues })
return result.flatMap((candidate) => {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
const original = parsed && paths.get(parsed.dbPath)
return parsed && original
? [
{
...candidate,
file: {
...candidate.file,
path: buildOpenCodeSqliteCandidatePath(original, parsed.sessionId)
}
}
]
: []
})
} catch (error) {
throwIfSignalAborted(args.signal)
issues.push({
agent: args.agent ?? 'opencode',
kind: 'scope',
path: first,
message: error instanceof Error ? error.message : String(error)
})
return []
} finally {
args.issues.push(
...issues.map((issue) => ({ ...issue, path: paths.get(issue.path) ?? issue.path }))
)
}
})
])
throwIfSignalAborted(args.signal)
return candidates.flat().sort((a, b) => b.file.mtimeMs - a.file.mtimeMs)
}
async function parseForHost(
args: Parameters<OpenCodeSqliteWorkerClient['parse']>[0]
): Promise<AiVaultSession | null> {
const wsl = openCodeWslPath(args.dbPath)
if (!wsl) {
return getSharedClient().parse(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
return mapOpenCodeWslSession(session, args.dbPath)
}
async function captureForHost(
args: Parameters<OpenCodeSqliteWorkerClient['capture']>[0]
): Promise<OpenCodeSqliteCaptureValue> {
const wsl = openCodeWslPath(args.dbPath)
if (!wsl) {
return getSharedClient().capture(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
import type { OpenCodeSqliteProcessOptions } from './session-scanner-opencode-sqlite-process-client'
const mocks = vi.hoisted(() => ({
running: vi.fn(async (paths: readonly string[]) => [...paths]),
create: vi.fn((_options: OpenCodeSqliteProcessOptions) => ({ dispose: vi.fn() }))
}))
vi.mock('../wsl-running-path-filter', () => ({
filterPathsToRunningWslDistrosAsync: mocks.running
}))
vi.mock('./session-scanner-opencode-sqlite-process-client', () => ({
createOpenCodeSqliteProcessClient: mocks.create
}))
vi.mock('../wsl/wsl-executable-path', () => ({
resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe'
}))
vi.mock('../wsl-interop-spawn-directory', () => ({
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
}))
import {
configureOpenCodeWslReaders,
mapOpenCodeWslSession,
openCodeWslClient
} from './session-scanner-opencode-wsl-client'
const path = String.raw`\\wsl$\Ubuntu\home\ada\opencode.db`
const runtime = {
distro: 'Ubuntu',
executable: '/usr/bin/node',
readerPath: '/mnt/c/reader $literal.cjs'
}
beforeEach(() => {
configureOpenCodeWslReaders([])
vi.clearAllMocks()
mocks.running.mockImplementation(async (paths) => [...paths])
})
afterEach(() => {
configureOpenCodeWslReaders([])
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('WSL SQLite reader clients', () => {
it('reuses one client per distro and uses literal --exec argv with a safe host cwd and env', async () => {
vi.stubEnv('NODE_OPTIONS', '--require=host-loader')
vi.stubEnv('WSLENV', 'NODE_OPTIONS/u')
configureOpenCodeWslReaders([runtime])
const first = await openCodeWslClient('Ubuntu', path)
configureOpenCodeWslReaders([
{ readerPath: runtime.readerPath, executable: runtime.executable, distro: 'ubuntu' }
])
expect(await openCodeWslClient('ubuntu', path)).toBe(first)
expect(mocks.create).toHaveBeenCalledOnce()
expect(mocks.running).not.toHaveBeenCalled()
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({
args: ['-d', 'Ubuntu', '--exec', '/usr/bin/node', '/mnt/c/reader $literal.cjs'],
cwd: 'C:\\Windows'
})
)
const options = mocks.create.mock.calls[0]?.[0]
expect(options).toEqual(
expect.objectContaining({
env: expect.not.objectContaining({
NODE_OPTIONS: expect.anything(),
WSLENV: expect.anything()
})
})
)
})
it('checks confirmed running state at process creation and retires changed configurations', async () => {
configureOpenCodeWslReaders([runtime])
await openCodeWslClient('Ubuntu', path)
const first = mocks.create.mock.results[0]?.value
const admit = mocks.create.mock.calls[0]?.[0].beforeSpawn
expect(admit).toBeDefined()
await admit?.(new AbortController().signal)
expect(mocks.running).toHaveBeenCalledWith([path], { requireConfirmed: true })
mocks.running.mockResolvedValueOnce([])
await expect(admit?.(new AbortController().signal)).rejects.toThrow('not running')
configureOpenCodeWslReaders([{ ...runtime, executable: '/new/node' }])
expect(first?.dispose).toHaveBeenCalledOnce()
await openCodeWslClient('Ubuntu', path)
const second = mocks.create.mock.results[1]?.value
configureOpenCodeWslReaders([])
expect(second?.dispose).toHaveBeenCalledOnce()
})
it('cancels a running-distro probe before creating a child', async () => {
configureOpenCodeWslReaders([runtime])
await openCodeWslClient('Ubuntu', path)
mocks.running.mockReturnValue(new Promise(() => {}))
const controller = new AbortController()
const pending = mocks.create.mock.calls[0]?.[0].beforeSpawn?.(controller.signal)
controller.abort(new Error('cancelled probe'))
await expect(pending).rejects.toThrow('cancelled probe')
expect(mocks.create).toHaveBeenCalledOnce()
})
it('keeps unavailable readers unavailable until repaired configuration arrives', async () => {
configureOpenCodeWslReaders([{ distro: 'Ubuntu', error: 'Preparing' }])
await expect(openCodeWslClient('Ubuntu', path)).rejects.toThrow('Preparing')
expect(mocks.create).not.toHaveBeenCalled()
configureOpenCodeWslReaders([runtime])
await openCodeWslClient('Ubuntu', path)
expect(mocks.create).toHaveBeenCalledOnce()
})
it('restores the original database identity and distro cwd while retaining a Linux resume command', () => {
const accumulator = createAccumulator({
agent: 'opencode',
sessionId: 'session',
file: { path: '/home/ada/opencode.db', mtimeMs: 1, modifiedAt: new Date(1).toISOString() }
})
accumulator.cwd = '/home/ada/project $literal'
accumulator.title = 'A session'
const native = finalizeSession(accumulator, 'linux')
const mapped = mapOpenCodeWslSession(native, path)
expect(mapped).toMatchObject({
id: `local:opencode:session:${path}`,
filePath: path,
cwd: '/home/ada/project $literal',
executionHostPlatform: 'linux',
resumeCommand: "cd '/home/ada/project $literal' && opencode --session 'session'"
})
expect(mapOpenCodeWslSession(null, path)).toBeNull()
})
})
@@ -0,0 +1,93 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { waitForPromiseWithSignal, throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { buildWslExecArgs } from '../../shared/wsl-login-shell-command'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveWslExecutablePath } from '../wsl/wsl-executable-path'
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client'
import type { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
import { buildRelayAiVaultServiceEnv } from './session-scanner-service-env'
const runtimes = new Map<string, OpenCodeWslRuntime>()
const clients = new Map<string, OpenCodeSqliteWorkerClient>()
export function configureOpenCodeWslReaders(entries: readonly OpenCodeWslRuntime[]): void {
const present = new Set(entries.map((entry) => entry.distro.toLowerCase()))
for (const key of runtimes.keys()) {
if (!present.has(key)) {
clients.get(key)?.dispose()
clients.delete(key)
runtimes.delete(key)
}
}
for (const entry of entries) {
const key = entry.distro.toLowerCase()
const previous = runtimes.get(key)
if (
previous?.executable !== entry.executable ||
previous?.readerPath !== entry.readerPath ||
previous?.error !== entry.error
) {
clients.get(key)?.dispose()
clients.delete(key)
runtimes.set(key, entry)
}
}
}
export function openCodeWslPath(path: string): ReturnType<typeof parseWslUncPath> {
return process.platform === 'win32' ? parseWslUncPath(path) : null
}
export async function openCodeWslClient(
distro: string,
dbPath: string,
signal?: AbortSignal
): Promise<OpenCodeSqliteWorkerClient> {
throwIfSignalAborted(signal)
const key = distro.toLowerCase()
const runtime = runtimes.get(key)
if (!runtime || runtime.error !== undefined) {
throw new Error(
runtime?.error ?? 'The WSL SQLite reader is not prepared. Refresh Vault to retry.'
)
}
let client = clients.get(key)
if (!client) {
client = createOpenCodeSqliteProcessClient({
executable: resolveWslExecutablePath(),
args: buildWslExecArgs(distro, [runtime.executable, runtime.readerPath]),
cwd: resolveWslInteropSpawnCwd(),
env: { ...buildRelayAiVaultServiceEnv(), WSL_UTF8: '1' },
async beforeSpawn(spawnSignal) {
const running = await waitForPromiseWithSignal(
filterPathsToRunningWslDistrosAsync([dbPath], { requireConfirmed: true }),
spawnSignal
)
if (running.length === 0) {
throw new Error(`WSL distro ${distro} is not running. Start it to read its history.`)
}
}
})
clients.set(key, client)
}
return client
}
/** Keep database reads Windows-addressable and the working directory native to its host. */
export function mapOpenCodeWslSession(
session: AiVaultSession | null,
dbPath: string
): AiVaultSession | null {
if (!session) {
return null
}
return {
...session,
id: `${session.executionHostId}:${session.agent}:${session.sessionId}:${dbPath}`,
filePath: dbPath,
executionHostPlatform: 'linux'
}
}
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
import type { SessionFileCandidate } from './session-scanner-types'
import type * as wslClientModule from './session-scanner-opencode-wsl-client'
const mocks = vi.hoisted(() => ({
native: {
list: vi.fn(async (): Promise<SessionFileCandidate[]> => []),
parse: vi.fn(async () => null),
capture: vi.fn(async () => ({ session: null, messages: [] }))
},
guest: vi.fn()
}))
vi.mock('./session-scanner-opencode-sqlite-worker-client', () => ({
OpenCodeSqliteWorkerClient: class {
list = mocks.native.list
parse = mocks.native.parse
capture = mocks.native.capture
}
}))
vi.mock('./session-scanner-opencode-wsl-client', async (importOriginal) => ({
...(await importOriginal<typeof wslClientModule>()),
openCodeWslClient: mocks.guest
}))
import {
captureOpenCode2SqliteSessionViaWorker,
captureOpenCodeSqliteSessionViaWorker,
listOpenCode2SqliteSessionsViaWorker,
listOpenCodeSqliteSessionsViaWorker,
parseOpenCode2SqliteSessionViaWorker,
parseOpenCodeSqliteSessionViaWorker
} from './session-scanner-opencode-sqlite-worker-spawn'
const ubuntu = '//wsl.localhost/Ubuntu/home/ada/opencode.db'
const debian = '//wsl$/Debian/home/ada/opencode.db'
const native = 'C:/Users/ada/opencode.db'
const guest = '/home/ada/opencode.db'
function row(path: string) {
return {
agent: 'opencode' as const,
codexHome: null,
file: { path: `${path}#same-session`, mtimeMs: 1, modifiedAt: new Date(1).toISOString() }
}
}
function session() {
const accumulator = createAccumulator({
agent: 'opencode',
sessionId: 'same-session',
file: row(guest).file
})
accumulator.title = 'Session'
accumulator.cwd = '/home/ada/repo'
return finalizeSession(accumulator, 'linux')
}
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
})
afterEach(() => vi.restoreAllMocks())
describe('OpenCode SQLite execution-host routes', () => {
it('separates native and distro databases and preserves equal IDs in different distros', async () => {
const list = vi.fn(async (args) => [row(args.dbPaths[0])])
mocks.guest.mockResolvedValue({ list })
mocks.native.list.mockResolvedValueOnce([row(native)])
const issues: AiVaultScanIssue[] = []
const result = await listOpenCodeSqliteSessionsViaWorker({
dbPaths: [native, ubuntu, debian],
limit: 2,
issues
})
expect(result.map((entry) => entry.file.path)).toEqual([
`${native}#same-session`,
`${ubuntu}#same-session`,
`${debian}#same-session`
])
expect(mocks.native.list).toHaveBeenCalledWith(expect.objectContaining({ dbPaths: [native] }))
expect(list).toHaveBeenCalledTimes(2)
expect(list.mock.calls.every(([args]) => args.dbPaths[0] === guest)).toBe(true)
expect(mocks.guest).toHaveBeenNthCalledWith(1, 'Ubuntu', ubuntu, undefined)
expect(mocks.guest).toHaveBeenNthCalledWith(2, 'Debian', debian, undefined)
expect(issues).toEqual([])
})
it('maps guest list issues back to the original database and omits unavailable distros', async () => {
mocks.guest
.mockResolvedValueOnce({
list: vi.fn(async (args) => {
args.issues.push({ agent: 'opencode2', path: guest, message: 'locked' })
return []
})
})
.mockRejectedValueOnce(new Error('Distro stopped'))
const issues: AiVaultScanIssue[] = []
await listOpenCode2SqliteSessionsViaWorker({ dbPaths: [ubuntu, debian], limit: 2, issues })
expect(issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: ubuntu, message: 'locked' }),
expect.objectContaining({ path: debian, kind: 'scope', message: 'Distro stopped' })
])
)
expect(mocks.native.list).not.toHaveBeenCalled()
})
it.each([
['v1', parseOpenCodeSqliteSessionViaWorker, captureOpenCodeSqliteSessionViaWorker, undefined],
[
'v2',
parseOpenCode2SqliteSessionViaWorker,
captureOpenCode2SqliteSessionViaWorker,
'opencode2'
]
] as const)(
'routes %s parse/capture to Linux and restores the caller identity',
async (_label, parse, capture, agent) => {
const parsed = session()
const messages = [{ role: 'user' as const, text: 'Complete transcript', timestamp: null }]
const reader = {
parse: vi.fn(async () => parsed),
capture: vi.fn(async () => ({ session: parsed, messages }))
}
mocks.guest.mockResolvedValue(reader)
const controller = new AbortController()
const args = {
dbPath: ubuntu,
sessionId: 'same-session',
platform: 'win32' as const,
signal: controller.signal
}
expect(await parse({ ...args, fullFirstUserPrompt: true })).toMatchObject({
id: `local:opencode:same-session:${ubuntu}`,
filePath: ubuntu,
cwd: '/home/ada/repo',
executionHostPlatform: 'linux'
})
expect(reader.parse).toHaveBeenCalledWith({
...args,
dbPath: guest,
platform: 'linux',
fullFirstUserPrompt: true,
...(agent ? { agent } : {})
})
expect(await capture(args)).toMatchObject({ session: { filePath: ubuntu }, messages })
expect(reader.capture).toHaveBeenCalledWith({
...args,
dbPath: guest,
platform: 'linux',
...(agent ? { agent } : {})
})
expect(mocks.native.parse).not.toHaveBeenCalled()
expect(mocks.native.capture).not.toHaveBeenCalled()
}
)
it('propagates cancellation instead of returning an empty successful list', async () => {
const controller = new AbortController()
mocks.guest.mockImplementationOnce(async () => {
controller.abort(new Error('cancelled'))
throw new Error('cancelled')
})
await expect(
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [ubuntu],
limit: 1,
issues: [],
signal: controller.signal
})
).rejects.toThrow('cancelled')
expect(mocks.native.list).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,4 @@
export type OpenCodeWslRuntime = { distro: string } & (
| { executable: string; readerPath: string; error?: never }
| { error: string; executable?: never; readerPath?: never }
)
@@ -114,12 +114,13 @@ export async function parseAgentSessionFileCached(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats,
requireRead?: SessionParseReadRequirement
requireRead?: SessionParseReadRequirement,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
// The whole lookup-read-store sequence runs in the lane: a concurrent parse of
// the same path shares this entry's resume point and its message channel.
return inSessionParseFileLane(candidate.file.path, () =>
parseCachedInLane(candidate, platform, stats, requireRead)
parseCachedInLane(candidate, platform, stats, requireRead, signal)
)
}
@@ -162,7 +163,8 @@ async function parseCachedInLane(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats,
requireRead?: SessionParseReadRequirement
requireRead?: SessionParseReadRequirement,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
const { file } = candidate
if (
@@ -216,7 +218,7 @@ async function parseCachedInLane(
return enriched.session
}
const session = await readWholeTranscript({ candidate, platform, stats })
const session = await readWholeTranscript({ candidate, platform, stats, signal })
// Whole-file agents merge the sibling here just like the resumable branch
// does post-read; the raw fold stays in foldSession so a sibling-only change
// re-merges without re-reading the transcript.
@@ -150,5 +150,17 @@ it('carries OMP root/profile inputs only to the desktop service, retaining empty
XDG_DATA_HOME: '/home/dev/data'
}
expect(buildAiVaultServiceEnv(roots, 'linux')).toEqual({ ...roots, ELECTRON_RUN_AS_NODE: '1' })
expect(buildRelayAiVaultServiceEnv(roots, 'linux')).toEqual({})
expect(buildRelayAiVaultServiceEnv(roots, 'linux')).toEqual({ XDG_DATA_HOME: '/home/dev/data' })
})
it('preserves execution-host OpenCode roots in the relay service', () => {
expect(
buildRelayAiVaultServiceEnv(
{ XDG_DATA_HOME: '/srv/data', OPENCODE_DB: 'opencode-team.db' },
'linux'
)
).toEqual({
XDG_DATA_HOME: '/srv/data',
OPENCODE_DB: 'opencode-team.db'
})
})
@@ -97,10 +97,14 @@ export function buildAiVaultServiceEnv(
return env
}
/** Relay: the sidecar takes every root from its init message, not the environment. */
/** Relay: home comes from init; OpenCode also supports host-side data overrides. */
export function buildRelayAiVaultServiceEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform)
return pickAllowedEnv(
[...RUNTIME_ENV_ALLOWLIST, 'XDG_DATA_HOME', 'OPENCODE_DB'],
baseEnv,
platform
)
}
@@ -5,6 +5,7 @@ import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import { antigravityDiscoveries } from './session-scanner-antigravity-sources'
import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources'
import { normalizedWslHomeDirs } from './session-scanner-roots'
import { configureOpenCodeWslReaders } from './session-scanner-opencode-wsl-client'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
export { DEFAULT_CODEX_HOME_DIR } from './session-scanner-agent-sources'
@@ -15,6 +16,9 @@ export async function discoverAiVaultSessionSources(args: {
issues: AiVaultScanIssue[]
}): Promise<SessionFileDiscovery[]> {
const { options, limitPerAgent, issues } = args
if (options.wslOpenCodeReaders) {
configureOpenCodeWslReaders(options.wslOpenCodeReaders)
}
const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs)
// The Cursor chat-meta scan scope is owned by scanAiVaultSessions: it has to
@@ -10,6 +10,7 @@ import type {
TranscriptSessionIdentity
} from './session-transcript-consumers'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
@@ -19,6 +20,7 @@ export type AiVaultScanOptions = {
// (codexHome null → unprefixed resume) is testable without the user's home.
defaultCodexHomeDir?: string
wslHomeDirs?: readonly string[]
wslOpenCodeReaders?: readonly OpenCodeWslRuntime[]
geminiSessionsDir?: string
antigravityBrainDir?: string
copilotSessionsDir?: string
@@ -1,5 +1,6 @@
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 } => ({
@@ -31,7 +32,8 @@ vi.mock('./remote-session-scanner-sources', () => ({ remoteSessionSources: () =>
vi.mock('./remote-session-scanner-discovery', () => ({
discoverRemoteSourceCandidates: async () => candidates()
}))
vi.mock('./remote-session-parse-cache', () => ({
vi.mock('./remote-session-parse-cache', async (original) => ({
...(await original<typeof RemoteSessionParseCache>()),
remoteSessionParseHostKey: () => 'fixture',
parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) =>
candidate.session
+11 -3
View File
@@ -240,7 +240,8 @@ async function parseSessionCandidates(args: {
args.platform,
args.executionHostId,
args.parseStats,
args.antigravityWorkspaceResolver
args.antigravityWorkspaceResolver,
args.signal
)
)
)
@@ -268,10 +269,17 @@ async function parseSessionCandidate(
platform: NodeJS.Platform,
executionHostId: ExecutionHostId,
parseStats: SessionParseStats,
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver,
signal?: AbortSignal
): Promise<SessionParseResult> {
try {
let session = await parseAgentSessionFileCached(candidate, platform, parseStats)
let session = await parseAgentSessionFileCached(
candidate,
platform,
parseStats,
undefined,
signal
)
if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) {
session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath)
}
@@ -160,6 +160,7 @@ export async function readWholeTranscript(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
stats?: TranscriptReadStats
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
if (args.stats) {
@@ -169,7 +170,7 @@ export async function readWholeTranscript(args: {
const channel = new TranscriptMessageChannel()
channel.beginRead({ candidate: args.candidate, mode: 'replace', previousByteOffset: 0 })
try {
const session = await parseAgentSessionFile(args.candidate, args.platform, channel)
const session = await parseAgentSessionFile(args.candidate, args.platform, channel, args.signal)
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: false })
return session
} catch (error) {
+10 -4
View File
@@ -1,6 +1,12 @@
import type { Worker } from 'node:worker_threads'
export type WorkerThreadFactory = () => Worker
export type WorkerRequestTransport = Pick<Worker, 'unref' | 'postMessage' | 'terminate'> & {
on(...args: Parameters<Worker['on']>): unknown
off(...args: Parameters<Worker['off']>): unknown
removeAllListeners(): unknown
}
export type WorkerThreadFactory = () => WorkerRequestTransport
/**
* Owns the lifetime of one lazily-spawned worker thread: spawn on demand,
@@ -10,7 +16,7 @@ export type WorkerThreadFactory = () => Worker
* queued calls closed instead of moving the work back onto the main thread.
*/
export class LazyWorkerThreadHost<TResponse> {
private worker: Worker | null = null
private worker: WorkerRequestTransport | null = null
private idleTimer: NodeJS.Timeout | null = null
private cleanupListeners: (() => void) | null = null
private reportedUnavailable = false
@@ -29,12 +35,12 @@ export class LazyWorkerThreadHost<TResponse> {
}
) {}
get current(): Worker | null {
get current(): WorkerRequestTransport | null {
return this.worker
}
/** The live worker, spawning one if needed; null when no worker can be had. */
ensure(): Worker | null {
ensure(): WorkerRequestTransport | null {
if (this.worker) {
return this.worker
}
@@ -35,6 +35,32 @@ describe('deployment C library selection', () => {
).rejects.toThrow('Could not identify')
})
it('checks connection ownership again before the fallback probe', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
const generation = conn.getConnectGeneration()
const firstProbe = Promise.withResolvers<string>()
vi.mocked(execCommand).mockReturnValueOnce(firstProbe.promise)
const exec = vi.fn(async (command: string) => {
if (conn.getConnectGeneration() !== generation) {
throw new Error('SSH connection changed during SQLite runtime setup.')
}
return execCommand(conn, command)
})
const pending = resolveOrcadDeploymentTarget({
conn,
host: getRemoteHostPlatform('linux-x64'),
exec
})
expect(execCommand).toHaveBeenCalledOnce()
await conn.disconnect()
firstProbe.resolve('ldd: not found')
await expect(pending).rejects.toThrow('SSH connection changed')
expect(exec).toHaveBeenLastCalledWith(expect.stringContaining('getconf GNU_LIBC_VERSION'))
expect(execCommand).toHaveBeenCalledOnce()
})
it.each([
['ldd (Ubuntu GLIBC 2.31-0ubuntu9) 2.31', 'glibc'],
['ldd (GNU libc) 2.28', 'glibc'],
+7 -7
View File
@@ -17,22 +17,22 @@ export async function resolveOrcadDeploymentTarget(options: {
conn: SshConnection
host: RemoteHostPlatform
signal?: AbortSignal
exec?: (command: string) => Promise<string>
}): Promise<OrcadBunTarget> {
const { host } = options
if (host.os !== 'linux') {
return `${host.os}-${host.arch}`
}
let output = await execCommand(options.conn, 'ldd --version 2>&1 || true', {
signal: options.signal
})
const exec =
options.exec ??
((command: string) => execCommand(options.conn, command, { signal: options.signal }))
let output = await exec('ldd --version 2>&1 || true')
try {
return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}`
} catch {
output = await execCommand(
options.conn,
output = await exec(
'getconf GNU_LIBC_VERSION 2>/dev/null || ' +
'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done',
{ signal: options.signal }
'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done'
)
}
return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}`
+18
View File
@@ -0,0 +1,18 @@
import { join } from 'node:path'
import type { RelayPlatform } from './relay-protocol'
export function relayBundleCandidates(platform: RelayPlatform, appPath: string): string[] {
return [
...new Set([
...(process.env.ORCA_RELAY_PATH ? [join(process.env.ORCA_RELAY_PATH, platform)] : []),
...(process.resourcesPath
? [
join(process.resourcesPath, 'relay', platform),
join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform)
]
: []),
join(appPath, 'resources', 'relay', platform),
join(appPath, 'out', 'relay', platform)
])
]
}
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
+3
View File
@@ -224,6 +224,9 @@ export class SshConnection {
getState(): SshConnectionState {
return { ...this.state }
}
getConnectGeneration(): number {
return this.connectGeneration
}
getClient(): SshClient | null {
return this.client
}
@@ -45,6 +45,10 @@ vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('not-needed')
}))
vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({
...(await importOriginal<typeof RelayInstallMarkerModule>()),
createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000'
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
@@ -64,6 +67,7 @@ vi.mock('./ssh-relay-superseded-endpoints', () => ({
sweepSupersededRelayEndpoints: vi.fn().mockResolvedValue([])
}))
import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints'
import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime'
import { gcOldRelayVersions } from './ssh-relay-versioned-install'
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
@@ -198,10 +202,18 @@ describe('deployAndLaunchRelay honours the incumbent verdict', () => {
onClose: vi.fn()
})
vi.mocked(sweepSupersededRelayEndpoints).mockRejectedValueOnce(error)
await deployAndLaunchRelay(makeMockConnection())
vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('not-needed')
const result = await deployAndLaunchRelay(makeMockConnection())
await vi.waitFor(() => expect(sweepSupersededRelayEndpoints).toHaveBeenCalledOnce())
await new Promise((resolve) => setImmediate(resolve))
expect(gcOldRelayVersions).toHaveBeenCalledTimes(expectedGcCalls)
const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 120_000)
try {
await result.prepareOpenCodeRuntime?.(new AbortController().signal)
expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledTimes(expectedGcCalls + 1)
} finally {
now.mockRestore()
}
}
)
})
@@ -52,6 +52,9 @@ vi.mock('./ssh-remote-node-resolution', () => ({
// Why: the post-launch ripgrep install is fire-and-forget and would drain the queued exec mocks.
// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks.
vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() }))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({
...(await importOriginal<typeof RelayRipgrepInstallModule>()),
ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'),
@@ -64,6 +64,9 @@ vi.mock('../ripgrep/bundled-ripgrep-path', () => ({
// Why: the fire-and-forget ripgrep install would drain the queued exec mocks.
// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks.
vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() }))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({
...(await importOriginal<typeof RelayRipgrepInstallModule>()),
ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'),
+41
View File
@@ -62,6 +62,9 @@ vi.mock('../ripgrep/bundled-ripgrep-path', () => ({
// Why: the fire-and-forget ripgrep install would drain the queued exec mocks.
// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks.
vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() }))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({
...(await importOriginal<typeof RelayRipgrepInstallModule>()),
ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'),
@@ -97,6 +100,7 @@ vi.mock('./ssh-connection-utils', () => ({
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install'
@@ -105,6 +109,7 @@ import {
ensureRemoteBundledRipgrep,
recordRemoteRipgrepReference
} from './ssh-relay-ripgrep-install'
import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc'
import * as DeployTiming from './ssh-relay-deploy-timing'
import type { SshConnection } from './ssh-connection'
import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution'
@@ -544,11 +549,33 @@ describe('deployAndLaunchRelay', () => {
await new Promise<void>((resolve) => setImmediate(resolve))
expect(execCommand).toHaveBeenCalledTimes(execCount)
expect(gcOldRelayVersions).not.toHaveBeenCalled()
expect(ensureRemoteOpenCodeRuntime).not.toHaveBeenCalled()
finishUpload()
await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce())
}
)
it('waits for SQLite runtime setup before cleanup on single-exec transports', async () => {
const conn = makeMockConnection()
vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false)
queueFreshLinuxDeploy()
let finishSetup!: () => void
vi.mocked(ensureRemoteOpenCodeRuntime).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishSetup = () => resolve('failed')
})
)
await deployAndLaunchRelay(conn)
await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce())
const execCount = vi.mocked(execCommand).mock.calls.length
await new Promise<void>((resolve) => setImmediate(resolve))
expect(execCommand).toHaveBeenCalledTimes(execCount)
expect(gcOldRelayVersions).not.toHaveBeenCalled()
finishSetup()
await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce())
})
it('does not launch or upload an unprotected binary when recording its reference fails', async () => {
const conn = makeMockConnection()
queueFreshLinuxDeploy()
@@ -558,6 +585,20 @@ describe('deployAndLaunchRelay', () => {
expect(ensureRemoteBundledRipgrep).not.toHaveBeenCalled()
})
it('skips cleanup when SQLite setup cannot confirm command teardown on a single-exec transport', async () => {
const conn = makeMockConnection()
vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false)
queueFreshLinuxDeploy()
vi.mocked(ensureRemoteOpenCodeRuntime).mockResolvedValueOnce('teardown-unconfirmed')
await deployAndLaunchRelay(conn)
await vi.waitFor(() => expect(ensureRemoteOpenCodeRuntime).toHaveBeenCalledOnce())
const execCount = vi.mocked(execCommand).mock.calls.length
await new Promise<void>((resolve) => setImmediate(resolve))
expect(execCommand).toHaveBeenCalledTimes(execCount)
expect(gcOldRelayVersions).not.toHaveBeenCalled()
expect(gcRemoteRipgrepCache).not.toHaveBeenCalled()
})
it('allows an unlimited SSH disconnect grace window', async () => {
const conn = makeMockConnection()
queueFreshLinuxDeploy()
+94 -65
View File
@@ -1,7 +1,7 @@
import { join } from 'node:path'
/* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */
import { existsSync } from 'node:fs'
import { app } from 'electron'
import { relayBundleCandidates } from './relay-bundle-paths'
import type { SshConnection } from './ssh-connection'
import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
@@ -30,6 +30,11 @@ import {
recordRemoteRipgrepReference
} from './ssh-relay-ripgrep-install'
import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc'
import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime'
import {
createRemoteOpenCodeRuntimeRetry,
type RemoteOpenCodeRuntimePreparation
} from './ssh-relay-opencode-runtime-retry'
import {
readLocalFullVersion,
computeRemoteRelayDir,
@@ -131,6 +136,7 @@ export type RelayDeployResult = {
nodePath?: string
sockPath?: string
credentialFile?: string
prepareOpenCodeRuntime?: RemoteOpenCodeRuntimePreparation
}
class RelayDirectoryGcConflictError extends Error {
@@ -624,59 +630,89 @@ async function deployAndLaunchRelayAttempt(
? ensureRemoteBundledRipgrep(conn, hostPlatform, remoteHome, { signal: deploySignal })
: Promise.resolve()
).catch(() => {})
const cleanupReady = conn.canRunConcurrentExecCommands() ? Promise.resolve() : ripgrepInstall
void cleanupReady
const runtimeInstall = (conn.canRunConcurrentExecCommands() ? Promise.resolve() : ripgrepInstall)
.then(() =>
ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, {
nodePath: launched.nodePath,
relayDir: remoteRelayDir,
signal: deploySignal
})
)
.catch(() => 'teardown-unconfirmed' as const)
const cleanupReady = conn.canRunConcurrentExecCommands() ? Promise.resolve() : runtimeInstall
const backgroundCleanup = cleanupReady.then((runtimeOutcome) => {
if (runtimeOutcome === 'teardown-unconfirmed') {
return false
}
return (
execHostCommand(
conn,
hostPlatform,
recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir)
)
)
.catch(() => {})
// Why before GC: a superseded relay pins its version dir via the live-socket probe, so the
// sweep has to settle first or GC keeps every orphan's tree forever.
.then(() =>
sweepSupersededRelayEndpoints(conn, hostPlatform, {
remoteHome,
currentRelayDir: remoteRelayDir,
sockName: relaySocketNameForInstanceId(relayInstanceId),
// Set only when this launch relocated past sun_path; the sweep must not reap
// the socket the transport it just handed back is talking to.
...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX)
? {
currentShortSocketDir: launched.sockPath.slice(0, launched.sockPath.lastIndexOf('/'))
}
: {}),
nodePath: launched.nodePath
})
)
.catch((error) => {
if (error instanceof RelayProbeCleanupUnconfirmedError) {
throw error
}
})
.then(() =>
gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, {
windowsNodePath: launched.nodePath,
windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)],
// Why pin rather than rely on the symlink alone: a deploy that fell back to a
// per-directory install has no reference to show, and its key must still survive.
nativeDepsCacheKeys: [
resolveRelayNativeDepsCacheKey({
platform,
localRelayDir,
deps: RELAY_NATIVE_DEPS
.catch((error) => {
if (isUnconfirmedSshCommandTermination(error)) {
throw error
}
})
// Why before GC: a superseded relay pins its version dir via the live-socket probe, so the
// sweep has to settle first or GC keeps every orphan's tree forever.
.then(() =>
sweepSupersededRelayEndpoints(conn, hostPlatform, {
remoteHome,
currentRelayDir: remoteRelayDir,
sockName: relaySocketNameForInstanceId(relayInstanceId),
// Set only when this launch relocated past sun_path; the sweep must not reap
// the socket the transport it just handed back is talking to.
...(launched.sockPath.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX)
? {
currentShortSocketDir: launched.sockPath.slice(
0,
launched.sockPath.lastIndexOf('/')
)
}
: {}),
nodePath: launched.nodePath
})
].filter((key): key is string => key !== null)
})
)
.catch((error) => {
if (
error instanceof RelayProbeCleanupUnconfirmedError ||
isUnconfirmedSshCommandTermination(error)
) {
throw error
}
})
.then(() =>
gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, {
windowsNodePath: launched.nodePath,
windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)],
// Why pin rather than rely on the symlink alone: a deploy that fell back to a
// per-directory install has no reference to show, and its key must still survive.
nativeDepsCacheKeys: [
resolveRelayNativeDepsCacheKey({
platform,
localRelayDir,
deps: RELAY_NATIVE_DEPS
})
].filter((key): key is string => key !== null)
})
)
// Why after the version GC and not beside it: that pass is what removes the relay directories
// holding the references, so running second is what lets a superseded build become collectable
// in the same connect rather than the next one.
.then(() =>
gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry })
)
.then(() => true)
.catch(
(error) =>
!(error instanceof RelayProbeCleanupUnconfirmedError) &&
!isUnconfirmedSshCommandTermination(error)
)
)
// Why after the version GC and not beside it: that pass is what removes the relay directories
// holding the references, so running second is what lets a superseded build become collectable
// in the same connect rather than the next one.
.then(() => gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry }))
.catch(() => {})
})
return {
transport: launched.transport,
@@ -687,7 +723,17 @@ async function deployAndLaunchRelayAttempt(
remoteRelayDir,
nodePath: launched.nodePath,
sockPath: launched.sockPath,
credentialFile: launched.credentialFile
credentialFile: launched.credentialFile,
prepareOpenCodeRuntime: createRemoteOpenCodeRuntimeRetry(
runtimeInstall,
backgroundCleanup,
(signal) =>
ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, {
nodePath: launched.nodePath,
relayDir: remoteRelayDir,
signal
})
)
}
}
@@ -1685,24 +1731,7 @@ function getLocalRelayPath(platform: RelayPlatform): string | null {
}
export function getLocalRelayCandidates(platform: RelayPlatform): string[] {
const candidates: string[] = []
if (process.env.ORCA_RELAY_PATH) {
candidates.push(join(process.env.ORCA_RELAY_PATH, platform))
}
// Why: electron-builder copies extraResources next to the app bundle, but app.getAppPath() points at app.asar in packaged builds.
if (process.resourcesPath) {
candidates.push(join(process.resourcesPath, 'relay', platform))
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform))
}
const appPath = app.getAppPath()
candidates.push(
join(appPath, 'resources', 'relay', platform),
join(appPath, 'out', 'relay', platform)
)
return [...new Set(candidates)]
return relayBundleCandidates(platform, app.getAppPath())
}
async function launchRelay(
@@ -35,6 +35,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn()
}))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
@@ -46,6 +46,9 @@ vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({
// Why: the post-launch ripgrep install would consume this file's queued exec mocks.
// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks.
vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() }))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({
...(await importOriginal<typeof RelayRipgrepInstallModule>()),
ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'),
@@ -35,6 +35,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn()
}))
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
@@ -6,6 +6,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RelayInstallMarkerModule from './ssh-relay-install-marker'
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
@@ -80,7 +83,7 @@ vi.mock('./ssh-connection-utils', () => ({
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { parseUnameToRelayPlatform } from './relay-protocol'
import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import { gcOldRelayVersions, isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import {
makeMockConnection,
@@ -221,6 +224,7 @@ describe('spawn-time node-pty repair through the locked deploy path', () => {
feed(repairSucceedsResponses())
const deploys = { count: 0 }
await recover(conn, deploys)
await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalledOnce())
vi.mocked(execCommand).mockReset().mockResolvedValue('')
const second = await recover(conn, deploys)
@@ -0,0 +1,274 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { decodeRemotePowerShellScript } from './ssh-remote-powershell'
import {
parseOpenCodeRuntimeResult,
probeOpenCodeRuntimeCacheCommand,
probeOpenCodeNodeSqliteCommand,
promoteOpenCodeRuntimeCommand,
publishOpenCodeRuntimeReferenceCommand
} from './ssh-relay-opencode-runtime-commands'
import {
cleanupOwnedRelayUploadStageCommand,
parseReservedRelayUploadStage,
recoverOneStaleRelayUploadStageCommand,
reserveRelayUploadStageCommand
} from './ssh-relay-upload-stage-commands'
const host = getRemoteHostPlatform('linux-x64')
const nodePath = process.execPath
const directories: string[] = []
const expectedHash = createHash('sha256').update('verified runtime').digest('hex')
const markerName = '.sftp-namespace-0123456789abcdef0123456789abcdef'
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
)
})
async function directory(): Promise<string> {
const result = await mkdtemp(join(tmpdir(), "orca-runtime spaces ' $-"))
directories.push(result)
return result
}
async function command(text: string, environment: NodeJS.ProcessEnv = {}) {
return runProcess({
program: 'sh',
args: ['-c', text],
timeoutMs: 10_000,
env: { ...process.env, OPENCODE_DB: '', XDG_DATA_HOME: '', ...environment }
})
}
async function reserveStage(root: string) {
const pool = join(root, '.upload-stages')
const result = await command(reserveRelayUploadStageCommand(host, pool, markerName))
expect(result.code, result.stderr).toBe(0)
return parseReservedRelayUploadStage(host, pool, markerName, result.stdout)
}
describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands', () => {
it('runs an actual SQLite read and identifies the executable', async () => {
const home = await directory()
const data = join(home, '.local', 'share', 'opencode')
await mkdir(data, { recursive: true })
await writeFile(join(data, 'opencode.db'), '')
const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home))
expect(result.code).toBe(0)
expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({
status: 'ready',
executable: nodePath
})
})
it('stages, verifies by bytes, promotes and atomically publishes under quoted paths', async () => {
const root = await directory()
const stage = await reserveStage(root)
const stageDir = stage.slotDir
const executable = join(root, expectedHash, 'bun')
const prepared = await command(
probeOpenCodeRuntimeCacheCommand({
host,
nodePath,
executable,
expectedHash,
reference: join(root, 'runtime.json')
})
)
expect(parseOpenCodeRuntimeResult(prepared.stdout).status).toBe('missing')
expect((await stat(join(stageDir, markerName))).isFile()).toBe(true)
const stagedBinary = join(stageDir, 'payload', 'bun')
await writeFile(stagedBinary, 'verified runtime')
const promoted = await command(
promoteOpenCodeRuntimeCommand({
host,
nodePath,
stagedBinary,
executable,
expectedHash,
repairToken: 'repair'
})
)
expect(parseOpenCodeRuntimeResult(promoted.stdout)).toEqual({ status: 'ready', executable })
expect(await readFile(executable, 'utf8')).toBe('verified runtime')
const reference = join(root, 'opencode-sqlite-runtime.json')
await writeFile(reference, '{"old":true}')
const stagedReference = join(stageDir, 'payload', 'ref.json')
await writeFile(stagedReference, JSON.stringify({ protocol: 1, executable }))
const published = await command(
publishOpenCodeRuntimeReferenceCommand({
host,
nodePath,
stagedReference,
reference,
token: 'one'
})
)
expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published')
expect(JSON.parse(await readFile(reference, 'utf8'))).toEqual({ protocol: 1, executable })
await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName))
await expect(stat(stageDir)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(executable, 'utf8')).toBe('verified runtime')
})
it('refuses equal-sized corrupt uploads instead of accepting a size match', async () => {
const root = await directory()
const stagedBinary = join(root, 'source')
const executable = join(root, 'installed', 'bun')
await writeFile(stagedBinary, 'corrupt! runtime')
expect((await stat(stagedBinary)).size).toBe(Buffer.byteLength('verified runtime'))
const result = await command(
promoteOpenCodeRuntimeCommand({
host,
nodePath,
stagedBinary,
executable,
expectedHash,
repairToken: 'one'
})
)
expect(result.code).not.toBe(0)
expect(result.stderr).toContain('checksum mismatch')
await expect(stat(executable)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('preserves an existing corrupt binary and reuses its verified repair reference', async () => {
const root = await directory()
const executable = join(root, expectedHash, 'bun')
await mkdir(join(root, expectedHash))
await writeFile(executable, 'old binary still owned by another process')
const stagedBinary = join(root, 'source')
await writeFile(stagedBinary, 'verified runtime')
const promoted = await command(
promoteOpenCodeRuntimeCommand({
host,
nodePath,
stagedBinary,
executable,
expectedHash,
repairToken: 'two'
})
)
const repaired = join(root, expectedHash, 'repair-two', 'bun')
expect(parseOpenCodeRuntimeResult(promoted.stdout).executable).toBe(repaired)
expect(await readFile(executable, 'utf8')).toBe('old binary still owned by another process')
const reference = join(root, 'runtime.json')
await writeFile(reference, JSON.stringify({ protocol: 1, executable: repaired }))
const prepared = await command(
probeOpenCodeRuntimeCacheCommand({
host,
nodePath,
executable,
expectedHash,
reference
})
)
expect(parseOpenCodeRuntimeResult(prepared.stdout)).toEqual({
status: 'ready',
executable: repaired
})
})
it('defers an empty host, honors database overrides, and ignores in-memory databases', async () => {
const home = await directory()
const probe = probeOpenCodeNodeSqliteCommand(host, nodePath, home)
expect(parseOpenCodeRuntimeResult((await command(probe)).stdout).status).toBe('not-needed')
const xdg = join(home, 'other data')
await mkdir(join(xdg, 'opencode'), { recursive: true })
await writeFile(join(xdg, 'opencode', 'opencode-team.db'), '')
const environment = { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' }
expect(parseOpenCodeRuntimeResult((await command(probe, environment)).stdout).status).toBe(
'ready'
)
expect(
parseOpenCodeRuntimeResult(
(await command(probe, { ...environment, OPENCODE_DB: ':memory:' })).stdout
).status
).toBe('not-needed')
})
it('reclaims an abandoned binary through the shared pool while preserving fresh uploads', async () => {
const root = await directory()
const abandoned = await reserveStage(root)
await writeFile(join(abandoned.slotDir, 'payload', 'bun'), 'partial upload')
const fresh = await reserveStage(root)
await writeFile(join(fresh.slotDir, 'payload', 'bun'), 'active upload')
const old = new Date(Date.now() - 3_600_000)
await utimes(join(abandoned.slotDir, '.orca-upload-owner'), old, old)
const recovered = await command(recoverOneStaleRelayUploadStageCommand(host, abandoned.poolDir))
expect(recovered.code, recovered.stderr).toBe(0)
await expect(stat(abandoned.slotDir)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(join(fresh.slotDir, 'payload', 'bun'), 'utf8')).toBe('active upload')
})
it('falls back to an atomic unique rename when the host filesystem rejects hard links', async () => {
const root = await directory()
const source = join(root, 'source')
await writeFile(source, 'verified runtime')
const preload = join(root, 'disable-hardlinks.cjs')
await writeFile(
preload,
"require('node:fs').promises.link=async()=>{throw Object.assign(Error('unsupported'),{code:'EPERM'})}"
)
const executable = join(root, expectedHash, 'bun')
const result = await command(
promoteOpenCodeRuntimeCommand({
host,
nodePath,
stagedBinary: source,
executable,
expectedHash,
repairToken: 'fallback'
}),
{ NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }
)
expect(result.code, result.stderr).toBe(0)
const repaired = join(root, expectedHash, 'repair-fallback', 'bun')
expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({
status: 'ready',
executable: repaired
})
expect(await readFile(repaired, 'utf8')).toBe('verified runtime')
await expect(stat(source)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
it('carries Windows JavaScript and path arguments through the established PowerShell encoder', () => {
const windows = getRemoteHostPlatform('win32-x64')
const command = promoteOpenCodeRuntimeCommand({
host: windows,
nodePath: "C:/Program Files/O'Brien/node.exe",
stagedBinary: 'C:/Users/a & b/.upload/bun.exe',
executable: 'C:/Users/a & b/cache/bun.exe',
expectedHash,
repairToken: 'one'
})
const decoded = decodeRemotePowerShellScript(command)
expect(decoded).toContain("& 'C:/Program Files/O''Brien/node.exe'")
expect(decoded).toContain('createHash')
expect(decoded).toContain('C:/Users/a & b/.upload/bun.exe')
expect(command).not.toContain('-ExecutionPolicy')
})
it('rejects missing or malformed host confirmations', () => {
expect(() => parseOpenCodeRuntimeResult('login banner')).toThrow('did not confirm')
expect(() =>
parseOpenCodeRuntimeResult('ORCA_VAULT_SQLITE:{"status":"ready","executable":"node"}')
).toThrow('invalid executable')
})
it('accepts an absolute Windows UNC executable path', () => {
const executable = String.raw`\\server\profile\vault-sqlite\bun.exe`
expect(
parseOpenCodeRuntimeResult(
`ORCA_VAULT_SQLITE:${JSON.stringify({ status: 'ready', executable })}`
)
).toEqual({ status: 'ready', executable })
})
@@ -0,0 +1,151 @@
import { shellEscape } from './ssh-connection-utils'
import { posix, win32 } from 'node:path'
import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
export const OPENCODE_RUNTIME_RESULT = 'ORCA_VAULT_SQLITE:'
function nodeCommand(
host: RemoteHostPlatform,
nodePath: string,
script: string,
args: string[]
): string {
if (isWindowsRemoteHost(host)) {
return powerShellCommand(
`& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(script)} -- ${args.map(powerShellNativeArg).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`
)
}
return `${shellEscape(nodePath)} -e ${shellEscape(script)} -- ${args.map(shellEscape).join(' ')}`
}
const SEND = `const send=(value)=>console.log(${JSON.stringify(OPENCODE_RUNTIME_RESULT)}+JSON.stringify(value));`
const HASH = `const fs=require('node:fs');const fsp=fs.promises;const path=require('node:path');
async function hash(file){try{const digest=require('node:crypto').createHash('sha256');for await(const chunk of fs.createReadStream(file))digest.update(chunk);return digest.digest('hex')}catch(error){if(error.code==='ENOENT')return null;throw error}}
`
export function probeOpenCodeNodeSqliteCommand(
host: RemoteHostPlatform,
nodePath: string,
homeDirectory: string
): string {
return nodeCommand(
host,
nodePath,
`${SEND}
const fs=require('node:fs/promises');const path=require('node:path');
(async()=>{const data=path.join(process.env.XDG_DATA_HOME?.trim()||path.join(process.argv[1],'.local','share'),'opencode');
const override=process.env.OPENCODE_DB?.trim();let present=false;
try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(override)?override:path.join(data,override))).isFile()}
else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}}
catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error}
if(!present){send({status:'not-needed'});return}
let db;try{db=new(require('node:sqlite').DatabaseSync)(':memory:');
if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed');
send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()}
})().catch(error=>{console.error(error.message);process.exitCode=1})`,
[homeDirectory]
)
}
export function probeOpenCodeRuntimeCacheCommand(args: {
host: RemoteHostPlatform
nodePath: string
executable: string
expectedHash: string
reference: string
}): string {
return nodeCommand(
args.host,
args.nodePath,
`${HASH}${SEND}
(async()=>{const [executable,expected,reference]=process.argv.slice(1);
let candidate=executable;let digest=candidate?await hash(candidate):null;
if(candidate&&digest!==expected){try{const ref=JSON.parse(await fsp.readFile(reference,'utf8'));
const relative=path.relative(path.dirname(executable),ref.executable);
if(ref.protocol===1&&relative&&!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)){candidate=ref.executable;digest=await hash(candidate)}}catch{}}
if(candidate&&digest===expected){if(process.platform!=='win32')await fsp.chmod(candidate,448);send({status:'ready',executable:candidate});return}
send({status:'missing'})})().catch(error=>{console.error(error.message);process.exitCode=1})`,
[args.executable, args.expectedHash, args.reference]
)
}
export function promoteOpenCodeRuntimeCommand(args: {
host: RemoteHostPlatform
nodePath: string
stagedBinary: string
executable: string
expectedHash: string
repairToken: string
}): string {
return nodeCommand(
args.host,
args.nodePath,
`${HASH}${SEND}
(async()=>{const [source,destination,expected,token]=process.argv.slice(1);
if(await hash(source)!==expected)throw Error('Uploaded SQLite runtime checksum mismatch');
let executable=destination;const existing=await hash(destination);
if(existing!==expected){
if(existing!==null)executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination));
await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448});
if(process.platform!=='win32')await fsp.chmod(source,448);
try{await fsp.link(source,executable)}catch(error){if(await hash(executable)!==expected){
if(!['EPERM','EOPNOTSUPP','ENOTSUP','ENOSYS','EXDEV'].includes(error.code))throw error;
executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination));
await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448});await fsp.rename(source,executable)
}}
}
send({status:'ready',executable})})().catch(error=>{console.error(error.message);process.exitCode=1})`,
[args.stagedBinary, args.executable, args.expectedHash, args.repairToken]
)
}
export function publishOpenCodeRuntimeReferenceCommand(args: {
host: RemoteHostPlatform
nodePath: string
stagedReference: string
reference: string
token: string
}): string {
return nodeCommand(
args.host,
args.nodePath,
`${SEND}
const fs=require('node:fs/promises');const path=require('node:path');
(async()=>{const [source,destination,token]=process.argv.slice(1);const temporary=destination+'.upload-'+token;
try{await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL);
await fs.rename(temporary,destination);send({status:'published'})}
finally{await fs.rm(temporary,{force:true})}})().catch(error=>{console.error(error.message);process.exitCode=1})`,
[args.stagedReference, args.reference, args.token]
)
}
export function parseOpenCodeRuntimeResult(output: string): {
status: string
executable?: string
} {
const line = output.split(/\r?\n/).findLast((entry) => entry.startsWith(OPENCODE_RUNTIME_RESULT))
if (!line) {
throw new Error('The host did not confirm SQLite runtime setup.')
}
const result: unknown = JSON.parse(line.slice(OPENCODE_RUNTIME_RESULT.length))
if (
typeof result !== 'object' ||
result === null ||
!('status' in result) ||
typeof result.status !== 'string'
) {
throw new Error('Invalid SQLite runtime setup result.')
}
if ('executable' in result) {
if (
typeof result.executable !== 'string' ||
!(posix.isAbsolute(result.executable) || win32.isAbsolute(result.executable)) ||
/[\0\r\n]/.test(result.executable)
) {
throw new Error('SQLite runtime setup returned an invalid executable path.')
}
return { status: result.status, executable: result.executable }
}
return { status: result.status }
}
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRemoteOpenCodeRuntimeRetry } from './ssh-relay-opencode-runtime-retry'
afterEach(() => vi.useRealTimers())
describe('scan-triggered SSH OpenCode runtime preparation', () => {
it('rechecks an empty host after the cooldown, coalesces scans, and stops once ready', async () => {
vi.useFakeTimers()
const retry = vi.fn().mockResolvedValue('ready')
const prepare = createRemoteOpenCodeRuntimeRetry(
Promise.resolve('not-needed'),
Promise.resolve(true),
retry
)
const signal = new AbortController().signal
await prepare(signal)
expect(retry).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
await Promise.all([prepare(signal), prepare(signal), prepare(signal)])
expect(retry).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(60_000)
await prepare(signal)
expect(retry).toHaveBeenCalledOnce()
})
it('waits for initial background cleanup before a scan can retry', async () => {
vi.useFakeTimers()
let finish!: () => void
const cleanup = new Promise<boolean>((resolve) => {
finish = () => resolve(true)
})
const retry = vi.fn().mockResolvedValue('ready')
const prepare = createRemoteOpenCodeRuntimeRetry(Promise.resolve('failed'), cleanup, retry)
await prepare(new AbortController().signal)
await vi.advanceTimersByTimeAsync(30_000)
const pending = prepare(new AbortController().signal)
await Promise.resolve()
expect(retry).not.toHaveBeenCalled()
finish()
await pending
expect(retry).toHaveBeenCalledOnce()
})
it('cancels a queued retry on session teardown without starting a remote command', async () => {
vi.useFakeTimers()
const retry = vi.fn()
const prepare = createRemoteOpenCodeRuntimeRetry(
Promise.resolve('not-needed'),
new Promise(() => {}),
retry
)
const controller = new AbortController()
await prepare(controller.signal)
await vi.advanceTimersByTimeAsync(60_000)
const pending = prepare(controller.signal)
controller.abort()
await pending
await prepare(controller.signal)
expect(retry).not.toHaveBeenCalled()
})
it('never retries an unconfirmed remote teardown', async () => {
vi.useFakeTimers()
const retry = vi.fn()
const prepare = createRemoteOpenCodeRuntimeRetry(
Promise.resolve('teardown-unconfirmed'),
Promise.resolve(true),
retry
)
await vi.advanceTimersByTimeAsync(3600_000)
await prepare(new AbortController().signal)
expect(retry).not.toHaveBeenCalled()
})
it('never retries after deployment cleanup reports an unconfirmed command', async () => {
vi.useFakeTimers()
const retry = vi.fn()
const prepare = createRemoteOpenCodeRuntimeRetry(
Promise.resolve('not-needed'),
Promise.resolve(false),
retry
)
await prepare(new AbortController().signal)
await vi.advanceTimersByTimeAsync(60_000)
await prepare(new AbortController().signal)
await vi.advanceTimersByTimeAsync(3600_000)
await prepare(new AbortController().signal)
expect(retry).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,42 @@
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import type { RemoteOpenCodeRuntimeOutcome } from './ssh-relay-opencode-runtime'
export type RemoteOpenCodeRuntimePreparation = (signal: AbortSignal) => Promise<void>
export function createRemoteOpenCodeRuntimeRetry(
initialSetup: Promise<RemoteOpenCodeRuntimeOutcome>,
backgroundCleanup: Promise<boolean>,
retry: (signal: AbortSignal) => Promise<RemoteOpenCodeRuntimeOutcome>
): RemoteOpenCodeRuntimePreparation {
let nextAttempt = Infinity
let pending: Promise<void> | undefined
const remember = (outcome: RemoteOpenCodeRuntimeOutcome): void => {
nextAttempt =
outcome === 'ready' || outcome === 'teardown-unconfirmed'
? Infinity
: Date.now() + (outcome === 'not-needed' ? 60_000 : 30_000)
}
const initialized = initialSetup.then(remember).catch(() => {})
return (signal) => {
if (signal.aborted) {
return Promise.resolve()
}
pending ??= (async () => {
await waitForPromiseWithSignal(initialized, signal)
if (Date.now() < nextAttempt) {
return
}
if (!(await waitForPromiseWithSignal(backgroundCleanup, signal))) {
nextAttempt = Infinity
return
}
signal.throwIfAborted()
remember(await retry(signal))
})()
.catch(() => {})
.finally(() => {
pending = undefined
})
return pending
}
}
@@ -0,0 +1,412 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
exec: vi.fn(),
upload: vi.fn(),
write: vi.fn(),
materialize: vi.fn(),
target: vi.fn(),
warm: false,
checksumError: false,
cleanupError: false,
reservationError: false
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: mocks.exec,
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
'sshChannelCloseConfirmed' in error &&
error.sshChannelCloseConfirmed === false
}))
vi.mock('./ssh-relay-install-transfers', () => ({
uploadRelayDirectory: mocks.upload,
writeRelayFile: mocks.write
}))
vi.mock('./orcad-bun-runtime-materializer', () => ({
materializeCachedOrcadBunRuntime: mocks.materialize
}))
vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTarget: mocks.target }))
import type { SshConnection } from './ssh-connection'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime'
import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime'
import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands'
const host = getRemoteHostPlatform('linux-x64')
const remoteHome = '/home/ada'
const relayDir = `${remoteHome}/.orca-remote/relay-build`
const binary = `${remoteHome}/.orca-remote/vault-sqlite/${ORCAD_BUN_RELEASE_ASSETS['linux-x64-glibc'].executableSha256}/bun`
let cacheRoot: string
let runtime: string
const frame = (status: string, executable?: string) =>
`${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n`
const options = () => ({ nodePath: '/usr/bin/node', relayDir, cacheRoot })
function hostCommandResult(command: string): string {
if (command.includes('staging quota is full')) {
if (mocks.reservationError) {
throw new Error('staging quota is full')
}
return `__ORCA_UPLOAD_STAGE_SLOT__${command.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0`
}
if (command.includes('SELECT 1 AS ready')) {
return frame('unsupported')
}
if (command.includes('checksum mismatch')) {
if (mocks.checksumError) {
throw new Error('Uploaded SQLite runtime checksum mismatch')
}
return frame('ready', binary)
}
if (command.includes('published')) {
return frame('published')
}
if (command.includes('status:')) {
return mocks.warm ? frame('ready', binary) : frame('missing')
}
if (mocks.cleanupError && command.includes('claim_identity') && !command.includes('old=')) {
throw Object.assign(new Error('Cleanup teardown is unconfirmed'), {
sshChannelCloseConfirmed: false
})
}
return ''
}
function connection(system = false): SshConnection {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup reads only the mocked transport flag and connection generation; remote I/O is mocked.
return {
usesSystemSshTransport: () => system,
getConnectGeneration: () => 1
} as unknown as SshConnection
}
beforeEach(async () => {
vi.resetAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
cacheRoot = await mkdtemp(join(tmpdir(), 'orca-vault-runtime-'))
runtime = join(cacheRoot, 'repair-1-orcad-bun')
await writeFile(runtime, 'verified runtime')
mocks.materialize.mockResolvedValue(runtime)
mocks.target.mockResolvedValue('linux-x64-glibc')
mocks.warm = false
mocks.checksumError = false
mocks.cleanupError = false
mocks.reservationError = false
mocks.exec.mockImplementation(async (_conn, command: string) => hostCommandResult(command))
})
afterEach(async () => {
vi.useRealTimers()
vi.restoreAllMocks()
await rm(cacheRoot, { recursive: true, force: true })
})
describe('SSH OpenCode runtime setup', () => {
it('publishes a capable existing Node without materializing or uploading Bun', async () => {
mocks.exec.mockResolvedValueOnce(frame('ready', '/opt/node 24/bin/node'))
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'ready'
)
expect(mocks.target).not.toHaveBeenCalled()
expect(mocks.materialize).not.toHaveBeenCalled()
expect(mocks.upload).not.toHaveBeenCalled()
expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({
protocol: 1,
executable: '/opt/node 24/bin/node'
})
})
it('uses the verified materializer cache after Node 18 fails the actual read probe', async () => {
mocks.upload.mockImplementation(async (_conn, localDir: string) => {
expect(await readdir(localDir)).toEqual(['bun'])
expect(await readFile(join(localDir, 'bun'), 'utf8')).toBe('verified runtime')
})
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'ready'
)
expect(mocks.target).toHaveBeenCalledWith(
expect.objectContaining({ host, signal: expect.any(AbortSignal) })
)
expect(mocks.materialize).toHaveBeenCalledWith('linux-x64-glibc', cacheRoot, {
signal: expect.any(AbortSignal)
})
expect(await readdir(cacheRoot)).toEqual(['repair-1-orcad-bun'])
expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary })
})
it('reuses a remotely verified binary without downloading it again', async () => {
mocks.warm = true
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'ready'
)
expect(mocks.materialize).not.toHaveBeenCalled()
expect(mocks.upload).not.toHaveBeenCalled()
})
it('uses one staging namespace for binary uploads and atomic reference writes', async () => {
await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())
const uploadOptions = mocks.upload.mock.calls[0][4]
const writeOptions = mocks.write.mock.calls[0][4]
expect(uploadOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch(
/^\.orca-remote\/\.upload-stages\/slot-0$/
)
expect(uploadOptions.sftpNamespace.shellProbePath).toBe(
writeOptions.sftpNamespace.shellProbePath
)
expect(writeOptions.sftpNamespace.homeRelativePath).toBe(
`${uploadOptions.sftpNamespace.homeRelativePath}/opencode-sqlite-runtime.json`
)
})
it('skips namespace probing on system SSH', async () => {
await ensureRemoteOpenCodeRuntime(connection(true), host, remoteHome, options())
expect(mocks.upload.mock.calls[0][4].sftpNamespace).toBeUndefined()
expect(mocks.write.mock.calls[0][4].sftpNamespace).toBeUndefined()
})
it('coalesces repeated setup for one execution connection and directory', async () => {
const conn = connection()
const result = await Promise.all([
ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options()),
ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
])
expect(result).toEqual(['ready', 'ready'])
expect(mocks.upload).toHaveBeenCalledOnce()
})
it('coalesces the bounded target cache fill across hosts', async () => {
let finish!: (path: string) => void
mocks.materialize.mockReturnValue(
new Promise<string>((resolve) => {
finish = resolve
})
)
const first = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())
const second = ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())
await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce())
finish(runtime)
expect(await Promise.all([first, second])).toEqual(['ready', 'ready'])
expect(mocks.upload).toHaveBeenCalledTimes(2)
})
it('never publishes a reference after a failed remote checksum', async () => {
mocks.checksumError = true
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'failed'
)
expect(mocks.write).not.toHaveBeenCalled()
})
it('aborts an upload without publishing or running further host commands', async () => {
const controller = new AbortController()
mocks.upload.mockImplementation(async (_conn, _local, _remote, _host, transfer) => {
controller.abort()
transfer.signal.throwIfAborted()
})
expect(
await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, {
...options(),
signal: controller.signal
})
).toBe('teardown-unconfirmed')
expect(mocks.write).not.toHaveBeenCalled()
expect(mocks.exec).toHaveBeenCalledTimes(4)
})
it('bounds even an unresponsive setup operation at 180 seconds', async () => {
vi.useFakeTimers()
mocks.exec.mockReturnValue(new Promise(() => {}))
const conn = connection()
const result = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
await vi.advanceTimersByTimeAsync(180_000)
expect(await result).toBe('teardown-unconfirmed')
expect(mocks.exec.mock.calls[0][2].signal.aborted).toBe(true)
expect(mocks.materialize).not.toHaveBeenCalled()
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(mocks.exec).toHaveBeenCalledOnce()
})
it('admits setup on a new connection generation after an unconfirmed teardown', async () => {
const conn = connection()
const generation = vi.spyOn(conn, 'getConnectGeneration')
mocks.exec.mockRejectedValueOnce(
Object.assign(new Error('Channel teardown is unconfirmed'), {
sshChannelCloseConfirmed: false
})
)
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(mocks.exec).toHaveBeenCalledOnce()
generation.mockReturnValue(2)
mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node'))
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready')
expect(mocks.write).toHaveBeenCalledOnce()
})
it.each(['ready', 'not-needed'] as const)(
'refuses a late %s result from a superseded setup',
async (status) => {
const conn = connection()
const generation = vi.spyOn(conn, 'getConnectGeneration')
mocks.exec.mockImplementationOnce(async () => {
generation.mockReturnValue(2)
return frame(status, '/usr/bin/node')
})
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('failed')
expect(mocks.exec).toHaveBeenCalledOnce()
expect(mocks.write).not.toHaveBeenCalled()
}
)
it.each(['publication', 'cleanup'] as const)(
'refuses completed setup when its generation changes during %s',
async (stage) => {
const conn = connection()
const generation = vi.spyOn(conn, 'getConnectGeneration')
const started = Promise.withResolvers<void>()
const finish = Promise.withResolvers<string>()
mocks.exec.mockImplementation(async (_conn, command: string) => {
if (command.includes('SELECT 1 AS ready')) {
return frame('ready', '/usr/bin/node')
}
const selected =
stage === 'publication'
? command.includes('published')
: command.includes('claim_identity') && !command.includes('old=')
if (selected) {
started.resolve()
return finish.promise
}
return hostCommandResult(command)
})
const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
await started.promise
generation.mockReturnValue(2)
finish.resolve(stage === 'publication' ? frame('published') : '')
expect(await pending).toBe('failed')
expect(mocks.exec).toHaveBeenCalledTimes(stage === 'publication' ? 4 : 5)
expect(mocks.write).toHaveBeenCalledOnce()
}
)
it('keeps a newer setup registered when the superseded setup finishes late', async () => {
const conn = connection()
const generation = vi.spyOn(conn, 'getConnectGeneration')
const oldProbe = Promise.withResolvers<string>()
const currentProbe = Promise.withResolvers<string>()
mocks.exec.mockReturnValueOnce(oldProbe.promise).mockReturnValueOnce(currentProbe.promise)
const oldSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
generation.mockReturnValue(2)
const currentSetup = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
oldProbe.resolve(frame('not-needed'))
expect(await oldSetup).toBe('failed')
const joined = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())
expect(mocks.exec).toHaveBeenCalledTimes(2)
currentProbe.resolve(frame('not-needed'))
expect(await Promise.all([currentSetup, joined])).toEqual(['not-needed', 'not-needed'])
})
it.each(['deadline', 'caller'] as const)(
'allows retry after local download cancellation by %s without reserving a stage',
async (cause) => {
const controller = new AbortController()
let finish!: (path: string) => void
mocks.materialize.mockReturnValueOnce(
new Promise<string>((resolve) => {
finish = resolve
})
)
if (cause === 'deadline') {
vi.useFakeTimers()
}
const conn = connection()
const pending = ensureRemoteOpenCodeRuntime(conn, host, remoteHome, {
...options(),
signal: controller.signal
})
await vi.waitFor(() => expect(mocks.materialize).toHaveBeenCalledOnce())
expect(mocks.exec).toHaveBeenCalledTimes(2)
if (cause === 'deadline') {
await vi.advanceTimersByTimeAsync(180_000)
} else {
controller.abort()
}
expect(await pending).toBe('failed')
finish(runtime)
vi.useRealTimers()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(mocks.exec).toHaveBeenCalledTimes(2)
expect(mocks.upload).not.toHaveBeenCalled()
expect(mocks.write).not.toHaveBeenCalled()
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready')
expect(mocks.upload).toHaveBeenCalledOnce()
}
)
it('retains the upload stage when a failed transfer may still be running', async () => {
mocks.upload.mockRejectedValue(
Object.assign(new Error('Upload teardown is unconfirmed'), {
sshChannelCloseConfirmed: false
})
)
const conn = connection()
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(mocks.exec).toHaveBeenCalledTimes(4)
expect(mocks.write).not.toHaveBeenCalled()
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(mocks.exec).toHaveBeenCalledTimes(4)
})
it('reports an unconfirmed stage cleanup to the deployment command queue', async () => {
mocks.exec.mockResolvedValueOnce(frame('ready', '/usr/bin/node'))
mocks.cleanupError = true
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'teardown-unconfirmed'
)
expect(mocks.exec).toHaveBeenCalledTimes(5)
})
it('skips installation without data and retries when a database appears on the same connection', async () => {
const conn = connection()
mocks.exec.mockResolvedValueOnce(frame('not-needed'))
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('not-needed')
expect(mocks.exec).toHaveBeenCalledOnce()
expect(mocks.materialize).not.toHaveBeenCalled()
expect(mocks.upload).not.toHaveBeenCalled()
expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe('ready')
expect(mocks.upload).toHaveBeenCalledOnce()
})
it('fails optionally without uploading when all bounded stages are occupied', async () => {
mocks.reservationError = true
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'failed'
)
expect(mocks.exec).toHaveBeenCalledTimes(4)
expect(mocks.upload).not.toHaveBeenCalled()
})
it('does not mistake an unanswered Node probe for an old runtime', async () => {
mocks.exec.mockResolvedValue('login banner only')
expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe(
'failed'
)
expect(mocks.materialize).not.toHaveBeenCalled()
})
})
+298
View File
@@ -0,0 +1,298 @@
import { randomBytes } from 'node:crypto'
import { copyFile, link, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime'
import type { SshConnection } from './ssh-connection'
import { resolveOrcadDeploymentTarget } from './orcad-deployment-target'
import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer'
import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers'
import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers'
import {
createRelayUploadStageNamespace,
relayUploadStageSftpNamespaceMapping
} from './ssh-relay-install-namespace'
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker'
import {
cleanupOwnedRelayUploadStageCommand,
parseReservedRelayUploadStage,
recoverOneStaleRelayUploadStageCommand,
reserveRelayUploadStageCommand,
RELAY_UPLOAD_STAGE_POOL_NAME
} from './ssh-relay-upload-stage-commands'
import {
parseOpenCodeRuntimeResult,
probeOpenCodeRuntimeCacheCommand,
probeOpenCodeNodeSqliteCommand,
promoteOpenCodeRuntimeCommand,
publishOpenCodeRuntimeReferenceCommand
} from './ssh-relay-opencode-runtime-commands'
const SETUP_TIMEOUT_MS = 180_000
export type RemoteOpenCodeRuntimeOutcome =
| 'ready'
| 'not-needed'
| 'failed'
| 'teardown-unconfirmed'
const installations = new WeakMap<
SshConnection,
{ generation: number; byDirectory: Map<string, Promise<RemoteOpenCodeRuntimeOutcome>> }
>()
const downloads = new Map<string, Promise<string>>()
type SetupOptions = {
nodePath: string
relayDir: string
signal?: AbortSignal
cacheRoot?: string
}
type RemoteOperation = <T>(operation: () => Promise<T>) => Promise<T>
/** Optional companion setup; the host's relay and terminals never depend on it. */
export function ensureRemoteOpenCodeRuntime(
conn: SshConnection,
host: RemoteHostPlatform,
remoteHome: string,
options: SetupOptions
): Promise<RemoteOpenCodeRuntimeOutcome> {
const generation = conn.getConnectGeneration()
let current = installations.get(conn)
if (current?.generation !== generation) {
current = { generation, byDirectory: new Map() }
installations.set(conn, current)
}
const { byDirectory } = current
const active = byDirectory.get(options.relayDir)
if (active) {
return waitForPromiseWithSignal(active, options.signal).catch(() => 'teardown-unconfirmed')
}
const timeout = new AbortController()
const timer = setTimeout(
() => timeout.abort(new Error('SSH SQLite runtime setup timed out.')),
SETUP_TIMEOUT_MS
)
timer.unref()
const signal = options.signal ? AbortSignal.any([options.signal, timeout.signal]) : timeout.signal
let remotePending = false
let remoteUnconfirmed = false
const assertCurrentGeneration = (): void => {
if (conn.getConnectGeneration() !== generation) {
throw new Error('SSH connection changed during SQLite runtime setup.')
}
}
const remote: RemoteOperation = async (operation) => {
signal.throwIfAborted()
assertCurrentGeneration()
remotePending = true
try {
const result = await operation()
assertCurrentGeneration()
return result
} catch (error) {
remoteUnconfirmed ||= signal.aborted || isUnconfirmedSshCommandTermination(error)
throw error
} finally {
remotePending = false
}
}
const pending = waitForPromiseWithSignal(
install(conn, host, remoteHome, options, signal, remote),
signal
)
.then((outcome) => {
assertCurrentGeneration()
return outcome
})
.catch((error: unknown) => {
console.warn(
'[ssh-relay] OpenCode history runtime setup did not finish:',
error instanceof Error ? error.message : String(error)
)
return remotePending || remoteUnconfirmed || isUnconfirmedSshCommandTermination(error)
? ('teardown-unconfirmed' as const)
: ('failed' as const)
})
.then((outcome) => {
clearTimeout(timer)
// An unresolved channel must not admit another installer on this connection.
if (outcome !== 'teardown-unconfirmed') {
byDirectory.delete(options.relayDir)
}
return outcome
})
byDirectory.set(options.relayDir, pending)
return pending
}
async function install(
conn: SshConnection,
host: RemoteHostPlatform,
remoteHome: string,
options: SetupOptions,
signal: AbortSignal,
remote: RemoteOperation
): Promise<RemoteOpenCodeRuntimeOutcome> {
const exec = async (command: string): Promise<string> => {
signal.throwIfAborted()
const output = await remote(() =>
execCommand(conn, command, {
signal,
wrapCommand: !isWindowsRemoteHost(host)
})
)
signal.throwIfAborted()
return output
}
const node = parseOpenCodeRuntimeResult(
await exec(probeOpenCodeNodeSqliteCommand(host, options.nodePath, remoteHome))
)
if (node.status === 'not-needed') {
return 'not-needed'
}
if (node.status !== 'ready' && node.status !== 'unsupported') {
throw new Error('The host did not complete its SQLite read probe.')
}
let executable = node.executable
let target: OrcadBunTarget | undefined
let localRuntime: string | undefined
if (node.status === 'unsupported') {
target = await resolveOrcadDeploymentTarget({ conn, host, signal, exec })
const expectedHash = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256
executable = joinRemotePath(
host,
remoteHome,
RELAY_REMOTE_DIR,
'vault-sqlite',
expectedHash,
isWindowsRemoteHost(host) ? 'bun.exe' : 'bun'
)
const cached = parseOpenCodeRuntimeResult(
await exec(
probeOpenCodeRuntimeCacheCommand({
host,
nodePath: options.nodePath,
executable,
expectedHash,
reference: joinRemotePath(host, options.relayDir, 'opencode-sqlite-runtime.json')
})
)
)
if (cached.status === 'ready' && cached.executable) {
executable = cached.executable
} else if (cached.status === 'missing') {
const cacheRoot =
options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts')
localRuntime = await cachedRuntime(target, cacheRoot, signal)
signal.throwIfAborted()
} else {
throw new Error('The host did not confirm its SQLite runtime cache.')
}
}
if (!executable) {
throw new Error('The host did not identify its SQLite executable.')
}
const token = randomBytes(12).toString('hex')
const relativePool = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}`
const poolDir = joinRemotePath(host, remoteHome, relativePool)
const owner = createRelayInstallMarkerFileName()
await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir))
const stage = parseReservedRelayUploadStage(
host,
poolDir,
owner,
await exec(reserveRelayUploadStageCommand(host, poolDir, owner))
)
const stageDir = stage.slotDir
const namespace = createRelayUploadStageNamespace(`${relativePool}/${stage.slotName}`, owner)
const mapping = (file?: string) =>
!isWindowsRemoteHost(host) && conn.usesSystemSshTransport?.() !== true
? relayUploadStageSftpNamespaceMapping(namespace, host, stageDir, file)
: undefined
let cleanupAllowed = true
try {
if (target && localRuntime) {
const localStage = await mkdtemp(join(dirname(localRuntime), '.vault-upload-'))
try {
const binaryName = isWindowsRemoteHost(host) ? 'bun.exe' : 'bun'
const localBinary = join(localStage, binaryName)
await link(localRuntime, localBinary).catch(() => copyFile(localRuntime, localBinary))
signal.throwIfAborted()
await remote(() =>
uploadRelayDirectory(conn, localStage, joinRemotePath(host, stageDir, 'payload'), host, {
signal,
sftpNamespace: mapping()
})
)
const promoted = parseOpenCodeRuntimeResult(
await exec(
promoteOpenCodeRuntimeCommand({
host,
nodePath: options.nodePath,
stagedBinary: joinRemotePath(host, stageDir, 'payload', binaryName),
executable,
expectedHash: ORCAD_BUN_RELEASE_ASSETS[target].executableSha256,
repairToken: token
})
)
)
if (promoted.status !== 'ready' || !promoted.executable) {
throw new Error('The host did not verify the uploaded SQLite runtime.')
}
executable = promoted.executable
} finally {
await rm(localStage, { recursive: true, force: true }).catch(() => {})
}
}
const referenceName = 'opencode-sqlite-runtime.json'
const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName)
signal.throwIfAborted()
await remote(() =>
writeRelayFile(conn, host, stagedReference, JSON.stringify({ protocol: 1, executable }), {
signal,
sftpNamespace: mapping(referenceName)
})
)
const published = parseOpenCodeRuntimeResult(
await exec(
publishOpenCodeRuntimeReferenceCommand({
host,
nodePath: options.nodePath,
stagedReference,
reference: joinRemotePath(host, options.relayDir, referenceName),
token
})
)
)
return published.status === 'published' ? 'ready' : 'failed'
} catch (error) {
cleanupAllowed = !isUnconfirmedSshCommandTermination(error)
throw error
} finally {
if (cleanupAllowed && !signal.aborted) {
await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner)).catch((error) => {
if (isUnconfirmedSshCommandTermination(error)) {
throw error
}
})
}
}
}
function cachedRuntime(
target: OrcadBunTarget,
cacheRoot: string,
signal: AbortSignal
): Promise<string> {
const key = `${cacheRoot}\0${target}`
let pending = downloads.get(key)
if (!pending) {
pending = materializeCachedOrcadBunRuntime(target, cacheRoot, {
signal: AbortSignal.timeout(SETUP_TIMEOUT_MS)
}).finally(() => downloads.delete(key))
downloads.set(key, pending)
}
return waitForPromiseWithSignal(pending, signal)
}
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RelayInstallMarkerModule from './ssh-relay-install-marker'
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
+21
View File
@@ -206,6 +206,27 @@ describe('SshRelaySession', () => {
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('rechecks OpenCode preparation from scans and aborts it when the relay session disconnects', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const prepareOpenCodeRuntime = vi.fn().mockResolvedValue(undefined)
vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({
transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() },
platform: 'linux-x64',
prepareOpenCodeRuntime
})
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
await session.requestAiVaultSessionList({})
await session.requestSessionSearch('sessionSearch.search', {})
expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2)
const signal = prepareOpenCodeRuntime.mock.calls[0][0]
expect(signal.aborted).toBe(false)
await session.dispose()
expect(signal.aborted).toBe(true)
await expect(session.requestAiVaultSessionList({})).rejects.toThrow('not ready')
expect(prepareOpenCodeRuntime).toHaveBeenCalledTimes(2)
})
it('continues provider registration when the relay managed-hook request fails', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockImplementation(async (method: string) => {
+26 -2
View File
@@ -4,6 +4,7 @@
import { randomUUID } from 'node:crypto'
import type { BrowserWindow } from 'electron'
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import type { RemoteOpenCodeRuntimePreparation } from './ssh-relay-opencode-runtime-retry'
import { execCommand } from './ssh-relay-deploy-helpers'
import { writeStringsViaSftp } from './sftp-upload'
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
@@ -329,6 +330,10 @@ export class SshRelaySession {
private lastGraceTimeSeconds: number | undefined = undefined
private hostPlatform: RemoteHostPlatform | null = null
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
private openCodeRuntimePreparation: {
run: RemoteOpenCodeRuntimePreparation
controller: AbortController
} | null = null
private aiVaultListMethodSupported: boolean | null = null
private aiVaultTitleMethodSupported: boolean | null = null
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
@@ -460,6 +465,7 @@ export class SshRelaySession {
if (!mux || mux.isDisposed() || this._state !== 'ready') {
throw new Error('SSH relay is not ready')
}
this.prepareOpenCodeRuntimeForScan()
return mux.request(method, params, { timeoutMs: 15_000 })
}
@@ -474,6 +480,7 @@ export class SshRelaySession {
if (!mux || mux.isDisposed() || this._state !== 'ready') {
throw new Error('SSH relay is not ready')
}
this.prepareOpenCodeRuntimeForScan()
try {
const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, {
signal: options.signal,
@@ -549,7 +556,8 @@ export class SshRelaySession {
nodePath,
sockPath,
credentialFile,
hostPlatform
hostPlatform,
prepareOpenCodeRuntime
} = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.hostPlatform = hostPlatform ?? null
this.remoteCliBridgeEnv =
@@ -574,6 +582,9 @@ export class SshRelaySession {
}
const mux = new SshChannelMultiplexer(transport)
this.openCodeRuntimePreparation = prepareOpenCodeRuntime
? { run: prepareOpenCodeRuntime, controller: new AbortController() }
: null
this.mux = mux
const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed()
const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed()
@@ -704,7 +715,8 @@ export class SshRelaySession {
nodePath,
sockPath,
credentialFile,
hostPlatform
hostPlatform,
prepareOpenCodeRuntime
} = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.hostPlatform = hostPlatform ?? null
this.remoteCliBridgeEnv =
@@ -729,6 +741,9 @@ export class SshRelaySession {
}
const mux = new SshChannelMultiplexer(transport)
this.openCodeRuntimePreparation = prepareOpenCodeRuntime
? { run: prepareOpenCodeRuntime, controller: new AbortController() }
: null
this.mux = mux
const isAttemptCurrent = (): boolean =>
@@ -1646,10 +1661,19 @@ export class SshRelaySession {
})
}
private prepareOpenCodeRuntimeForScan(): void {
const preparation = this.openCodeRuntimePreparation
if (preparation) {
void preparation.run(preparation.controller.signal)
}
}
private teardownProviders(
reason: 'shutdown' | 'connection_lost',
outputGenerationReason: string = reason
): void {
this.openCodeRuntimePreparation?.controller.abort()
this.openCodeRuntimePreparation = null
this.releaseRelayLossWatcher()
this.muxNotificationCleanup?.()
this.muxNotificationCleanup = null
@@ -7,6 +7,9 @@ import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RelayInstallMarkerModule from './ssh-relay-install-marker'
vi.mock('./ssh-relay-opencode-runtime', () => ({
ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready')
}))
vi.mock('./ssh-relay-ripgrep-install', () => ({
remoteRipgrepLayout: vi.fn().mockReturnValue(null),
recordRemoteRipgrepReference: vi.fn().mockResolvedValue(false),
@@ -1,4 +1,5 @@
import type { Worker } from 'node:worker_threads'
import { getEventListeners } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkerThreadRequestQueue } from './worker-thread-request-queue'
@@ -103,6 +104,88 @@ describe('WorkerThreadRequestQueue', () => {
vi.useRealTimers()
})
it('cancels queued requests without retiring active work and retires active cancellation', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const active = new AbortController()
const queued = new AbortController()
const first = settle(
queue.dispatch((id) => ({ id, label: 'active' }), TIMEOUT_MS, active.signal)
)
const second = settle(
queue.dispatch((id) => ({ id, label: 'queued' }), TIMEOUT_MS, queued.signal)
)
const third = send(queue, 'survivor')
queued.abort(new Error('queued cancelled'))
await expect(second).resolves.toMatchObject({ message: 'queued cancelled' })
expect(workers[0].terminated).toBe(false)
active.abort(new Error('active cancelled'))
await expect(first).resolves.toMatchObject({ message: 'active cancelled' })
expect(workers[0].terminated).toBe(true)
expect(workers).toHaveLength(2)
expect(labels(workers[1])).toEqual(['survivor'])
workers[0].respond()
workers[1].respond()
await expect(third).resolves.toMatchObject({ label: 'survivor' })
queue.dispose()
})
it('never starts an already aborted request and rejects all work on disposal', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
await expect(
queue.dispatch(
(id) => ({ id, label: 'aborted' }),
TIMEOUT_MS,
AbortSignal.abort(new Error('cancelled'))
)
).rejects.toThrow('cancelled')
expect(workers).toHaveLength(0)
const active = settle(send(queue, 'active'))
const queued = settle(send(queue, 'queued'))
queue.dispose()
await expect(active).resolves.toMatchObject({ message: 'Worker request queue disposed' })
await expect(queued).resolves.toMatchObject({ message: 'Worker request queue disposed' })
await expect(send(queue, 'later')).rejects.toThrow('disposed')
expect(workers[0].terminated).toBe(true)
})
it.each([false, true])(
'does not respawn for queued calls sharing the cancelled active signal (survivor: %s)',
async (hasSurvivor) => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const controller = new AbortController()
const reason = new Error('scan cancelled')
const pending = Array.from({ length: 8 }, (_, index) =>
settle(
queue.dispatch(
(id) => ({ id, label: `cancelled-${index}` }),
TIMEOUT_MS,
controller.signal
)
)
)
const survivor = hasSurvivor ? send(queue, 'survivor') : undefined
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(8)
controller.abort(reason)
expect(await Promise.all(pending)).toEqual(Array.from({ length: 8 }, () => reason))
expect(workers[0].terminated).toBe(true)
expect(workers).toHaveLength(hasSurvivor ? 2 : 1)
expect(workers.flatMap(labels)).toEqual(
hasSurvivor ? ['cancelled-0', 'survivor'] : ['cancelled-0']
)
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
if (survivor) {
workers[1].respond()
await expect(survivor).resolves.toMatchObject({ label: 'survivor' })
}
queue.dispose()
}
)
it('posts one request at a time and in the order it was dispatched', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
+61 -5
View File
@@ -43,6 +43,8 @@ type PendingCall<TRequest, TResponse> = {
resolve: (value: TResponse) => void
reject: (error: Error) => void
timer: NodeJS.Timeout | null
signal?: AbortSignal
cleanupAbort: () => void
}
export class WorkerThreadRequestQueue<
@@ -53,6 +55,7 @@ export class WorkerThreadRequestQueue<
private queue: PendingCall<TRequest, TResponse>[] = []
private consecutiveDeaths = 0
private nextId = 1
private disposed = false
private readonly host: LazyWorkerThreadHost<TResponse>
constructor(private readonly options: WorkerThreadRequestQueueOptions<TRequest>) {
@@ -73,8 +76,20 @@ export class WorkerThreadRequestQueue<
* @param timeoutMs - Deadline measured from dispatch, not from enqueue.
* @returns The worker's response; rejects on timeout, crash, or an unspawnable worker.
*/
dispatch(buildRequest: (id: number) => TRequest, timeoutMs: number): Promise<TResponse> {
dispatch(
buildRequest: (id: number) => TRequest,
timeoutMs: number,
signal?: AbortSignal
): Promise<TResponse> {
return new Promise((resolve, reject) => {
if (this.disposed) {
reject(new Error('Worker request queue disposed'))
return
}
if (signal?.aborted) {
reject(signal.reason ?? new Error('Worker request aborted'))
return
}
// Built before the cap check so a rejection can name the dropped work;
// the id it burns is only a correlation token, so a gap costs nothing.
const request = buildRequest(this.nextId++)
@@ -88,21 +103,57 @@ export class WorkerThreadRequestQueue<
if (!this.active && this.queue.length === 0) {
this.consecutiveDeaths = 0
}
this.queue.push({
const call: PendingCall<TRequest, TResponse> = {
request,
timeoutMs,
resolve,
reject,
timer: null
})
timer: null,
signal,
cleanupAbort: () => signal?.removeEventListener('abort', abort)
}
const abort = (): void => {
if (this.active === call) {
this.host.destroy()
} else {
this.queue = this.queue.filter((queued) => queued !== call)
}
this.settle(call, () => reject(signal?.reason ?? new Error('Worker request aborted')))
this.afterSettle()
}
signal?.addEventListener('abort', abort, { once: true })
this.queue.push(call)
this.pump()
})
}
dispose(): void {
this.disposed = true
this.host.destroy()
const pending = this.active ? [this.active, ...this.queue] : this.queue
this.queue = []
for (const call of pending) {
this.settle(call, () => call.reject(new Error('Worker request queue disposed')))
}
}
private pump(): void {
if (this.active || this.queue.length === 0) {
return
}
// A shared signal is already aborted before its remaining listeners run.
while (this.queue[0]?.signal?.aborted) {
const cancelled = this.queue.shift()
if (cancelled) {
this.settle(cancelled, () =>
cancelled.reject(cancelled.signal?.reason ?? new Error('Worker request aborted'))
)
}
}
if (this.queue.length === 0) {
this.host.scheduleIdleTeardown()
return
}
const worker = this.host.ensure()
if (!worker) {
this.failQueuedAsUnavailable()
@@ -115,7 +166,11 @@ export class WorkerThreadRequestQueue<
this.active = call
this.host.clearIdleTimer()
this.armDeadline(call)
worker.postMessage(call.request)
try {
worker.postMessage(call.request)
} catch (error) {
this.onWorkerFault(error instanceof Error ? error : new Error(String(error)))
}
}
/**
@@ -199,6 +254,7 @@ export class WorkerThreadRequestQueue<
}
private settle(call: PendingCall<TRequest, TResponse>, run: () => void): void {
call.cleanupAbort()
if (call.timer) {
clearTimeout(call.timer)
call.timer = null
+19 -5
View File
@@ -8,7 +8,8 @@ import { wslDistroListRetryDelayMs } from './wsl-distro-retry'
let cache: string[] | null = null
let retryAfterMs = 0
let failureStreak = 0
let inFlightProbe: Promise<string[]> | null = null
type RunningDistroObservation = { distros: string[]; confirmed: boolean }
let inFlightProbe: Promise<RunningDistroObservation> | null = null
function armRetryAfterFailure(): void {
const now = Date.now()
@@ -28,24 +29,37 @@ function armRetryAfterFailure(): void {
* back to the cache and arms backoff. Bounds wsl.exe spawns under both IPC fan-out and a
* broken/degraded host.
*/
export function resolveRunningWslDistros(probe: () => Promise<string[]>): Promise<string[]> {
export async function resolveRunningWslDistros(
probe: () => Promise<string[]>,
options: { requireConfirmed?: boolean } = {}
): Promise<string[]> {
const observation = await observeRunningWslDistros(probe)
if (options.requireConfirmed && !observation.confirmed) {
throw new Error('WSL running-distro discovery is unavailable. Retry when WSL is reachable.')
}
return observation.distros
}
function observeRunningWslDistros(
probe: () => Promise<string[]>
): Promise<RunningDistroObservation> {
if (inFlightProbe) {
return inFlightProbe
}
if (Date.now() < retryAfterMs) {
return Promise.resolve(cache ?? [])
return Promise.resolve({ distros: cache ?? [], confirmed: false })
}
const result = probe()
.then((distros) => {
cache = distros
retryAfterMs = 0
failureStreak = 0
return cache
return { distros, confirmed: true }
})
.catch((error: unknown) => {
armRetryAfterFailure()
console.warn('[wsl] running-distro probe failed; falling back to last-known-good list', error)
return cache ?? []
return { distros: cache ?? [], confirmed: false }
})
.finally(() => {
if (inFlightProbe === result) {
+74
View File
@@ -67,6 +67,65 @@ describe('running WSL distro discovery', () => {
})
})
it('rejects stale running state for launch admission through failure and backoff', async () => {
vi.useFakeTimers()
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'Ubuntu\n')
})
await withPlatform('win32', async () => {
await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(new Error('wsl unavailable'), '')
})
const paths = ['\\\\wsl.localhost\\Ubuntu\\home\\ada']
await expect(
filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true })
).rejects.toThrow('discovery is unavailable')
await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
await expect(listRunningWslDistrosAsync({ requireConfirmed: true })).rejects.toThrow(
'discovery is unavailable'
)
expect(execFileMock).toHaveBeenCalledTimes(2)
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(null, '')
})
await vi.advanceTimersByTimeAsync(15_001)
await expect(
filterPathsToRunningWslDistrosAsync(paths, { requireConfirmed: true })
).resolves.toEqual([])
expect(execFileMock).toHaveBeenCalledTimes(3)
})
})
it.each([true, false])(
'shares the probe while preserving admission and observer failure policies (strict first: %s)',
async (strictFirst) => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'Ubuntu\n')
})
await withPlatform('win32', async () => {
await listRunningWslDistrosAsync()
let finishProbe: ((error: Error | null, output: string) => void) | undefined
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
finishProbe = callback
})
const first = listRunningWslDistrosAsync({ requireConfirmed: strictFirst })
const second = listRunningWslDistrosAsync({ requireConfirmed: !strictFirst })
const strict = strictFirst ? first : second
const observer = strictFirst ? second : first
const rejected = expect(strict).rejects.toThrow('discovery is unavailable')
expect(execFileMock).toHaveBeenCalledTimes(2)
finishProbe?.(new Error('wsl unavailable'), '')
await rejected
await expect(observer).resolves.toEqual(['Ubuntu'])
expect(execFileMock).toHaveBeenCalledTimes(2)
})
}
)
it('resolves homes only for the running distro set', async () => {
execFileMock.mockImplementation((_command, args, _options, callback) => {
callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n')
@@ -139,6 +198,21 @@ describe('running WSL distro discovery', () => {
})
})
it('shares a confirmed fresh probe between discovery and launch admission', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) =>
callback(null, 'Ubuntu\n')
)
await withPlatform('win32', async () => {
expect(
await Promise.all([
listRunningWslDistrosAsync(),
listRunningWslDistrosAsync({ requireConfirmed: true })
])
).toEqual([['Ubuntu'], ['Ubuntu']])
expect(execFileMock).toHaveBeenCalledOnce()
})
})
it('filters stopped-distro UNC paths while preserving host paths', async () => {
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(null, 'Ubuntu\n')
+3 -2
View File
@@ -14,11 +14,12 @@ export function filterPathsToWslDistros(
/** Keep host paths and WSL paths whose distro is running now. */
export async function filterPathsToRunningWslDistrosAsync(
paths: readonly string[]
paths: readonly string[],
options?: { requireConfirmed?: boolean }
): Promise<string[]> {
if (process.platform !== 'win32') {
return [...paths]
}
const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync() : []
const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync(options) : []
return filterPathsToWslDistros(paths, runningDistros)
}
+10 -6
View File
@@ -230,15 +230,19 @@ export async function listWslDistrosAsync(): Promise<string[]> {
/** Running user distros only — see `resolveRunningWslDistros` for the fallback/backoff and
* single-flight contract shared by every caller. */
export async function listRunningWslDistrosAsync(): Promise<string[]> {
export async function listRunningWslDistrosAsync(
options: { requireConfirmed?: boolean } = {}
): Promise<string[]> {
if (process.platform !== 'win32') {
return []
}
return resolveRunningWslDistros(() =>
execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], {
...process.env,
WSL_UTF8: '1'
}).then((output) => filterUserWslDistros(parseWslDistros(output)))
return resolveRunningWslDistros(
() =>
execFileUtf8('wsl.exe', ['--list', '--running', '--quiet'], {
...process.env,
WSL_UTF8: '1'
}).then((output) => filterUserWslDistros(parseWslDistros(output))),
options
)
}
+4 -1
View File
@@ -393,7 +393,10 @@ function createTestService(
return {
listSessions: (params, signal) =>
scan({
provider: createRelayAiVaultFilesystemProvider(),
provider: createRelayAiVaultFilesystemProvider({
homeDirectory: remoteHome,
environment: {}
}),
executionHostId: 'local',
remoteHome,
hostPlatform,
+253
View File
@@ -0,0 +1,253 @@
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,
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: expect.stringContaining('waiting')
})
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),
parse: async (args) =>
args.agent === 'opencode2'
? parseOpenCode2SqliteSession(args)
: parseOpenCodeSqliteSession(args),
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' })]
})
})
})
+159
View File
@@ -0,0 +1,159 @@
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 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('OpenCode history is waiting for its database reader on this host.')
}
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()
}
}
function canCurrentRuntimeReadSqlite(): boolean {
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()
}
}
+4 -2
View File
@@ -19,7 +19,7 @@ if (!process.send) {
const controllers = new Map<number, AbortController>()
const cancelled = new Set<number>()
const pending = new Set<number>()
const provider = createRelayAiVaultFilesystemProvider()
let provider: ReturnType<typeof createRelayAiVaultFilesystemProvider> | null = null
let init: RelayAiVaultServiceInit | null = null
let cacheLane = Promise.resolve()
let interactiveLane = Promise.resolve()
@@ -36,7 +36,7 @@ async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
controller.abort()
}
try {
if (!init) {
if (!init || !provider) {
throw new Error('Relay AI Vault service is not initialized.')
}
if (request.operation === 'titles') {
@@ -79,6 +79,7 @@ async function shutdown(): Promise<void> {
controller.abort()
}
await Promise.allSettled([cacheLane, interactiveLane])
provider?.dispose()
process.disconnect?.()
}
@@ -89,6 +90,7 @@ process.on('message', (raw: RelayAiVaultServiceParentMessage) => {
return
}
init = raw
provider = createRelayAiVaultFilesystemProvider({ homeDirectory: raw.remoteHome })
send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid })
return
}
+10 -1
View File
@@ -2,9 +2,18 @@ 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(): RemoteSessionFilesystemProvider {
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) => ({
+2
View File
@@ -47,11 +47,13 @@ export type RelayArtifact = {
/** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */
export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node'
export const RELAY_OPENCODE_SQLITE_READER_FILENAME = 'opencode-sqlite-reader.cjs'
export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
{ filename: 'relay.js' },
{ filename: 'relay-watcher.js', daemonServiceChild: true },
{ filename: 'relay-ai-vault-service.js', daemonServiceChild: true },
{ filename: RELAY_OPENCODE_SQLITE_READER_FILENAME },
{ filename: 'managed-hook-runtime.js' },
// Forked by the AI Vault title reader; without it a relay answers every WSL
// title request with no title and no error.