Files
orca/src/main/ai-vault/session-scanner-opencode-sqlite.ts
T
Neil 9062494f9b fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript (#16587)
* fix(ai-vault): stop a whole opencode.db failure reading as one skipped transcript

#15036 reported "1 transcript skipped / database is locked" with both Agent
Session History scopes empty. Two separate defects.

The panel counts every unkinded scan issue as a skipped transcript, so a
failure that lost an entire *source* was reported as one lost *file*. The
whole-database failure is now kinded `scope`, and an unknown `kind` from a
newer host degrades to `scope` instead of failing validation and coming back
unkinded — a mixed-version remote host previously turned a source-level
failure into a phantom skipped transcript.

The read also inherited sqlite3's 0 ms busy timeout, so a genuinely contended
open failed in ~1 ms. It now opens once with a bounded timeout. No retry loop:
sqlite's own busy handler already blocks and retries internally for the whole
timeout, and WAL readers do not block on a writer at all (measured: 547/547
cross-process reads at timeout=0 while a writer held open transactions).

Measured against a real Ubuntu-24.04 distro, Windows cannot take SQLite's file
locks over \\wsl.localhost at all: an idle, never-WAL, nothing-attached
database still answers SQLITE_BUSY, a 5 s busy timeout does not change it, and
the identical bytes open fine once copied to local disk. So a lock-family error
on that share never means "a writer holds it" and no timeout can help. The copy
says so rather than sending the user after a write-ahead log that is not the
problem. Restoring those sessions needs an in-distro read; that is a follow-up,
and this PR no longer pretends a timeout will do it.

immutable=1 is deliberately not used as a workaround: over the same share it
opens and returns 100 of 150 rows, silently dropping everything still in the
uncheckpointed -wal — in a history panel, exactly the newest sessions.

* skip the provably futile busy wait on \\wsl.localhost paths
2026-08-26 23:37:16 -07:00

346 lines
12 KiB
TypeScript

import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
import {
addPreviewMessage,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import { normalizeTitleText } from './session-scanner-values'
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: OpenCode 1.17.x migrated session storage from per-session JSON files
// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module
// parses individual sessions from the DB into AiVaultSession objects. The
// discovery layer (listing candidates) lives in
// session-scanner-opencode-sqlite-discovery.ts.
const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
// Why (#8864): a heavy session can hold ~10K parts (25-150 KB tool-output
// blobs). Join preview parts against only the newest N messages instead of
// scanning every part of the session, using the real (session_id, time_created,
// id) index. Bounds the read to those messages' parts; the 15 s parse timeout
// caps the residual for a single pathological giant part.
const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100
// Bounds a pathological single message; a real typed prompt is a handful of parts.
const FIRST_USER_PROMPT_PART_LIMIT = 512
type SessionRow = {
id: string
title: string | null
directory: string | null
time_created: number
time_updated: number
model_json: string | null
agent: string | null
tokens_input: number
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
cost: number
message_count: number
}
type PreviewRow = {
role: string | null
part_data: string
time_created: number
summary_title: string | null
summary_body: string | null
}
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
return (
tableExists(db, 'session') &&
columnExists(db, 'session', 'time_created') &&
columnExists(db, 'session', 'time_updated')
)
}
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
}
function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string {
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
}
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionQuery(db: SyncDatabase): string {
const messageCountSubquery = canCountOpenCodeMessages(db)
? `(SELECT COUNT(*) FROM message m
WHERE m.session_id = s.id
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
: '0'
return `SELECT s.id,
${sessionColumnSelect(db, 'title')} AS title,
${sessionColumnSelect(db, 'directory')} AS directory,
s.time_created,
s.time_updated,
${sessionColumnSelect(db, 'model')} AS model_json,
${sessionColumnSelect(db, 'agent')} AS agent,
${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input,
${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output,
${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning,
${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read,
${sessionNumberColumnSelect(db, 'cost')} AS cost,
${messageCountSubquery} AS message_count
FROM session s
WHERE s.id = ?
LIMIT 1`
}
function extractModelId(modelJson: string | null): string | null {
if (!modelJson) {
return null
}
try {
const parsed = JSON.parse(modelJson) as unknown
const record =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
if (!record) {
return null
}
// Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}.
// Older schemas used {"modelID":"..."}; accept both.
return (
(typeof record.id === 'string' && record.id.trim()) ||
(typeof record.modelID === 'string' && record.modelID.trim()) ||
null
)
} catch {
return null
}
}
function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] {
if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') {
return role
}
return 'unknown'
}
function extractPartText(partData: string): string | null {
try {
const parsed = JSON.parse(partData) as unknown
const record =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
if (!record) {
return null
}
if (typeof record.text === 'string') {
return record.text
}
return null
} catch {
return null
}
}
function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
return null
}
try {
// Why: pin to the single earliest user message that actually has text parts,
// then take all of its parts. Ordering parts across every user message would
// pad a short first prompt with later turns and truncate a long one.
const rows = db
.prepare(
`SELECT p.data AS part_data
FROM part p
WHERE p.message_id = (
SELECT m.id
FROM message m
JOIN part fp ON fp.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(fp.data, '$.type') = 'text'
ORDER BY m.time_created ASC, m.id ASC
LIMIT 1
)
AND json_extract(p.data, '$.type') = 'text'
ORDER BY p.time_created ASC, p.rowid ASC
LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}`
)
.all(sessionId) as { part_data: string }[]
const parts: string[] = []
for (const row of rows) {
const text = extractPartText(row.part_data)
if (text) {
parts.push(text)
}
}
if (parts.length === 0) {
return null
}
return normalizeFullFirstUserPromptText(parts.join('\n'))
} catch {
return null
}
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
return null
}
return `SELECT json_extract(m.data, '$.role') AS role,
p.data AS part_data,
p.time_created,
json_extract(m.data, '$.summary.title') AS summary_title,
json_extract(m.data, '$.summary.body') AS summary_body
FROM (SELECT id, data FROM message
WHERE session_id = ?
ORDER BY time_created DESC, id DESC
LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m
JOIN part p ON p.message_id = m.id
WHERE json_extract(m.data, '$.role') IN ('user','assistant')
AND json_extract(p.data, '$.type') = 'text'
ORDER BY p.time_created DESC
LIMIT ?`
}
/**
* Parse a single OpenCode session from the SQLite database into an
* `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost)
* and up to 5 preview messages by joining the `message` and `part` tables.
* The database is opened read-only with `PRAGMA query_only = ON` as a
* belt-and-suspenders guard against mutations.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - The session ID (primary key in the `session` table).
* @param args.platform - The platform to use for resume command generation.
* @returns The parsed `AiVaultSession`, or `null` if the session does not exist
* or the database lacks the required schema.
*/
export async function parseOpenCodeSqliteSession(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): Promise<AiVaultSession | null> {
return readOpenCodeDatabase({
dbPath: args.dbPath,
read: (db) => readSession({ db, ...args })
})
}
// Extracted so the open wrapper owns the handle's lifetime.
function readSession(args: {
db: SyncDatabase
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): AiVaultSession | null {
const { db, dbPath, sessionId, platform } = args
if (!canReadOpenCodeSessions(db)) {
return null
}
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
if (!row || row.id !== sessionId) {
return null
}
const mtimeMs =
typeof row.time_updated === 'number' && row.time_updated > 0
? row.time_updated
: row.time_created
// Why: discovery uses a synthetic db#session path only for parser routing.
// The UI's log open/reveal actions need a real filesystem path.
const accumulator = createAccumulator({
agent: 'opencode',
file: {
path: dbPath,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString()
},
sessionId
})
accumulator.title = normalizeTitleText(row.title ?? '')
accumulator.cwd = row.directory
accumulator.model = extractModelId(row.model_json)
accumulator.totalTokens =
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
accumulator.messageCount = row.message_count ?? 0
updateTimeline(accumulator, row.time_created)
updateTimeline(accumulator, row.time_updated)
const previewSql = buildPreviewQuery(db)
if (previewSql) {
// Why: SQL already dropped anything older than the newest-N window, so the
// accumulator never shifts and cannot detect the truncation itself. Ask for
// one extra row so an exactly-full window is not mistaken for a trimmed one.
const probedRows = db
.prepare(previewSql)
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[]
if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) {
accumulator.previewMessagesTruncated = true
}
// Newest-first, so the probe row to drop is the oldest one at the tail.
const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT)
// Why: query returns newest-first; push in chronological order so the
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
// messages.
for (let i = previewRows.length - 1; i >= 0; i--) {
const previewRow = previewRows[i]
if (!previewRow) {
continue
}
const text = extractPartText(previewRow.part_data)
if (!text) {
continue
}
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created,
// Preview window is newest-N; first-prompt is loaded separately below.
seedFirstUserPrompt: false
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
normalizeTitleText(previewRow.summary_title ?? '') ||
normalizeTitleText(previewRow.summary_body ?? '')
}
}
}
// Why: list preview only joins the newest messages. On-demand copy needs the
// session's earliest real user text part, not a later turn still in the window.
if (shouldCaptureFullFirstUserPrompt()) {
accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId)
}
return finalizeSession(accumulator, platform)
}