Files
orca/src/relay/ai-vault-opencode-reader.ts
T
OrcaWinandm4air da6d483ab9 fix(vault): read OpenCode SQLite inside WSL and SSH hosts (#23128)
* fix(vault): read OpenCode SQLite on WSL and SSH execution hosts

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

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

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

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

* fix(queue): discard aborted requests before respawn

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

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

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

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 03:59:38 -07:00

160 lines
5.3 KiB
TypeScript

import { open } from 'node:fs/promises'
import { homedir } from 'node:os'
import { isAbsolute, join } from 'node:path'
import type { RemoteOpenCodeSessionReader } from '../main/ai-vault/remote-session-scanner-types'
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from '../main/ai-vault/remote-session-file-stat'
import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-opencode-parser'
import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client'
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory'
import SyncDatabase from '../main/sqlite/sync-database'
type Reader = Pick<RemoteOpenCodeSessionReader, 'list' | 'parse'> & { dispose(): void }
export type RelayOpenCodeReaderOptions = {
baseDir?: string
environment?: NodeJS.ProcessEnv
homeDirectory?: string
currentExecutable?: string
readerFactory?: (options: Parameters<typeof createOpenCodeSqliteProcessClient>[0]) => Reader
canReadSqlite?: () => boolean
}
export function createRelayOpenCodeReader(
options: RelayOpenCodeReaderOptions = {}
): RemoteOpenCodeSessionReader & { dispose(): void } {
const environment = options.environment ?? process.env
const dataDirectory = resolveOpenCodeDataDirectory(
environment,
options.homeDirectory ?? homedir()
)
const override = environment.OPENCODE_DB?.trim()
const baseDir = options.baseDir ?? __dirname
let reader: Reader | undefined
let executable: string | undefined
let pending: Promise<Reader> | undefined
let disposed = false
const getReader = (refreshRuntime = false): Promise<Reader> => {
if (disposed) {
return Promise.reject(new Error('OpenCode database reader was disposed.'))
}
if (reader && !refreshRuntime) {
return Promise.resolve(reader)
}
pending ??= (async () => {
const configured = await readRuntimeExecutable(join(baseDir, 'opencode-sqlite-runtime.json'))
if (!configured && !(options.canReadSqlite ?? canCurrentRuntimeReadSqlite)()) {
throw new Error('OpenCode history is waiting for its database reader on this host.')
}
if (disposed) {
throw new Error('OpenCode database reader was disposed.')
}
const nextExecutable = configured ?? options.currentExecutable ?? process.execPath
if (!reader || executable !== nextExecutable) {
reader?.dispose()
reader = (options.readerFactory ?? createOpenCodeSqliteProcessClient)({
executable: nextExecutable,
args: [join(baseDir, 'opencode-sqlite-reader.cjs')],
env: buildRelayAiVaultServiceEnv(environment)
})
executable = nextExecutable
}
return reader
})().finally(() => {
pending = undefined
})
return pending
}
return {
dataDirectory,
...(override
? {
databasePath:
override === ':memory:'
? null
: isAbsolute(override)
? override
: join(dataDirectory, override)
}
: {}),
async list(args) {
throwIfAiVaultScanCancelled(args.signal)
if (args.dbPaths.length === 0) {
return []
}
try {
return await (await getReader(true)).list(args)
} catch (error) {
throwIfAiVaultScanCancelled(args.signal)
args.issues.push({
agent: args.agent ?? 'opencode',
kind: 'scope',
path: args.dbPaths[0] ?? dataDirectory,
message: error instanceof Error ? error.message : String(error)
})
return []
}
},
async parse(args) {
throwIfAiVaultScanCancelled(args.signal)
return (await getReader()).parse(args)
},
parseLegacy: parseOpenCodeSessionFile,
dispose() {
if (disposed) {
return
}
disposed = true
reader?.dispose()
}
}
}
async function readRuntimeExecutable(path: string): Promise<string | undefined> {
let file
try {
file = await open(path, 'r')
} catch (error) {
if (isMissingRemoteSessionPathError(error)) {
return undefined
}
throw error
}
try {
const bytes = Buffer.alloc(16_385)
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0)
if (bytesRead > 16_384) {
throw new Error('OpenCode database runtime reference exceeds its size limit.')
}
const reference: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'))
if (
typeof reference !== 'object' ||
reference === null ||
!('protocol' in reference) ||
reference.protocol !== 1 ||
!('executable' in reference) ||
typeof reference.executable !== 'string' ||
!isAbsolute(reference.executable) ||
reference.executable.includes('\0')
) {
throw new Error('OpenCode database runtime reference is invalid.')
}
return reference.executable
} finally {
await file.close()
}
}
function canCurrentRuntimeReadSqlite(): boolean {
let db: SyncDatabase | undefined
try {
db = new SyncDatabase(':memory:')
return db.prepare('SELECT 1 AS ready').get()?.ready === 1
} catch {
return false
} finally {
db?.close()
}
}