diff --git a/packages/sdk/src/workflows/collectors/codex.ts b/packages/sdk/src/workflows/collectors/codex.ts index cfdf95eab..e306b743f 100644 --- a/packages/sdk/src/workflows/collectors/codex.ts +++ b/packages/sdk/src/workflows/collectors/codex.ts @@ -3,11 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { createRequire } from 'node:module'; -import type { - CliSessionCollector, - CliSessionQuery, - CliSessionReport, -} from '../cli-session-collector.js'; +import type { CliSessionCollector, CliSessionQuery, CliSessionReport } from '../cli-session-collector.js'; const require = createRequire(import.meta.url); const CODEX_HOME = path.join(os.homedir(), '.codex'); @@ -23,14 +19,11 @@ type DatabaseInstance = { type DatabaseConstructor = new ( filename: string, - options?: { readonly?: boolean; fileMustExist?: boolean }, + options?: { readonly?: boolean; fileMustExist?: boolean } ) => DatabaseInstance; interface DatabaseSyncModule { - DatabaseSync: new ( - filename: string, - options?: { readOnly?: boolean; open?: boolean }, - ) => DatabaseInstance; + DatabaseSync: new (filename: string, options?: { readOnly?: boolean; open?: boolean }) => DatabaseInstance; } interface CodexCollectorOptions { @@ -69,16 +62,32 @@ function loadBetterSqlite3(): DatabaseConstructor | null { } } +function isBunRuntime(): boolean { + // Bun exposes its version on process.versions.bun. The `node:sqlite` module + // is a Node.js 22+ builtin that Bun does not implement; attempting + // `await import('node:sqlite')` under Bun rejects AND emits cosmetic stderr + // noise ("error: Registry URL must be http:// or https://") that leaks + // through the try/catch into runner.log. Skip the fallback under Bun. + return ( + typeof process !== 'undefined' && + typeof (process.versions as { bun?: string } | undefined)?.bun === 'string' + ); +} + async function openDatabase(dbPath: string): Promise { const BetterSqlite = loadBetterSqlite3(); if (BetterSqlite) { try { return new BetterSqlite(dbPath, { readonly: true, fileMustExist: true }); } catch { - // Fall through to node:sqlite. + // Fall through to node:sqlite (on Node) or give up (on Bun). } } + if (isBunRuntime()) { + return null; + } + try { const sqlite = (await import('node:sqlite')) as DatabaseSyncModule; return new sqlite.DatabaseSync(dbPath, { readOnly: true, open: true }); @@ -88,11 +97,12 @@ async function openDatabase(dbPath: string): Promise { } function normalizeTimestamp(value: unknown): number | null { - const numeric = typeof value === 'number' && Number.isFinite(value) - ? value - : typeof value === 'string' && value.trim() - ? Number(value) - : null; + const numeric = + typeof value === 'number' && Number.isFinite(value) + ? value + : typeof value === 'string' && value.trim() + ? Number(value) + : null; if (numeric === null || !Number.isFinite(numeric)) { return null; } @@ -108,7 +118,10 @@ function parseJsonLine(line: string): T | null { } } -function parseModelProvider(value: string | null | undefined): { provider: string | null; model: string | null } { +function parseModelProvider(value: string | null | undefined): { + provider: string | null; + model: string | null; +} { if (!value) { return { provider: null, model: null }; } @@ -247,7 +260,8 @@ export class CodexCollector implements CliSessionCollector { } try { - return fs.readFileSync(this.historyPath, 'utf8') + return fs + .readFileSync(this.historyPath, 'utf8') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) @@ -287,20 +301,24 @@ export class CodexCollector implements CliSessionCollector { } try { - const threads = db.prepare( - ` + const threads = db + .prepare( + ` SELECT * FROM threads WHERE cwd = ? ORDER BY created_at DESC LIMIT 100 - `, - ).all(query.cwd); - - return threads.find((thread) => { - const createdAt = normalizeTimestamp(thread.created_at); - return createdAt !== null && createdAt >= query.startedAt && createdAt <= query.completedAt; - }) ?? null; + ` + ) + .all(query.cwd); + + return ( + threads.find((thread) => { + const createdAt = normalizeTimestamp(thread.created_at); + return createdAt !== null && createdAt >= query.startedAt && createdAt <= query.completedAt; + }) ?? null + ); } catch { return null; } finally { @@ -319,15 +337,17 @@ export class CodexCollector implements CliSessionCollector { } try { - const rows = db.prepare( - ` + const rows = db + .prepare( + ` SELECT ts, level, message, line FROM logs WHERE thread_id = ? AND lower(level) = 'error' ORDER BY ts ASC - `, - ).all(threadId); + ` + ) + .all(threadId); return rows .map((row, index) => { diff --git a/packages/sdk/src/workflows/collectors/opencode.ts b/packages/sdk/src/workflows/collectors/opencode.ts index 2a7576847..75ff6a47d 100644 --- a/packages/sdk/src/workflows/collectors/opencode.ts +++ b/packages/sdk/src/workflows/collectors/opencode.ts @@ -3,11 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { createRequire } from 'node:module'; -import type { - CliSessionCollector, - CliSessionQuery, - CliSessionReport, -} from '../cli-session-collector.js'; +import type { CliSessionCollector, CliSessionQuery, CliSessionReport } from '../cli-session-collector.js'; const require = createRequire(import.meta.url); const OPENCODE_DB_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'); @@ -25,7 +21,7 @@ type DatabaseInstance = { type DatabaseConstructor = new ( filename: string, - options?: { readonly?: boolean; fileMustExist?: boolean }, + options?: { readonly?: boolean; fileMustExist?: boolean } ) => DatabaseInstance; interface SessionRow { @@ -70,6 +66,17 @@ interface OpenCodePartData { name?: string; } +function isBunRuntime(): boolean { + // Bun does not implement the Node 22+ `node:sqlite` builtin; requiring it + // under Bun both throws AND emits cosmetic stderr noise + // ("error: Registry URL must be http:// or https://") that leaks through + // the try/catch into runner.log. + return ( + typeof process !== 'undefined' && + typeof (process.versions as { bun?: string } | undefined)?.bun === 'string' + ); +} + function loadDatabaseConstructor(): DatabaseConstructor | null { try { return require('better-sqlite3') as DatabaseConstructor; @@ -77,26 +84,38 @@ function loadDatabaseConstructor(): DatabaseConstructor | null { // fall through } + if (isBunRuntime()) { + return null; + } + // Fall back to Node 22+ native node:sqlite (experimental) try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { DatabaseSync } = require('node:sqlite'); - return function NativeSqliteWrapper(filename: string, options?: { readonly?: boolean; fileMustExist?: boolean }) { + return function NativeSqliteWrapper( + filename: string, + options?: { readonly?: boolean; fileMustExist?: boolean } + ) { const db = new DatabaseSync(filename, { open: true, readOnly: options?.readonly ?? false }); return { prepare(sql: string) { const stmt = db.prepare(sql); return { get(params?: unknown): T | undefined { - return params != null ? stmt.get(params) as T | undefined : stmt.get() as T | undefined; + return params != null ? (stmt.get(params) as T | undefined) : (stmt.get() as T | undefined); }, all(params?: unknown): T[] { return (params != null ? stmt.all(params) : stmt.all()) as T[]; }, }; }, - pragma(source: string) { db.exec(`PRAGMA ${source}`); return undefined; }, - close() { db.close(); }, + pragma(source: string) { + db.exec(`PRAGMA ${source}`); + return undefined; + }, + close() { + db.close(); + }, }; } as unknown as DatabaseConstructor; } catch { @@ -169,42 +188,48 @@ export class OpenCodeCollector implements CliSessionCollector { db = new Database(OPENCODE_DB_PATH, { readonly: true, fileMustExist: true }); db.pragma('query_only = ON'); - const session = db.prepare( - ` + const session = db + .prepare( + ` SELECT id, directory, time_created FROM session WHERE directory = @cwd AND time_created BETWEEN @startedAt AND @completedAt ORDER BY time_created DESC LIMIT 1 - `, - ).get({ - cwd: query.cwd, - startedAt: query.startedAt - MATCH_WINDOW_GRACE_MS, - completedAt: query.completedAt, - }); + ` + ) + .get({ + cwd: query.cwd, + startedAt: query.startedAt - MATCH_WINDOW_GRACE_MS, + completedAt: query.completedAt, + }); if (!session) { return null; } - const messages = db.prepare( - ` + const messages = db + .prepare( + ` SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC - `, - ).all(session.id); - - const parts = db.prepare( ` + ) + .all(session.id); + + const parts = db + .prepare( + ` SELECT id, message_id, session_id, time_created, data FROM part WHERE session_id = ? ORDER BY time_created ASC - `, - ).all(session.id); + ` + ) + .all(session.id); const parsedMessages = messages.map((message) => ({ ...message, @@ -227,11 +252,11 @@ export class OpenCodeCollector implements CliSessionCollector { totals.cacheRead += toNumber(tokens?.cache?.read); return totals; }, - { input: 0, output: 0, cacheRead: 0 }, + { input: 0, output: 0, cacheRead: 0 } ); const hasCostData = parsedMessages.some( - (message) => typeof message.parsed?.cost === 'number' && Number.isFinite(message.parsed.cost), + (message) => typeof message.parsed?.cost === 'number' && Number.isFinite(message.parsed.cost) ); const totalCost = parsedMessages.reduce((sum, message) => sum + toNumber(message.parsed?.cost), 0); @@ -266,13 +291,16 @@ export class OpenCodeCollector implements CliSessionCollector { } } - const summary = [...parsedParts] - .reverse() - .find((part) => part.parsed?.type === 'text' && part.parsed.text?.trim())?.parsed?.text?.trim() ?? null; + const summary = + [...parsedParts] + .reverse() + .find((part) => part.parsed?.type === 'text' && part.parsed.text?.trim()) + ?.parsed?.text?.trim() ?? null; - const turns = parsedMessages.filter( - (message) => message.parsed?.role === 'assistant' || message.parsed?.role === 'user', - ).length || parsedMessages.length; + const turns = + parsedMessages.filter( + (message) => message.parsed?.role === 'assistant' || message.parsed?.role === 'user' + ).length || parsedMessages.length; return { cli: 'opencode', @@ -292,7 +320,10 @@ export class OpenCodeCollector implements CliSessionCollector { summary, raw: { session, - messages: parsedMessages.map(({ parsed, ...message }) => ({ ...message, data: parsed ?? message.data })), + messages: parsedMessages.map(({ parsed, ...message }) => ({ + ...message, + data: parsed ?? message.data, + })), parts: parsedParts.map(({ parsed, ...part }) => ({ ...part, data: parsed ?? part.data })), }, };