mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
* 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
346 lines
12 KiB
TypeScript
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)
|
|
}
|