diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 4d408712f97..c67ece1dca8 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -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({ diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs index 2796a361778..7177b9821f8 100644 --- a/config/scripts/relay-artifact-manifest.test.mjs +++ b/config/scripts/relay-artifact-manifest.test.mjs @@ -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') diff --git a/src/main/ai-vault-search/session-search-scan-roots.test.ts b/src/main/ai-vault-search/session-search-scan-roots.test.ts index 39324ca5da8..0d30ca632e8 100644 --- a/src/main/ai-vault-search/session-search-scan-roots.test.ts +++ b/src/main/ai-vault-search/session-search-scan-roots.test.ts @@ -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) +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.ts b/src/main/ai-vault-search/session-search-scan-roots.ts index 5c1041eac25..d8eb766a371 100644 --- a/src/main/ai-vault-search/session-search-scan-roots.ts +++ b/src/main/ai-vault-search/session-search-scan-roots.ts @@ -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() } diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts index e5857faf378..0c2ef27316a 100644 --- a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -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' diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 673de66e666..0d1cbabb7c8 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -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 + Pick > { 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 diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts new file mode 100644 index 00000000000..d78130a119f --- /dev/null +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -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) => 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) => 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 }) + } + } + ) +}) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts new file mode 100644 index 00000000000..c9bc81fff77 --- /dev/null +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -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() +const downloads = new Map>() +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 { + if (process.platform !== 'win32') { + return [] + } + const distros = new Map() + 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 { + const deadline = Date.now() + PREPARATION_TIMEOUT_MS + const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS) + const run = async (spec: WslSpec): Promise => { + 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 } +} diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts index 73c33aec27f..e6bac42fd7e 100644 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -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') } diff --git a/src/main/ai-vault/remote-session-parse-cache.ts b/src/main/ai-vault/remote-session-parse-cache.ts index c892c4961ef..24d63c3dc2a 100644 --- a/src/main/ai-vault/remote-session-parse-cache.ts +++ b/src/main/ai-vault/remote-session-parse-cache.ts @@ -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 { 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, diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts index e74375259f5..a336af84a06 100644 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -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)), diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 1fb25213f32..f6b13b24e30 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -22,6 +22,11 @@ export async function discoverRemoteSourceCandidates(args: { context: RemoteScannerContext issues: AiVaultScanIssue[] }): Promise { + 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) diff --git a/src/main/ai-vault/remote-session-scanner-opencode-source.ts b/src/main/ai-vault/remote-session-scanner-opencode-source.ts new file mode 100644 index 00000000000..c42f4b6cb09 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-opencode-source.ts @@ -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 | 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 { + 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 [] + } +} diff --git a/src/main/ai-vault/remote-session-scanner-opencode.test.ts b/src/main/ai-vault/remote-session-scanner-opencode.test.ts new file mode 100644 index 00000000000..573ee1939cf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-opencode.test.ts @@ -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() + 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) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 7f78cbb5d60..408a2ca47be 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -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 & { + dataDirectory: string + databasePath?: string | null + parseLegacy: (file: FileWithMtime, platform: NodeJS.Platform) => Promise +} 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 + /** 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 + parseCandidate?: ( + file: FileWithMtime, + context: RemoteScannerContext + ) => Promise // 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 diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 2ebaba9103a..17f9bdc35a8 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -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 }> { +}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set }> { const sessions = new ScannedSessionCollection() - const parsedFilePaths = new Set() + const parsedCandidateKeys = new Set() 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 + alreadyParsedCandidateKeys: ReadonlySet }): Promise { 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[] = [] diff --git a/src/main/ai-vault/remote-session-transcript-read.ts b/src/main/ai-vault/remote-session-transcript-read.ts index 42ea29af409..bc9d4a59532 100644 --- a/src/main/ai-vault/remote-session-transcript-read.ts +++ b/src/main/ai-vault/remote-session-transcript-read.ts @@ -9,6 +9,9 @@ export async function parseRemoteSessionTranscript( candidate: RemoteSessionCandidate, context: RemoteScannerContext ): Promise { + 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 || diff --git a/src/main/ai-vault/session-first-user-prompt-opencode.test.ts b/src/main/ai-vault/session-first-user-prompt-opencode.test.ts new file mode 100644 index 00000000000..c0d81a531a6 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-opencode.test.ts @@ -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 }) +}) diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index ac5a35f2a53..cf1a5bb2bed 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -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 { - // 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 diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 7ca808173cf..c95dd8d7eac 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -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 { - 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 { 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 } diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts index 6903e689a64..70eca2434ef 100644 --- a/src/main/ai-vault/session-scanner-background.ts +++ b/src/main/ai-vault/session-scanner-background.ts @@ -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 { + 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) diff --git a/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts new file mode 100644 index 00000000000..4f47af380cd --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts @@ -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(), + capture: vi.fn(), + discover: vi.fn<() => Promise>() +})) +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 { + 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() + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts b/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts new file mode 100644 index 00000000000..5f8920043a1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-process-admission.test.ts @@ -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[] = [] +afterEach(() => { + clients.splice(0).forEach((client) => client.dispose()) + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +function reader(beforeSpawn: (signal: AbortSignal) => Promise, 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((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() + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index ea93787d642..5f15f17d758 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -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 { - const files = await listOpenCode2SqliteSessionsViaWorker({ dbPaths, limit, issues }) + const files = await listOpenCode2SqliteSessionsViaWorker({ dbPaths, limit, issues, signal }) return { agent: 'opencode2' as const, rootDir: storageDir, diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts index a32ca1b2447..f8d8a84a7a9 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts @@ -40,6 +40,7 @@ export async function discoverOpenCodeSessions(args: { dbPaths: readonly string[] limitPerAgent: number issues: AiVaultScanIssue[] + signal?: AbortSignal }): Promise { 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 }) ]) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts new file mode 100644 index 00000000000..7632aa070c0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -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 { + 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) } + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts new file mode 100644 index 00000000000..59e12071dec --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-client.ts @@ -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 +} + +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 { + 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 +): 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 { + controller.abort() + transport?.removeAllListeners() + return transport ? transport.terminate() : 0 + } + }) +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts new file mode 100644 index 00000000000..9c197b1ecea --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts @@ -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 | 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)) +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-framing.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-framing.ts new file mode 100644 index 00000000000..9458456cd8d --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-framing.ts @@ -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` +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts new file mode 100644 index 00000000000..91402885721 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts @@ -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((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() + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index 1103a8379e5..498bb9a65f5 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -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 { 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 { 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 { 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 { - 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) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts index 7441bb1564a..704f80a53a7 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts @@ -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 { - 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 { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index e18cad32c2c..d44bd62c11a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index c1e89e9fc79..1b3b1faf7ce 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -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 { - 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 { - 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 { - return getSharedClient().parse(args) + return parseForHost(args) } export function parseOpenCode2SqliteSessionViaWorker(args: { + fullFirstUserPrompt?: boolean dbPath: string sessionId: string platform: NodeJS.Platform + signal?: AbortSignal }): Promise { - 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 { - return getSharedClient().capture(args) + return captureForHost(args) } export function captureOpenCode2SqliteSessionViaWorker(args: { dbPath: string sessionId: string platform: NodeJS.Platform + signal?: AbortSignal }): Promise { - return getSharedClient().capture({ ...args, agent: 'opencode2' }) + return captureForHost({ ...args, agent: 'opencode2' }) +} + +async function listForHost( + args: Parameters[0] +): Promise { + throwIfSignalAborted(args.signal) + const native: string[] = [] + const groups = new Map }>() + 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() } + 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[0] +): Promise { + 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[0] +): Promise { + 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) } } diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts b/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts new file mode 100644 index 00000000000..2d0e52bfc81 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-wsl-client.test.ts @@ -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() + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-client.ts b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts new file mode 100644 index 00000000000..1c93c2616a6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-wsl-client.ts @@ -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() +const clients = new Map() + +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 { + return process.platform === 'win32' ? parseWslUncPath(path) : null +} + +export async function openCodeWslClient( + distro: string, + dbPath: string, + signal?: AbortSignal +): Promise { + 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' + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts new file mode 100644 index 00000000000..754211c323c --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts @@ -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 => []), + 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()), + 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() + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-runtime.ts b/src/main/ai-vault/session-scanner-opencode-wsl-runtime.ts new file mode 100644 index 00000000000..df03c61ef6d --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-wsl-runtime.ts @@ -0,0 +1,4 @@ +export type OpenCodeWslRuntime = { distro: string } & ( + | { executable: string; readerPath: string; error?: never } + | { error: string; executable?: never; readerPath?: never } +) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a5eb98235e4..a9dff003948 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -114,12 +114,13 @@ export async function parseAgentSessionFileCached( candidate: SessionFileCandidate, platform: NodeJS.Platform, stats?: SessionParseStats, - requireRead?: SessionParseReadRequirement + requireRead?: SessionParseReadRequirement, + signal?: AbortSignal ): Promise { // 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 { 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. diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index b04db9e0c92..e658cb63bbb 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -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' + }) }) diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index 257ba676d8c..063df2dcc63 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -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 + ) } diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index d62fdd4d19d..5bb53a605ba 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -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 { 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 diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index ede4173a090..75a10283562 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts index d217c2bf489..8e912021e5f 100644 --- a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -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()), remoteSessionParseHostKey: () => 'fixture', parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) => candidate.session diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index c730eac6385..2f5629f94c3 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -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 { 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) } diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts index 8a5cd4e93e7..f9eb4e91b11 100644 --- a/src/main/ai-vault/session-transcript-reader.ts +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -160,6 +160,7 @@ export async function readWholeTranscript(args: { candidate: SessionFileCandidate platform: NodeJS.Platform stats?: TranscriptReadStats + signal?: AbortSignal }): Promise { 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) { diff --git a/src/main/lazy-worker-thread-host.ts b/src/main/lazy-worker-thread-host.ts index 01f15eb1c66..2de02689afc 100644 --- a/src/main/lazy-worker-thread-host.ts +++ b/src/main/lazy-worker-thread-host.ts @@ -1,6 +1,12 @@ import type { Worker } from 'node:worker_threads' -export type WorkerThreadFactory = () => Worker +export type WorkerRequestTransport = Pick & { + on(...args: Parameters): unknown + off(...args: Parameters): 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 { - 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 { } ) {} - 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 } diff --git a/src/main/ssh/orcad-deployment-target.test.ts b/src/main/ssh/orcad-deployment-target.test.ts index d833f560be9..db5915e659c 100644 --- a/src/main/ssh/orcad-deployment-target.test.ts +++ b/src/main/ssh/orcad-deployment-target.test.ts @@ -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() + 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'], diff --git a/src/main/ssh/orcad-deployment-target.ts b/src/main/ssh/orcad-deployment-target.ts index 7d822cc6ac7..9fd98fa8cfb 100644 --- a/src/main/ssh/orcad-deployment-target.ts +++ b/src/main/ssh/orcad-deployment-target.ts @@ -17,22 +17,22 @@ export async function resolveOrcadDeploymentTarget(options: { conn: SshConnection host: RemoteHostPlatform signal?: AbortSignal + exec?: (command: string) => Promise }): Promise { 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)}` diff --git a/src/main/ssh/relay-bundle-paths.ts b/src/main/ssh/relay-bundle-paths.ts new file mode 100644 index 00000000000..d20dccb4cdb --- /dev/null +++ b/src/main/ssh/relay-bundle-paths.ts @@ -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) + ]) + ] +} diff --git a/src/main/ssh/relay-socket-path-limit.test.ts b/src/main/ssh/relay-socket-path-limit.test.ts index ce878765e8d..8d58fbb10e0 100644 --- a/src/main/ssh/relay-socket-path-limit.test.ts +++ b/src/main/ssh/relay-socket-path-limit.test.ts @@ -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), diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 98d383c5c92..afc3ed6240b 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -224,6 +224,9 @@ export class SshConnection { getState(): SshConnectionState { return { ...this.state } } + getConnectGeneration(): number { + return this.connectGeneration + } getClient(): SshClient | null { return this.client } diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index acda3bf0d69..be66463206a 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -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()), createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' diff --git a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts index ef90e829f7b..4826f3c4dcc 100644 --- a/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts +++ b/src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts @@ -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() + } } ) }) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 467ddc5f788..09ebb135479 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -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()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index 4c913234117..fa800097bd7 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -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()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index f2fdaee4ed2..e8eb862848e 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -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()), 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((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((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((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() diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index aab5e6c39d3..f1e8377025e 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -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( diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts index ffc859df223..8d7a1988a97 100644 --- a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -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), diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index df15135963a..5c1e99eb80d 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -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()), ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index 395aed06f95..69c7ae1d2a5 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -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), diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts index 1f8fd69b8dd..7eda53e684d 100644 --- a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts new file mode 100644 index 00000000000..72b5254c322 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -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 { + 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 }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts new file mode 100644 index 00000000000..14900f15e5b --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts @@ -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 } +} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts new file mode 100644 index 00000000000..be8fde1fc5e --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-retry.test.ts @@ -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((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() + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-retry.ts b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts new file mode 100644 index 00000000000..103da40a6cd --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime-retry.ts @@ -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 + +export function createRemoteOpenCodeRuntimeRetry( + initialSetup: Promise, + backgroundCleanup: Promise, + retry: (signal: AbortSignal) => Promise +): RemoteOpenCodeRuntimePreparation { + let nextAttempt = Infinity + let pending: Promise | 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 + } +} diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts new file mode 100644 index 00000000000..ce3da563b97 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime.test.ts @@ -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((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() + const finish = Promise.withResolvers() + 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() + const currentProbe = Promise.withResolvers() + 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((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((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() + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts new file mode 100644 index 00000000000..83657e8b7f2 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-runtime.ts @@ -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> } +>() +const downloads = new Map>() + +type SetupOptions = { + nodePath: string + relayDir: string + signal?: AbortSignal + cacheRoot?: string +} +type RemoteOperation = (operation: () => Promise) => Promise + +/** 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 { + 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 { + const exec = async (command: string): Promise => { + 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 { + 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) +} diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index 5b2a42d87c7..f7fd4d65794 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -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), diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index dc9ff181b2f..5b48337f0ab 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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) => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b739fbd3d8c..3892781dfab 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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() @@ -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 diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 65459b8bcba..5bf0cc3ab53 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -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), diff --git a/src/main/worker-thread-request-queue.test.ts b/src/main/worker-thread-request-queue.test.ts index d7f29266acd..1c1616d8839 100644 --- a/src/main/worker-thread-request-queue.test.ts +++ b/src/main/worker-thread-request-queue.test.ts @@ -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) diff --git a/src/main/worker-thread-request-queue.ts b/src/main/worker-thread-request-queue.ts index 3c8f6d0a711..08c27e0eda4 100644 --- a/src/main/worker-thread-request-queue.ts +++ b/src/main/worker-thread-request-queue.ts @@ -43,6 +43,8 @@ type PendingCall = { 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[] = [] private consecutiveDeaths = 0 private nextId = 1 + private disposed = false private readonly host: LazyWorkerThreadHost constructor(private readonly options: WorkerThreadRequestQueueOptions) { @@ -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 { + dispatch( + buildRequest: (id: number) => TRequest, + timeoutMs: number, + signal?: AbortSignal + ): Promise { 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 = { 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, run: () => void): void { + call.cleanupAbort() if (call.timer) { clearTimeout(call.timer) call.timer = null diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts index 436fbe419cb..07a55a47bcd 100644 --- a/src/main/wsl-running-distro-cache.ts +++ b/src/main/wsl-running-distro-cache.ts @@ -8,7 +8,8 @@ import { wslDistroListRetryDelayMs } from './wsl-distro-retry' let cache: string[] | null = null let retryAfterMs = 0 let failureStreak = 0 -let inFlightProbe: Promise | null = null +type RunningDistroObservation = { distros: string[]; confirmed: boolean } +let inFlightProbe: Promise | 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): Promise { +export async function resolveRunningWslDistros( + probe: () => Promise, + options: { requireConfirmed?: boolean } = {} +): Promise { + 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 +): Promise { 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) { diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts index 049c2f2e379..4f80e5eb5c2 100644 --- a/src/main/wsl-running-distros.test.ts +++ b/src/main/wsl-running-distros.test.ts @@ -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') diff --git a/src/main/wsl-running-path-filter.ts b/src/main/wsl-running-path-filter.ts index 7384229757f..370e5b4783c 100644 --- a/src/main/wsl-running-path-filter.ts +++ b/src/main/wsl-running-path-filter.ts @@ -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 { 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) } diff --git a/src/main/wsl.ts b/src/main/wsl.ts index be69dde401a..007b2f1c8bc 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -230,15 +230,19 @@ export async function listWslDistrosAsync(): Promise { /** Running user distros only — see `resolveRunningWslDistros` for the fallback/backoff and * single-flight contract shared by every caller. */ -export async function listRunningWslDistrosAsync(): Promise { +export async function listRunningWslDistrosAsync( + options: { requireConfirmed?: boolean } = {} +): Promise { 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 ) } diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts index eb90958d0a2..3951b58ea5f 100644 --- a/src/relay/ai-vault-handler.test.ts +++ b/src/relay/ai-vault-handler.test.ts @@ -393,7 +393,10 @@ function createTestService( return { listSessions: (params, signal) => scan({ - provider: createRelayAiVaultFilesystemProvider(), + provider: createRelayAiVaultFilesystemProvider({ + homeDirectory: remoteHome, + environment: {} + }), executionHostId: 'local', remoteHome, hostPlatform, diff --git a/src/relay/ai-vault-opencode-reader.test.ts b/src/relay/ai-vault-opencode-reader.test.ts new file mode 100644 index 00000000000..981ade1fd12 --- /dev/null +++ b/src/relay/ai-vault-opencode-reader.test.ts @@ -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 { + 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>[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>() + .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 = () => ({ + 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' })] + }) + }) +}) diff --git a/src/relay/ai-vault-opencode-reader.ts b/src/relay/ai-vault-opencode-reader.ts new file mode 100644 index 00000000000..d90f2e08cd1 --- /dev/null +++ b/src/relay/ai-vault-opencode-reader.ts @@ -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 & { dispose(): void } + +export type RelayOpenCodeReaderOptions = { + baseDir?: string + environment?: NodeJS.ProcessEnv + homeDirectory?: string + currentExecutable?: string + readerFactory?: (options: Parameters[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 | undefined + let disposed = false + const getReader = (refreshRuntime = false): Promise => { + 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 { + 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() + } +} diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 9bd3ea35786..3f0a2b02aaf 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -19,7 +19,7 @@ if (!process.send) { const controllers = new Map() const cancelled = new Set() const pending = new Set() -const provider = createRelayAiVaultFilesystemProvider() +let provider: ReturnType | 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 { 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 { 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 } diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts index fd5ff90a755..58b1b1f2d6e 100644 --- a/src/relay/ai-vault-service-filesystem.ts +++ b/src/relay/ai-vault-service-filesystem.ts @@ -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) => ({ diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts index 73ce8c7af1b..4e4c834f220 100644 --- a/src/shared/relay-artifacts.ts +++ b/src/shared/relay-artifacts.ts @@ -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.