import { existsSync } from 'node:fs' import { Worker } from 'node:worker_threads' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' 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' import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path' import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope' // 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 // scanner call sites depend only on the two routing functions below. // The shared foreign SQLite reader worker runs OpenCode reads too. export function resolveOpenCodeSqliteWorkerEntryPath( runtimeDir = __dirname, pathExists: (path: string) => boolean = existsSync ): string { return findForeignSqliteReaderEntry(runtimeDir, pathExists) } function defaultWorkerFactory(): Worker { const workerPath = resolveOpenCodeSqliteWorkerEntryPath() // Why: a missing built entry must throw synchronously so the client can fail // closed before it waits on a worker that can never post a result. if (!existsSync(workerPath)) { throw new Error(`OpenCode SQLite worker entry not found: ${workerPath}`) } return new Worker(workerPath) } let sharedClient: OpenCodeSqliteWorkerClient | null = null function getSharedClient(): OpenCodeSqliteWorkerClient { sharedClient ??= new OpenCodeSqliteWorkerClient({ workerFactory: defaultWorkerFactory }) return sharedClient } /** * List OpenCode SQLite session candidates through the shared worker client. * @param args.dbPaths - Absolute paths to opencode.db files to scan. * @param args.limit - Maximum number of sessions to return per database. * @param args.issues - Collected scan issues to append errors to. * @returns Synthetic candidates sorted by effective recency. */ export function listOpenCodeSqliteSessionsViaWorker(args: { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] signal?: AbortSignal }): Promise { return listForHost(args) } /** * List opencode2 session candidates (v2 channel-scoped DB schema) through the * shared worker client. */ export function listOpenCode2SqliteSessionsViaWorker(args: { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] signal?: AbortSignal }): Promise { return listForHost({ ...args, agent: 'opencode2' }) } export function listZcodeSqliteSessionsViaWorker(args: { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] signal?: AbortSignal }): Promise { return listForHost({ ...args, agent: 'zcode' }) } /** * Parse one OpenCode SQLite session through the shared worker client. * @param args.dbPath - Absolute path to the opencode.db file. * @param args.sessionId - Primary key in the `session` table. * @param args.platform - Platform used for resume-command generation. * @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 parseForHost(args) } export function parseOpenCode2SqliteSessionViaWorker(args: { fullFirstUserPrompt?: boolean dbPath: string sessionId: string platform: NodeJS.Platform signal?: AbortSignal }): Promise { return parseForHost({ ...args, agent: 'opencode2' }) } export function parseZcodeSqliteSessionViaWorker(args: { fullFirstUserPrompt?: boolean dbPath: string sessionId: string platform: NodeJS.Platform signal?: AbortSignal }): Promise { return parseForHost({ ...args, agent: 'zcode' }) } /** * Read one OpenCode SQLite session and its whole transcript through the shared * worker client. * @param args.dbPath - Absolute path to the opencode.db file. * @param args.sessionId - Primary key in the `session` table. * @param args.platform - Platform used for resume-command generation. * @returns The session and every message it holds. */ export function captureOpenCodeSqliteSessionViaWorker(args: { dbPath: string sessionId: string platform: NodeJS.Platform signal?: AbortSignal }): Promise { return captureForHost(args) } export function captureOpenCode2SqliteSessionViaWorker(args: { dbPath: string sessionId: string platform: NodeJS.Platform signal?: AbortSignal }): Promise { return captureForHost({ ...args, agent: 'opencode2' }) } export function captureZcodeSqliteSessionViaWorker(args: { dbPath: string sessionId: string platform: NodeJS.Platform signal?: AbortSignal }): Promise { return captureForHost({ ...args, agent: 'zcode' }) } 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 result = await runOpenCodeSqliteScanRequest( args.signal, async (signal) => { const client = await openCodeWslClient(distro, first, signal) return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues }) }, args.agent ) return result.flatMap((candidate) => { const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent) 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 session = await runOpenCodeSqliteScanRequest( args.signal, async (signal) => { const client = await openCodeWslClient(wsl.distro, args.dbPath, signal) return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' }) }, args.agent ) return mapOpenCodeWslSession(session, args.dbPath) } async function captureForHost( args: Parameters[0] ): Promise { const wsl = openCodeWslPath(args.dbPath) if (!wsl) { return getSharedClient().capture(args) } const capture = await runOpenCodeSqliteScanRequest( args.signal, async (signal) => { const client = await openCodeWslClient(wsl.distro, args.dbPath, signal) return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' }) }, args.agent ) return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) } } export async function readOpenCodeTranscriptPageViaWorker( args: { dbPath: string sessionId: string limit: number beforeMessageRowId?: number }, signal?: AbortSignal ) { const wsl = openCodeWslPath(args.dbPath) const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient() const value = await client.readNativeChat( { ...args, limit: openCodeTranscriptPageLimit(args.limit), dbPath: wsl?.linuxPath ?? args.dbPath, kind: 'native-page' }, signal ) if (value !== null && !('items' in value)) { throw new Error('Invalid OpenCode transcript page') } return value } export async function readOpenCodeTranscriptSignalViaWorker( args: { dbPath: string sessionId: string }, signal?: AbortSignal ) { const wsl = openCodeWslPath(args.dbPath) const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient() const value = await client.readNativeChat( { ...args, dbPath: wsl?.linuxPath ?? args.dbPath, kind: 'native-signal' }, signal ) if (value !== null && !('messageCount' in value)) { throw new Error('Invalid OpenCode transcript signal') } return value }