mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* fix(opencode): keep scan budgets across queue waits and batches Reuse the scan-owned lifetime proposed in #10708 by @AmethystLiang with the existing shared worker queue. * test(opencode): check nonempty session fixtures and lint scoped controls * Derive OpenCode scan deadline message from its budget --------- Co-authored-by: Neil Parker <nwparker@MacBook-Pro-3.localdomain> Co-authored-by: OpenCode issue campaign <codex@localhost>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
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<SessionFileCandidate[]> {
|
|
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<SessionFileCandidate[]> {
|
|
return listForHost({ ...args, agent: 'opencode2' })
|
|
}
|
|
|
|
export function listZcodeSqliteSessionsViaWorker(args: {
|
|
dbPaths: readonly string[]
|
|
limit: number
|
|
issues: AiVaultScanIssue[]
|
|
signal?: AbortSignal
|
|
}): Promise<SessionFileCandidate[]> {
|
|
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<AiVaultSession | null> {
|
|
return parseForHost(args)
|
|
}
|
|
|
|
export function parseOpenCode2SqliteSessionViaWorker(args: {
|
|
fullFirstUserPrompt?: boolean
|
|
dbPath: string
|
|
sessionId: string
|
|
platform: NodeJS.Platform
|
|
signal?: AbortSignal
|
|
}): Promise<AiVaultSession | null> {
|
|
return parseForHost({ ...args, agent: 'opencode2' })
|
|
}
|
|
|
|
export function parseZcodeSqliteSessionViaWorker(args: {
|
|
fullFirstUserPrompt?: boolean
|
|
dbPath: string
|
|
sessionId: string
|
|
platform: NodeJS.Platform
|
|
signal?: AbortSignal
|
|
}): Promise<AiVaultSession | null> {
|
|
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<OpenCodeSqliteCaptureValue> {
|
|
return captureForHost(args)
|
|
}
|
|
|
|
export function captureOpenCode2SqliteSessionViaWorker(args: {
|
|
dbPath: string
|
|
sessionId: string
|
|
platform: NodeJS.Platform
|
|
signal?: AbortSignal
|
|
}): Promise<OpenCodeSqliteCaptureValue> {
|
|
return captureForHost({ ...args, agent: 'opencode2' })
|
|
}
|
|
|
|
export function captureZcodeSqliteSessionViaWorker(args: {
|
|
dbPath: string
|
|
sessionId: string
|
|
platform: NodeJS.Platform
|
|
signal?: AbortSignal
|
|
}): Promise<OpenCodeSqliteCaptureValue> {
|
|
return captureForHost({ ...args, agent: 'zcode' })
|
|
}
|
|
|
|
async function listForHost(
|
|
args: Parameters<OpenCodeSqliteWorkerClient['list']>[0]
|
|
): Promise<SessionFileCandidate[]> {
|
|
throwIfSignalAborted(args.signal)
|
|
const native: string[] = []
|
|
const groups = new Map<string, { distro: string; paths: Map<string, string> }>()
|
|
for (const path of args.dbPaths) {
|
|
const wsl = openCodeWslPath(path)
|
|
if (!wsl) {
|
|
native.push(path)
|
|
continue
|
|
}
|
|
const key = wsl.distro.toLowerCase()
|
|
const group = groups.get(key) ?? { distro: wsl.distro, paths: new Map<string, string>() }
|
|
group.paths.set(wsl.linuxPath, path)
|
|
groups.set(key, group)
|
|
}
|
|
const candidates = await Promise.all([
|
|
native.length ? getSharedClient().list({ ...args, dbPaths: native }) : Promise.resolve([]),
|
|
...[...groups.values()].map(async ({ distro, paths }) => {
|
|
const first = paths.values().next().value!
|
|
const issues: AiVaultScanIssue[] = []
|
|
try {
|
|
const 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<OpenCodeSqliteWorkerClient['parse']>[0]
|
|
): Promise<AiVaultSession | null> {
|
|
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<OpenCodeSqliteWorkerClient['capture']>[0]
|
|
): Promise<OpenCodeSqliteCaptureValue> {
|
|
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
|
|
}
|