mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,10 @@ function createTestService(
|
||||
return {
|
||||
listSessions: (params, signal) =>
|
||||
scan({
|
||||
provider: createRelayAiVaultFilesystemProvider(),
|
||||
provider: createRelayAiVaultFilesystemProvider({
|
||||
homeDirectory: remoteHome,
|
||||
environment: {}
|
||||
}),
|
||||
executionHostId: 'local',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
|
||||
@@ -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' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user