From d87663dd48c33a767fae5b67af2812dad0022e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 12 Jun 2026 21:06:20 +0200 Subject: [PATCH 1/8] feat(opencode): add db doctor and repair commands Adds native db doctor and repair flows with JSON output, dry-run and apply modes, safe/aggressive repair planning, backup-before-apply safeguards, transactional preconditions, and targeted DB tests. --- packages/core/src/database/health.ts | 567 +++++++++++++++++++++ packages/core/src/database/repair.ts | 222 ++++++++ packages/opencode/src/cli/cmd/db-runner.ts | 91 ++++ packages/opencode/src/cli/cmd/db.ts | 89 +++- packages/opencode/src/index.ts | 9 +- packages/opencode/test/db/cli.test.ts | 172 +++++++ packages/opencode/test/db/health.test.ts | 315 ++++++++++++ 7 files changed, 1452 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/database/health.ts create mode 100644 packages/core/src/database/repair.ts create mode 100644 packages/opencode/src/cli/cmd/db-runner.ts create mode 100644 packages/opencode/test/db/cli.test.ts create mode 100644 packages/opencode/test/db/health.test.ts diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts new file mode 100644 index 000000000000..4f861b0ab3a5 --- /dev/null +++ b/packages/core/src/database/health.ts @@ -0,0 +1,567 @@ +export * as DatabaseHealth from "./health" + +import { Database as BunDatabase } from "bun:sqlite" +import { existsSync, lstatSync, realpathSync } from "node:fs" +import path from "node:path" + +export type RepairMode = "safe" | "aggressive" +export type IssueSeverity = "info" | "warning" | "error" +export type Confidence = "low" | "medium" | "high" + +export interface Issue { + code: string + severity: IssueSeverity + table?: string + rowId?: string + sessionId?: string + messageId?: string + repairable: boolean + reason: string + suggestedRepair?: string + confidence?: Confidence + before?: unknown + after?: unknown + warning?: string +} + +export interface DoctorReport { + dbPath: string + checkedAt: string + schemaSupported: boolean + sessionCount?: number + messageCount?: number + issues: Issue[] + exitCode: 0 | 1 | 2 +} + +export interface RepairOperation { + id: string + issueCode: string + table: "session" | "session_message" + rowId: string + before: unknown + after: unknown + preconditions: Record + reason: string + confidence: Confidence + backupRequired: boolean + mode: RepairMode + warning?: string +} + +export interface RepairPlan { + dbPath: string + generatedAt: string + mode: RepairMode + operations: RepairOperation[] + warnings: string[] + exitCode: 0 | 1 | 2 +} + +interface SchemaStatus { + supported: boolean + issues: Issue[] + columns: { + session: Set + sessionMessage: Set + project: Set + } +} + +interface SessionRow { + id: string + project_id: string | null + directory: string + path: string | null + agent: string | null + model: string | null +} + +interface SessionMessageRow { + id: string + session_id: string + type: string + data: string +} + +interface DirectoryRow { + session_id: string + session_directory: string + project_id: string | null + project_worktree: string | null + project_matches: number +} + +const noColumns = { + session: new Set(), + sessionMessage: new Set(), + project: new Set(), +} + +export async function generateDoctorReport(dbPath: string): Promise { + if (!(await Bun.file(dbPath).exists())) { + return unreadableDoctorReport(dbPath, "database_not_found", "Database file does not exist") + } + + try { + return withReadOnlyDatabase(dbPath, (db) => { + const schema = analyzeSchema(db) + if (!schema.supported) { + return buildReport(dbPath, schema, 0, 0, schema.issues) + } + + const sessions = analyzeSessions(db, schema.columns.session) + const messages = analyzeMessages(db) + return buildReport(dbPath, schema, sessions.count, messages.count, [ + ...schema.issues, + ...sessions.issues, + ...messages.issues, + ...analyzeDirectoryMismatch(db, "safe"), + ]) + }) + } catch (error) { + return unreadableDoctorReport(dbPath, "database_unreadable", `Database is unreadable: ${errorMessage(error)}`) + } +} + +export async function generateRepairPlan(dbPath: string, mode: RepairMode = "safe"): Promise { + if (!(await Bun.file(dbPath).exists())) { + return unreadableRepairPlan(dbPath, mode, "Database file does not exist") + } + + try { + return withReadOnlyDatabase(dbPath, (db) => { + const schema = analyzeSchema(db) + if (!schema.supported) { + return { + dbPath, + generatedAt: new Date().toISOString(), + mode, + operations: [], + warnings: schema.issues.map((issue) => issue.reason), + exitCode: 2 as const, + } satisfies RepairPlan + } + + const sessions = analyzeSessions(db, schema.columns.session) + const messages = analyzeMessages(db) + const issues = [...sessions.issues, ...messages.issues, ...analyzeDirectoryMismatch(db, mode)] + const operations = issues.flatMap((issue) => operationForIssue(db, mode, issue)) + return { + dbPath, + generatedAt: new Date().toISOString(), + mode, + operations, + warnings: operations.flatMap((operation) => (operation.warning ? [operation.warning] : [])), + exitCode: operations.length > 0 ? 1 : 0, + } satisfies RepairPlan + }) + } catch (error) { + return unreadableRepairPlan(dbPath, mode, `Database is unreadable: ${errorMessage(error)}`) + } +} + +export function analyzeSchema(db: BunDatabase): SchemaStatus { + const sessionMessage = tableColumns(db, "session_message") + const session = tableColumns(db, "session") + const project = tableColumns(db, "project") + const issues: Issue[] = [ + ...missingTableIssues("session_message", sessionMessage), + ...missingTableIssues("session", session), + ...missingTableIssues("project", project), + ] + + if (issues.length > 0) { + return { supported: false, issues, columns: { session, sessionMessage, project } } + } + + const columnIssues: Issue[] = [ + ...missingColumnIssues("session_message", sessionMessage, ["id", "session_id", "type", "data"]), + ...missingColumnIssues("session", session, ["id", "project_id", "directory", "path", "agent", "model"]), + ...missingColumnIssues("project", project, ["id", "worktree"]), + ] + + if (!sessionMessage.has("seq")) { + columnIssues.push({ + code: "session_message_seq_not_present", + severity: "info", + table: "session_message", + repairable: false, + reason: "session_message.seq column is not present; current upstream schema does not require repair", + }) + } + + return { + supported: !columnIssues.some((issue) => issue.severity === "error"), + issues: columnIssues, + columns: { session, sessionMessage, project }, + } +} + +export function analyzeSessions(db: BunDatabase, columns?: Set): { count: number; issues: Issue[] } { + const count = readCount(db, "session") + if (columns && !(columns.has("agent") && columns.has("model") && columns.has("path"))) return { count, issues: [] } + + return { + count, + issues: db + .query("SELECT id, project_id, directory, path, agent, model FROM session WHERE agent IS NULL OR agent = '' OR model IS NULL OR model = '' OR path IS NULL OR path = ''") + .all() + .flatMap((row) => sessionMetadataIssues(db, row as SessionRow)), + } +} + +export function analyzeMessages(db: BunDatabase): { count: number; issues: Issue[] } { + const rows = db.query("SELECT id, session_id, type, data FROM session_message WHERE type = ?").all("assistant") + return { + count: readCount(db, "session_message"), + issues: rows.flatMap((row) => assistantMessageIssues(row as SessionMessageRow)), + } +} + +export function analyzeDirectoryMismatch(db: BunDatabase, mode: RepairMode): Issue[] { + return db + .query( + `SELECT s.id AS session_id, s.directory AS session_directory, s.project_id, p.worktree AS project_worktree, + (SELECT count(*) FROM project p2 WHERE p2.id = s.project_id) AS project_matches + FROM session s LEFT JOIN project p ON p.id = s.project_id`, + ) + .all() + .flatMap((row) => directoryMismatchIssue(row as DirectoryRow, mode)) +} + +function buildReport(dbPath: string, schema: SchemaStatus, sessionCount: number, messageCount: number, issues: Issue[]) { + return { + dbPath, + checkedAt: new Date().toISOString(), + schemaSupported: schema.supported, + sessionCount, + messageCount, + issues, + exitCode: !schema.supported ? 2 : issues.some((issue) => issue.severity === "error") ? 1 : 0, + } satisfies DoctorReport +} + +function unreadableDoctorReport(dbPath: string, code: string, reason: string) { + return { + dbPath, + checkedAt: new Date().toISOString(), + schemaSupported: false, + issues: [ + { + code, + severity: "error" as const, + repairable: false, + reason, + }, + ], + exitCode: 2 as const, + } satisfies DoctorReport +} + +function unreadableRepairPlan(dbPath: string, mode: RepairMode, reason: string) { + return { + dbPath, + generatedAt: new Date().toISOString(), + mode, + operations: [], + warnings: [reason], + exitCode: 2 as const, + } satisfies RepairPlan +} + +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message + return "Unknown database error" +} + +function withReadOnlyDatabase(dbPath: string, run: (db: BunDatabase) => T) { + const db = new BunDatabase(dbPath, { readonly: true, create: false }) + try { + return run(db) + } finally { + db.close() + } +} + +function tableColumns(db: BunDatabase, table: string) { + if ((db.query("SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { count: number } | null)?.count !== 1) { + return new Set() + } + return new Set(db.query(`PRAGMA table_info(${table})`).all().map((row) => (row as { name: string }).name)) +} + +function missingTableIssues(table: string, columns: Set): Issue[] { + if (columns.size > 0) return [] + return [ + { + code: `${table}_table_missing`, + severity: "error" as const, + table, + repairable: false, + reason: `${table} table does not exist`, + }, + ] +} + +function missingColumnIssues(table: string, columns: Set, required: string[]): Issue[] { + return required + .filter((column) => !columns.has(column)) + .map((column) => ({ + code: `${table}_required_column_missing_${column}`, + severity: "error" as const, + table, + repairable: false, + reason: `Required column ${table}.${column} is missing`, + })) +} + +function readCount(db: BunDatabase, table: string) { + return (db.query(`SELECT count(*) AS count FROM ${table}`).get() as { count: number } | null)?.count ?? 0 +} + +function assistantMessageIssues(row: SessionMessageRow): Issue[] { + const data = parseObject(row.data) + if (!data) { + return [ + { + code: "session_message_malformed_json", + severity: "warning" as const, + table: "session_message", + rowId: row.id, + sessionId: row.session_id, + messageId: row.id, + repairable: false, + reason: "session_message.data contains malformed JSON", + }, + ] + } + + if (nonEmptyString(data.agent) || !nonEmptyString(data.mode)) return [] + return [ + { + code: "assistant_message_missing_agent", + severity: "error" as const, + table: "session_message", + rowId: row.id, + sessionId: row.session_id, + messageId: row.id, + repairable: true, + reason: "Assistant message has data.mode but data.agent is missing or empty", + suggestedRepair: "copy_mode_to_agent", + confidence: "high" as const, + before: { data: row.data, agent: data.agent, mode: data.mode }, + after: { agent: data.mode }, + }, + ] +} + +function sessionMetadataIssues(db: BunDatabase, row: SessionRow): Issue[] { + return [ + ...metadataIssue(row, "agent", deriveSessionAgent(db, row.id)), + ...metadataIssue(row, "model", deriveSessionModel(db, row.id)), + ...metadataIssue(row, "path", nonEmptyString(row.directory) ? row.directory : undefined), + ] +} + +function metadataIssue(row: SessionRow, field: "agent" | "model" | "path", derived: unknown): Issue[] { + if (nonEmptyString(row[field])) return [] + return [ + { + code: `session_${field}_missing`, + severity: "warning" as const, + table: "session", + rowId: row.id, + sessionId: row.id, + repairable: derived !== undefined, + reason: derived === undefined ? `session.${field} is missing and no single unambiguous value is derivable` : `session.${field} is missing`, + suggestedRepair: derived === undefined ? undefined : `set_session_${field}`, + confidence: derived === undefined ? undefined : ("high" as const), + before: { [field]: row[field] }, + after: derived === undefined ? undefined : { [field]: derived }, + }, + ] +} + +function deriveSessionAgent(db: BunDatabase, sessionID: string) { + return singleValue( + db + .query("SELECT data FROM session_message WHERE session_id = ? AND type IN ('assistant', 'agent-switched')") + .all(sessionID) + .map((row) => { + const data = parseObject((row as { data: string }).data) + return data?.agent ?? data?.mode + }) + .filter(nonEmptyString), + ) +} + +function deriveSessionModel(db: BunDatabase, sessionID: string) { + return singleValue( + db + .query("SELECT data FROM session_message WHERE session_id = ? AND type IN ('assistant', 'model-switched')") + .all(sessionID) + .map((row) => parseObject((row as { data: string }).data)?.model) + .filter(isRecord) + .map((value) => JSON.stringify(value)), + ) +} + +function directoryMismatchIssue(row: DirectoryRow, mode: RepairMode): Issue[] { + if (!nonEmptyString(row.session_directory) || row.session_directory === row.project_worktree) return [] + if (!row.project_id || !row.project_worktree || row.project_matches !== 1) { + return [diagnosticDirectoryIssue(row, "Session project is missing or ambiguous")] + } + + const eligibility = directoryEligibility(row.session_directory, row.project_worktree) + return [ + { + code: eligibility.ok ? "directory_mismatch" : "directory_mismatch_unrepairable", + severity: "warning" as const, + table: "session", + rowId: row.session_id, + sessionId: row.session_id, + repairable: mode === "aggressive" && eligibility.ok, + reason: eligibility.ok + ? `Session directory does not match owning project worktree (${mode === "safe" ? "repairable only in aggressive mode" : "eligible for aggressive repair"})` + : eligibility.reason, + suggestedRepair: mode === "aggressive" && eligibility.ok ? "update_session_directory_to_project_worktree" : undefined, + confidence: eligibility.ok ? ("high" as const) : undefined, + before: row.session_directory, + after: mode === "aggressive" && eligibility.ok ? row.project_worktree : undefined, + warning: eligibility.ok ? "Aggressive directory repair rewrites session.directory to the owning project.worktree after exact row preconditions pass." : undefined, + }, + ] +} + +function diagnosticDirectoryIssue(row: DirectoryRow, reason: string): Issue { + return { + code: "directory_mismatch_unrepairable", + severity: "warning" as const, + table: "session", + rowId: row.session_id, + sessionId: row.session_id, + repairable: false, + reason, + before: row.session_directory, + after: row.project_worktree, + } +} + +function directoryEligibility(currentDirectory: string, targetDirectory: string) { + if (looksWindowsPath(currentDirectory) !== looksWindowsPath(targetDirectory)) return { ok: false, reason: "Directory repair refuses cross-platform path conversion" } + if (!path.isAbsolute(currentDirectory) || !path.isAbsolute(targetDirectory)) return { ok: false, reason: "Directory repair requires absolute paths" } + if (!existsSync(targetDirectory)) return { ok: false, reason: "Project worktree target directory is missing" } + if (lstatSync(targetDirectory).isSymbolicLink()) return { ok: false, reason: "Project worktree target is symlink-sensitive" } + if (!lstatSync(targetDirectory).isDirectory()) return { ok: false, reason: "Project worktree target directory is missing" } + if (safeRealpath(targetDirectory) !== path.resolve(targetDirectory)) return { ok: false, reason: "Project worktree target is symlink-sensitive" } + if (existsSync(currentDirectory)) return { ok: false, reason: "Current session directory still exists; refusing moved-folder or worktree-intent inference" } + if (isSubpath(currentDirectory, targetDirectory) || isSubpath(targetDirectory, currentDirectory)) return { ok: false, reason: "Directory repair refuses subdirectory/worktree-intent inference" } + return { ok: true, reason: "Eligible" } +} + +function operationForIssue(db: BunDatabase, mode: RepairMode, issue: Issue): RepairOperation[] { + if (issue.code === "assistant_message_missing_agent" && issue.rowId) return [assistantOperation(db, issue)] + if (issue.code.startsWith("session_") && issue.code.endsWith("_missing") && issue.rowId && issue.repairable) return [sessionMetadataOperation(db, issue)] + if (issue.code === "directory_mismatch" && mode === "aggressive" && issue.rowId && issue.repairable) return [directoryOperation(db, issue)] + return [] +} + +function assistantOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing assistant repair row id") + const row = db.query("SELECT id, session_id, type, data FROM session_message WHERE id = ?").get(issue.rowId) as SessionMessageRow + const data = parseObject(row.data) + return { + id: `repair_assistant_agent_${row.id}`, + issueCode: issue.code, + table: "session_message" as const, + rowId: row.id, + before: issue.before, + after: issue.after, + preconditions: { id: row.id, type: "assistant", data: row.data, mode: data?.mode }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "safe" as const, + } +} + +function sessionMetadataOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing session repair row id") + const row = db.query("SELECT id, agent, model, path FROM session WHERE id = ?").get(issue.rowId) as Pick + return { + id: `repair_${issue.code}_${row.id}`, + issueCode: issue.code, + table: "session" as const, + rowId: row.id, + before: issue.before, + after: issue.after, + preconditions: { id: row.id, agent: row.agent, model: row.model, path: row.path }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "safe" as const, + } +} + +function directoryOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing directory repair row id") + const row = db + .query("SELECT s.id, s.project_id, s.directory, p.worktree FROM session s JOIN project p ON p.id = s.project_id WHERE s.id = ?") + .get(issue.rowId) as { id: string; project_id: string; directory: string; worktree: string } + return { + id: `repair_directory_mismatch_${row.id}`, + issueCode: issue.code, + table: "session" as const, + rowId: row.id, + before: row.directory, + after: row.worktree, + preconditions: { id: row.id, project_id: row.project_id, directory: row.directory, project_worktree: row.worktree }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "aggressive" as const, + warning: issue.warning, + } +} + +function parseObject(input: string) { + try { + const value: unknown = JSON.parse(input) + if (isRecord(value)) return value + return undefined + } catch { + return undefined + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== "" +} + +function singleValue(values: unknown[]) { + const unique = [...new Set(values.map((value) => (typeof value === "string" ? value : JSON.stringify(value))))] + if (unique.length !== 1) return undefined + return unique[0] +} + +function looksWindowsPath(value: string) { + return /^[a-zA-Z]:[\\/]/.test(value) +} + +function safeRealpath(value: string) { + try { + return realpathSync.native(value) + } catch { + return "" + } +} + +function isSubpath(parent: string, child: string) { + const relative = path.relative(path.resolve(parent), path.resolve(child)) + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) +} diff --git a/packages/core/src/database/repair.ts b/packages/core/src/database/repair.ts new file mode 100644 index 000000000000..73de08b2b4ca --- /dev/null +++ b/packages/core/src/database/repair.ts @@ -0,0 +1,222 @@ +export * as DatabaseRepair from "./repair" + +import { Database as BunDatabase } from "bun:sqlite" +import type { RepairOperation, RepairPlan } from "./health" +import { generateDoctorReport } from "./health" + +export interface BackupInfo { + path: string + createdAt: string + originalPath: string +} + +export interface ApplyResult { + success: boolean + backup: BackupInfo + operationsApplied: number + operationsFailed: number + postCheckIssues: number + error?: string +} + +export async function createBackup(dbPath: string) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backup = { + path: `${dbPath}.backup.${timestamp}`, + createdAt: new Date().toISOString(), + originalPath: dbPath, + } + + await Bun.write(backup.path, Bun.file(dbPath)) + if (await Bun.file(`${dbPath}-wal`).exists()) await Bun.write(`${backup.path}-wal`, Bun.file(`${dbPath}-wal`)) + if (await Bun.file(`${dbPath}-shm`).exists()) await Bun.write(`${backup.path}-shm`, Bun.file(`${dbPath}-shm`)) + return backup +} + +export async function applyRepairPlan(plan: RepairPlan) { + if (plan.exitCode === 2) { + return failedResult(plan.dbPath, "Cannot apply repairs to an unsupported or unreadable database") + } + + if (plan.operations.length === 0) { + return { + success: true, + backup: { path: "", createdAt: "", originalPath: plan.dbPath }, + operationsApplied: 0, + operationsFailed: 0, + postCheckIssues: 0, + } satisfies ApplyResult + } + + const backup = await createBackup(plan.dbPath) + const db = new BunDatabase(plan.dbPath) + try { + db.exec("BEGIN IMMEDIATE TRANSACTION") + try { + plan.operations.forEach((operation) => applyOperation(db, operation)) + db.exec("COMMIT") + } catch (error) { + db.exec("ROLLBACK") + return { + success: false, + backup, + operationsApplied: 0, + operationsFailed: 1, + postCheckIssues: -1, + error: error instanceof Error ? error.message : "Unknown repair failure", + } satisfies ApplyResult + } + } finally { + db.close() + } + + const postCheck = await generateDoctorReport(plan.dbPath) + if (postCheck.exitCode === 2) { + return { + success: false, + backup, + operationsApplied: plan.operations.length, + operationsFailed: 1, + postCheckIssues: -1, + error: "Post-check failed after repair commit", + } satisfies ApplyResult + } + + return { + success: true, + backup, + operationsApplied: plan.operations.length, + operationsFailed: 0, + postCheckIssues: postCheck.issues.filter((issue) => issue.severity === "error").length, + } satisfies ApplyResult +} + +function applyOperation(db: BunDatabase, operation: RepairOperation) { + if (operation.issueCode === "assistant_message_missing_agent") return applyAssistantAgentRepair(db, operation) + if (operation.issueCode.startsWith("session_") && operation.issueCode.endsWith("_missing")) return applySessionMetadataRepair(db, operation) + if (operation.issueCode === "directory_mismatch") return applyDirectoryRepair(db, operation) + throw new Error(`Unsupported repair operation: ${operation.issueCode}`) +} + +function applyAssistantAgentRepair(db: BunDatabase, operation: RepairOperation) { + const row = db.query("SELECT type, data FROM session_message WHERE id = ?").get(operation.rowId) as { type: string; data: string } | null + if (!row || row.type !== operation.preconditions.type || row.data !== operation.preconditions.data) { + throw new Error(`Precondition failed for ${operation.id}`) + } + + const data = JSON.parse(row.data) as Record + if (typeof data.agent === "string" && data.agent.trim() !== "") throw new Error(`Precondition failed for ${operation.id}: agent already set`) + if (data.mode !== operation.preconditions.mode || typeof data.mode !== "string" || data.mode.trim() === "") { + throw new Error(`Precondition failed for ${operation.id}: mode changed`) + } + + data.agent = data.mode + db.query("UPDATE session_message SET data = ? WHERE id = ? AND data = ?").run(JSON.stringify(data), operation.rowId, row.data) +} + +function applySessionMetadataRepair(db: BunDatabase, operation: RepairOperation) { + const field = sessionMetadataField(operation.issueCode) + const value = (operation.after as Record)[field] + if (typeof value !== "string") throw new Error(`Invalid ${field} repair value for ${operation.id}`) + const row = db.query("SELECT agent, model, path FROM session WHERE id = ?").get(operation.rowId) as { agent: string | null; model: string | null; path: string | null } | null + if (!row || row[field] !== operation.preconditions[field]) { + throw new Error(`Precondition failed for ${operation.id}`) + } + if (typeof row[field] === "string" && row[field].trim() !== "") throw new Error(`Precondition failed for ${operation.id}: ${field} already set`) + if (deriveSessionMetadataValue(db, operation.rowId, field) !== value) { + throw new Error(`Precondition failed for ${operation.id}: ${field} derivation changed`) + } + if (field === "agent") db.query("UPDATE session SET agent = ? WHERE id = ? AND (agent IS NULL OR agent = '')").run(value, operation.rowId) + if (field === "model") db.query("UPDATE session SET model = ? WHERE id = ? AND (model IS NULL OR model = '')").run(value, operation.rowId) + if (field === "path") db.query("UPDATE session SET path = ? WHERE id = ? AND (path IS NULL OR path = '')").run(value, operation.rowId) +} + +function applyDirectoryRepair(db: BunDatabase, operation: RepairOperation) { + if (operation.mode !== "aggressive") throw new Error(`Directory repair requires aggressive mode: ${operation.id}`) + const row = db + .query("SELECT s.project_id, s.directory, p.worktree FROM session s JOIN project p ON p.id = s.project_id WHERE s.id = ?") + .get(operation.rowId) as { project_id: string; directory: string; worktree: string } | null + if ( + !row || + row.project_id !== operation.preconditions.project_id || + row.directory !== operation.preconditions.directory || + row.worktree !== operation.preconditions.project_worktree + ) { + throw new Error(`Precondition failed for ${operation.id}`) + } + if (typeof operation.after !== "string") throw new Error(`Invalid directory repair value for ${operation.id}`) + db.query("UPDATE session SET directory = ? WHERE id = ? AND project_id = ? AND directory = ?").run( + operation.after, + operation.rowId, + row.project_id, + row.directory, + ) +} + +function sessionMetadataField(issueCode: string) { + if (issueCode === "session_agent_missing") return "agent" as const + if (issueCode === "session_model_missing") return "model" as const + if (issueCode === "session_path_missing") return "path" as const + throw new Error(`Unsupported session metadata operation: ${issueCode}`) +} + +function deriveSessionMetadataValue(db: BunDatabase, sessionID: string, field: "agent" | "model" | "path") { + if (field === "path") { + return (db.query("SELECT directory FROM session WHERE id = ?").get(sessionID) as { directory: string } | null)?.directory + } + if (field === "agent") { + return singleValue( + db + .query("SELECT data FROM session_message WHERE session_id = ? AND type IN ('assistant', 'agent-switched')") + .all(sessionID) + .map((row) => { + const data = parseObject((row as { data: string }).data) + return data?.agent ?? data?.mode + }) + .filter(nonEmptyString), + ) + } + return singleValue( + db + .query("SELECT data FROM session_message WHERE session_id = ? AND type IN ('assistant', 'model-switched')") + .all(sessionID) + .map((row) => parseObject((row as { data: string }).data)?.model) + .filter(isRecord) + .map((value) => JSON.stringify(value)), + ) +} + +function parseObject(input: string) { + try { + const value: unknown = JSON.parse(input) + if (isRecord(value)) return value + return undefined + } catch { + return undefined + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== "" +} + +function singleValue(values: unknown[]) { + const unique = [...new Set(values.map((value) => (typeof value === "string" ? value : JSON.stringify(value))))] + if (unique.length !== 1) return undefined + return unique[0] +} + +function failedResult(dbPath: string, error: string) { + return { + success: false, + backup: { path: "", createdAt: "", originalPath: dbPath }, + operationsApplied: 0, + operationsFailed: 0, + postCheckIssues: -1, + error, + } satisfies ApplyResult +} diff --git a/packages/opencode/src/cli/cmd/db-runner.ts b/packages/opencode/src/cli/cmd/db-runner.ts new file mode 100644 index 000000000000..4afd74c53864 --- /dev/null +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -0,0 +1,91 @@ +import { generateDoctorReport, generateRepairPlan, type RepairPlan } from "@opencode-ai/core/database/health" +import { applyRepairPlan, type ApplyResult } from "@opencode-ai/core/database/repair" + +type CommandExitCode = 0 | 1 | 2 + +export async function runDoctorCommand(dbPath: string, args: { json: boolean }): Promise<{ exitCode: CommandExitCode; issueCount: number }> { + const report = await generateDoctorReport(dbPath) + if (args.json) console.log(JSON.stringify(report, null, 2)) + else printDoctorReport(report) + return { exitCode: report.exitCode, issueCount: report.issues.length } +} + +export async function runRepairCommand( + dbPath: string, + args: { dryRun?: boolean; "dry-run"?: boolean; apply: boolean; json: boolean; mode: string }, +): Promise<{ exitCode: CommandExitCode; message: string }> { + const mode = args.mode === "aggressive" ? "aggressive" : "safe" + const plan = await generateRepairPlan(dbPath, mode) + if (args.dryRun || args["dry-run"]) { + if (args.json) console.log(JSON.stringify(plan, null, 2)) + else printRepairPlan(plan) + return { exitCode: plan.exitCode, message: `Repair dry-run found ${plan.operations.length} operation(s)` } + } + + const applyResult = await applyRepairPlan(plan) + if (args.json) console.log(JSON.stringify({ ...applyResult, exitCode: applyResult.success ? 0 : 2 }, null, 2)) + else printApplyResult(plan, applyResult) + return { exitCode: (applyResult.success ? 0 : 2) satisfies CommandExitCode, message: applyResult.error || "Repair failed" } +} + +function printDoctorReport(report: Awaited>) { + console.log("OpenCode DB Doctor") + console.log("==================") + console.log(`Database: ${report.dbPath}`) + console.log(`Schema: ${report.schemaSupported ? "supported" : "unsupported"}`) + console.log(`Sessions: ${report.sessionCount ?? 0}`) + console.log(`Messages: ${report.messageCount ?? 0}`) + console.log("") + if (report.issues.length === 0) console.log("Issues: None") + report.issues.forEach((issue) => { + console.log(`- [${issue.severity}] ${issue.code}: ${issue.reason}`) + if (issue.sessionId) console.log(` session: ${issue.sessionId}`) + if (issue.messageId) console.log(` message: ${issue.messageId}`) + console.log(` repairable: ${issue.repairable ? issue.suggestedRepair ?? "yes" : "no"}`) + }) + console.log("") + console.log("No changes were made.") + console.log(`Exit code: ${report.exitCode}`) +} + +function printRepairPlan(plan: RepairPlan) { + console.log("OpenCode DB Repair (Dry Run)") + console.log("============================") + console.log(`Database: ${plan.dbPath}`) + console.log(`Mode: ${plan.mode}`) + console.log("") + if (plan.operations.length === 0) console.log("Repair plan: No repairs needed") + plan.operations.forEach((operation) => { + console.log(`- ${operation.id}`) + console.log(` issue: ${operation.issueCode}`) + console.log(` table: ${operation.table}`) + console.log(` row: ${operation.rowId}`) + console.log(` before: ${JSON.stringify(operation.before)}`) + console.log(` after: ${JSON.stringify(operation.after)}`) + console.log(` reason: ${operation.reason}`) + console.log(` confidence: ${operation.confidence}`) + console.log(` backup required: ${operation.backupRequired}`) + console.log(` preconditions: ${JSON.stringify(operation.preconditions)}`) + if (operation.warning) console.log(` WARNING: ${operation.warning}`) + }) + console.log("") + console.log("No changes were made.") + console.log(`Exit code: ${plan.exitCode}`) +} + +function printApplyResult(plan: RepairPlan, result: ApplyResult) { + console.log("OpenCode DB Repair (Apply)") + console.log("==========================") + console.log(`Database: ${plan.dbPath}`) + if (result.backup.path) console.log(`Backup created: ${result.backup.path}`) + if (!result.success) { + console.log(`Repair failed: ${result.error}`) + console.log("No changes were applied. Database transaction was rolled back.") + console.log("Exit code: 2") + return + } + plan.warnings.forEach((warning) => console.log(`WARNING: ${warning}`)) + console.log(`Operations applied: ${result.operationsApplied}`) + console.log(`Post-check critical issues: ${result.postCheckIssues}`) + console.log("Exit code: 0") +} diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index 2aa5caf10aa5..d56d500ee2ef 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -8,6 +8,9 @@ import { cmd } from "./cmd" import { JsonMigration } from "@/storage/json-migration" import { EOL } from "os" import { errorMessage } from "../../util/error" +import { Effect } from "effect" +import { effectCmd, fail } from "../effect-cmd" +import { runDoctorCommand, runRepairCommand } from "./db-runner" const QueryCommand = cmd({ command: "$0 [query]", @@ -31,20 +34,18 @@ const QueryCommand = cmd({ const db = new BunDatabase(Database.Path, { readonly: true }) try { const result = db.query(query).all() as Record[] - if (args.format === "json") { - console.log(JSON.stringify(result, null, 2)) - } else if (result.length > 0) { + if (args.format === "json") console.log(JSON.stringify(result, null, 2)) + else if (result.length > 0) { const keys = Object.keys(result[0]) console.log(keys.join("\t")) - for (const row of result) { - console.log(keys.map((k) => row[k]).join("\t")) - } + for (const row of result) console.log(keys.map((key) => row[key]).join("\t")) } } catch (err) { UI.error(errorMessage(err)) process.exit(1) + } finally { + db.close() } - db.close() return } const child = spawn("sqlite3", [Database.Path], { @@ -62,6 +63,72 @@ const PathCommand = cmd({ }, }) +const DoctorCommand = effectCmd({ + command: "doctor", + describe: "diagnose database health issues", + instance: false, + builder: (yargs: Argv) => { + return yargs.option("json", { + type: "boolean", + default: false, + describe: "Output in JSON format", + }) + }, + handler: Effect.fn("Cli.db.doctor")(function* (args: { json: boolean }) { + const result = yield* Effect.promise(() => runDoctorCommand(Database.Path, args)) + if (result.exitCode !== 0) yield* fail(`Database doctor found ${result.issueCount} issue(s)`, result.exitCode) + }), +}) + +const RepairCommand = effectCmd({ + command: "repair", + describe: "plan or apply database repairs", + instance: false, + builder: (yargs: Argv) => { + return yargs + .option("dry-run", { + type: "boolean", + default: false, + describe: "Generate repair plan without applying", + }) + .option("apply", { + type: "boolean", + default: false, + describe: "Apply repairs (creates backup first)", + }) + .option("json", { + type: "boolean", + default: false, + describe: "Output in JSON format", + }) + .option("mode", { + type: "string", + choices: ["safe", "aggressive"], + default: "safe", + describe: "Repair mode (aggressive includes directory mismatch repair)", + }) + .check((argv) => { + if (argv.dryRun && argv.apply) { + throw new Error("Cannot use both --dry-run and --apply") + } + if (!argv.dryRun && !argv.apply) { + throw new Error("Must specify either --dry-run or --apply") + } + return true + }) + }, + handler: Effect.fn("Cli.db.repair")(function* (args: { + dryRun?: boolean + "dry-run"?: boolean + apply: boolean + json: boolean + mode: string + }) { + const result = yield* Effect.promise(() => runRepairCommand(Database.Path, args)) + if (result.exitCode !== 0) yield* fail(result.message, result.exitCode) + }), +}) + const MigrateCommand = cmd({ command: "migrate", describe: "migrate JSON data to SQLite (merges with existing data)", @@ -114,7 +181,13 @@ export const DbCommand = cmd({ command: "db", describe: "database tools", builder: (yargs: Argv) => { - return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand() + return yargs + .command(QueryCommand) + .command(PathCommand) + .command(DoctorCommand) + .command(RepairCommand) + .command(MigrateCommand) + .demandCommand() }, handler: () => {}, }) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d20f29dd4d2f..e5e46f8a665a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -30,8 +30,6 @@ import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" -import path from "path" -import { Global } from "@opencode-ai/core/global" import { JsonMigration } from "@/storage/json-migration" import { Database } from "@/storage/db" import { errorMessage } from "./util/error" @@ -116,8 +114,9 @@ const cli = yargs(args) run_id: processMetadata.runID, }) - const marker = path.join(Global.Path.data, "opencode.db") - if (!(await Filesystem.exists(marker))) { + const marker = Database.Path + const skipDbCreation = args[0] === "db" && (args[1] === "doctor" || args[1] === "repair") + if (!skipDbCreation && !(await Filesystem.exists(marker))) { const tty = process.stderr.isTTY process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) const width = 36 @@ -241,7 +240,7 @@ try { UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) process.stderr.write(errorMessage(e) + EOL) } - process.exitCode = 1 + process.exitCode = process.exitCode ?? 1 } finally { // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts new file mode 100644 index 000000000000..6f2fadf9aa13 --- /dev/null +++ b/packages/opencode/test/db/cli.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Database as BunDatabase } from "bun:sqlite" +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { runDoctorCommand, runRepairCommand } from "../../src/cli/cmd/db-runner" + +const cleanup: string[] = [] + +afterEach(() => { + cleanup.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe("opencode db CLI doctor and repair", () => { + test("doctor human/json output and approved exit codes", async () => { + const healthy = createFixture("healthy") + healthy.db.close() + const healthyResult = await capture(() => runDoctorCommand(healthy.dbPath, { json: false })) + expect(healthyResult.exitCode).toBe(0) + expect(healthyResult.stdout).toContain("No changes were made.") + + const broken = createFixture("broken") + insertRepairableAssistantIssue(broken) + const brokenJson = await capture(() => runDoctorCommand(broken.dbPath, { json: true })) + expect(brokenJson.exitCode).toBe(1) + expect(JSON.parse(brokenJson.stdout).issues.some((issue: { code: string }) => issue.code === "assistant_message_missing_agent")).toBe(true) + + const missing = join(tempDir(), "missing.db") + const missingResult = await capture(() => runDoctorCommand(missing, { json: true })) + expect(missingResult.exitCode).toBe(2) + expect(existsSync(missing)).toBe(false) + expect(JSON.parse(missingResult.stdout).issues[0].code).toBe("database_not_found") + }) + + test("repair dry-run/apply CLI output, JSON, and no-write/apply behavior", async () => { + const fixture = createFixture("repair") + insertRepairableAssistantIssue(fixture) + const before = statSync(fixture.dbPath).mtimeMs + + const dryRun = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "safe" })) + const dryRunJson = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: true, mode: "safe" })) + + expect(dryRun.exitCode).toBe(1) + expect(dryRun.stdout).toContain("No changes were made.") + expect(dryRun.stdout).toContain("repair_assistant_agent_msg") + expect(JSON.parse(dryRunJson.stdout).operations[0].issueCode).toBe("assistant_message_missing_agent") + expect(statSync(fixture.dbPath).mtimeMs).toBe(before) + expect(await hasBackup(fixture.dbPath)).toBe(false) + + const apply = await capture(() => runRepairCommand(fixture.dbPath, { apply: true, json: false, mode: "safe" })) + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const data = JSON.parse((db.query("SELECT data FROM session_message WHERE id = ?").get("msg") as { data: string }).data) as { agent: string } + db.close() + + expect(apply.exitCode).toBe(0) + expect(apply.stdout).toContain("Backup created:") + expect(data.agent).toBe("build") + expect(await hasBackup(fixture.dbPath)).toBe(true) + }) + + test("corrupt database returns controlled exit code for doctor, dry-run, and apply", async () => { + const dbPath = join(tempDir(), "corrupt.db") + writeFileSync(dbPath, "not a sqlite database") + + const doctor = await capture(() => runDoctorCommand(dbPath, { json: true })) + const dryRun = await capture(() => runRepairCommand(dbPath, { dryRun: true, apply: false, json: true, mode: "safe" })) + const apply = await capture(() => runRepairCommand(dbPath, { apply: true, json: true, mode: "safe" })) + + expect(doctor.exitCode).toBe(2) + expect(JSON.parse(doctor.stdout).issues[0].code).toBe("database_unreadable") + expect(dryRun.exitCode).toBe(2) + expect(JSON.parse(dryRun.stdout).warnings[0]).toContain("Database is unreadable") + expect(apply.exitCode).toBe(2) + expect(JSON.parse(apply.stdout).success).toBe(false) + expect(await hasBackup(dbPath)).toBe(false) + }) + + test("aggressive directory repair CLI succeeds with warning", async () => { + const fixture = createFixture("aggressive") + const stale = join(fixture.dir, "stale") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", stale, stale, "title", "1") + fixture.db.close() + + const safe = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "safe" })) + const dryRun = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "aggressive" })) + const apply = await capture(() => runRepairCommand(fixture.dbPath, { apply: true, json: false, mode: "aggressive" })) + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const session = db.query("SELECT directory FROM session WHERE id = ?").get("ses") as { directory: string } + db.close() + + expect(safe.exitCode).toBe(0) + expect(dryRun.exitCode).toBe(1) + expect(dryRun.stdout).toContain("WARNING") + expect(apply.exitCode).toBe(0) + expect(apply.stdout).toContain("WARNING") + expect(session.directory).toBe(fixture.worktree) + }) +}) + +async function capture(run: () => Promise<{ exitCode: 0 | 1 | 2 }>) { + const lines: string[] = [] + const original = console.log + console.log = (...args: unknown[]) => { + lines.push(args.join(" ")) + } + try { + return { ...(await run()), stdout: lines.join("\n") } + } finally { + console.log = original + } +} + +function tempDir() { + const dir = mkdtempSync(join(tmpdir(), "opencode-db-cli-")) + cleanup.push(dir) + return dir +} + +function createFixture(name: string) { + const dir = tempDir() + const worktree = join(dir, `${name}-worktree`) + mkdirSync(worktree) + const dbPath = join(dir, `${name}.db`) + const db = new BunDatabase(dbPath) + createSchema(db) + return { dir, worktree, dbPath, db } +} + +function insertRepairableAssistantIssue(fixture: ReturnType) { + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version, path, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1", fixture.worktree, "build", JSON.stringify({ providerID: "p", modelID: "m" })) + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ mode: "build" })) + fixture.db.close() +} + +async function hasBackup(dbPath: string) { + return Array.from(new Bun.Glob(`${dbPath}.backup.*`).scanSync()).length > 0 +} + +function createSchema(db: BunDatabase) { + db.exec(` + CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL); + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + version text NOT NULL, + agent text, + model text, + time_created integer NOT NULL DEFAULT 0, + time_updated integer NOT NULL DEFAULT 0 + ); + CREATE TABLE session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) +} diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts new file mode 100644 index 000000000000..abd3e6948e83 --- /dev/null +++ b/packages/opencode/test/db/health.test.ts @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Database as BunDatabase } from "bun:sqlite" +import { mkdtempSync, rmSync, mkdirSync, existsSync, statSync, symlinkSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { generateDoctorReport, generateRepairPlan } from "@opencode-ai/core/database/health" +import { applyRepairPlan } from "@opencode-ai/core/database/repair" + +const cleanup: string[] = [] + +afterEach(() => { + cleanup.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe("database doctor and repair", () => { + test("reports a missing database without creating it", async () => { + const dir = tempDir() + const dbPath = join(dir, "missing.db") + + const report = await generateDoctorReport(dbPath) + const plan = await generateRepairPlan(dbPath) + + expect(report.exitCode).toBe(2) + expect(plan.exitCode).toBe(2) + expect(existsSync(dbPath)).toBe(false) + }) + + test("reports a corrupt database as unreadable without throwing", async () => { + const dbPath = join(tempDir(), "corrupt.db") + writeFileSync(dbPath, "not a sqlite database") + + const report = await generateDoctorReport(dbPath) + const plan = await generateRepairPlan(dbPath) + const apply = await applyRepairPlan(plan) + + expect(report.exitCode).toBe(2) + expect(report.schemaSupported).toBe(false) + expect(report.issues[0].code).toBe("database_unreadable") + expect(plan.exitCode).toBe(2) + expect(plan.operations).toHaveLength(0) + expect(plan.warnings[0]).toContain("Database is unreadable") + expect(apply.success).toBe(false) + expect(apply.backup.path).toBe("") + }) + + test("detects malformed JSON but does not plan a repair", async () => { + const fixture = createFixture("malformed") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1") + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), "{") + fixture.db.close() + + const report = await generateDoctorReport(fixture.dbPath) + const plan = await generateRepairPlan(fixture.dbPath) + + expect(report.issues.some((issue) => issue.code === "session_message_malformed_json")).toBe(true) + expect(plan.operations).toHaveLength(0) + }) + + test("dry-run is read-only and plans assistant/session metadata repair", async () => { + const fixture = createFixture("safe-plan") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version) VALUES (?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1") + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ mode: "build", model: { providerID: "p", modelID: "m" } })) + fixture.db.close() + const before = statSync(fixture.dbPath).mtimeMs + + const plan = await generateRepairPlan(fixture.dbPath) + + expect(plan.exitCode).toBe(1) + expect(plan.operations.map((operation) => operation.issueCode).sort()).toEqual([ + "assistant_message_missing_agent", + "session_agent_missing", + "session_model_missing", + "session_path_missing", + ]) + expect(statSync(fixture.dbPath).mtimeMs).toBe(before) + expect(existsSync(`${fixture.dbPath}.backup`)).toBe(false) + }) + + test("apply creates a backup, repairs in a transaction, and is idempotent", async () => { + const fixture = createFixture("apply") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version) VALUES (?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1") + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ mode: "build", model: { providerID: "p", modelID: "m" } })) + fixture.db.close() + + const result = await applyRepairPlan(await generateRepairPlan(fixture.dbPath)) + const second = await generateRepairPlan(fixture.dbPath) + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const message = JSON.parse((db.query("SELECT data FROM session_message WHERE id = ?").get("msg") as { data: string }).data) as { agent: string } + const session = db.query("SELECT agent, model, path FROM session WHERE id = ?").get("ses") as { agent: string; model: string; path: string } + db.close() + + expect(result.success).toBe(true) + expect(result.backup.path).toContain(".backup.") + expect(existsSync(result.backup.path)).toBe(true) + expect(message.agent).toBe("build") + expect(session.agent).toBe("build") + expect(session.model).toBe(JSON.stringify({ providerID: "p", modelID: "m" })) + expect(session.path).toBe(fixture.worktree) + expect(second.operations).toHaveLength(0) + }) + + test("rollback leaves rows unchanged when a precondition fails", async () => { + const fixture = createFixture("rollback") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version) VALUES (?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1") + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ mode: "build" })) + fixture.db.close() + const plan = await generateRepairPlan(fixture.dbPath) + const db = new BunDatabase(fixture.dbPath) + db.query("UPDATE session_message SET data = ? WHERE id = ?").run(JSON.stringify({ mode: "build", agent: "changed" }), "msg") + db.close() + + const result = await applyRepairPlan(plan) + const check = new BunDatabase(fixture.dbPath, { readonly: true }) + const data = JSON.parse((check.query("SELECT data FROM session_message WHERE id = ?").get("msg") as { data: string }).data) as { agent: string } + const session = check.query("SELECT agent FROM session WHERE id = ?").get("ses") as { agent: string | null } + check.close() + + expect(result.success).toBe(false) + expect(data.agent).toBe("changed") + expect(session.agent).toBeNull() + }) + + test("session metadata apply revalidates derivation sources", async () => { + const fixture = createFixture("metadata-drift") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version) VALUES (?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1") + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ mode: "build" })) + fixture.db.close() + const plan = await generateRepairPlan(fixture.dbPath) + const db = new BunDatabase(fixture.dbPath) + db.query("UPDATE session_message SET data = ? WHERE id = ?").run(JSON.stringify({ mode: "review" }), "msg") + db.close() + + const result = await applyRepairPlan(plan) + const check = new BunDatabase(fixture.dbPath, { readonly: true }) + const session = check.query("SELECT agent FROM session WHERE id = ?").get("ses") as { agent: string | null } + check.close() + + expect(result.success).toBe(false) + expect(result.error).toContain("derivation changed") + expect(session.agent).toBeNull() + }) + + test("directory mismatch is aggressive-only and refuses missing targets", async () => { + const fixture = createFixture("directory") + const stale = join(fixture.dir, "stale") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", stale, stale, "title", "1") + fixture.db.close() + + expect((await generateRepairPlan(fixture.dbPath, "safe")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) + expect((await generateRepairPlan(fixture.dbPath, "aggressive")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(true) + + rmSync(fixture.worktree, { recursive: true, force: true }) + expect((await generateRepairPlan(fixture.dbPath, "aggressive")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) + }) + + test("aggressive directory repair applies, is idempotent, and fails on precondition drift", async () => { + const fixture = createFixture("directory-apply") + const stale = join(fixture.dir, "stale") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", stale, stale, "title", "1") + fixture.db.close() + + const result = await applyRepairPlan(await generateRepairPlan(fixture.dbPath, "aggressive")) + const second = await generateRepairPlan(fixture.dbPath, "aggressive") + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const session = db.query("SELECT directory FROM session WHERE id = ?").get("ses") as { directory: string } + db.close() + + expect(result.success).toBe(true) + expect(session.directory).toBe(fixture.worktree) + expect(second.operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) + + const drift = createFixture("directory-drift") + const driftStale = join(drift.dir, "stale") + drift.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", drift.worktree, "[]") + drift.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", driftStale, driftStale, "title", "1") + drift.db.close() + const plan = await generateRepairPlan(drift.dbPath, "aggressive") + const driftDb = new BunDatabase(drift.dbPath) + driftDb.query("UPDATE session SET directory = ? WHERE id = ?").run("changed", "ses") + driftDb.close() + expect((await applyRepairPlan(plan)).success).toBe(false) + }) + + test("directory mismatch refuses symlink, cross-platform, and subdirectory targets", async () => { + const symlinkFixture = createFixture("directory-symlink") + const link = join(symlinkFixture.dir, "link-worktree") + symlinkSync(symlinkFixture.worktree, link, "junction") + symlinkFixture.db.query("UPDATE project SET worktree = ? WHERE id = ?").run(link, "missing") + symlinkFixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", link, "[]") + symlinkFixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", join(symlinkFixture.dir, "stale"), join(symlinkFixture.dir, "stale"), "title", "1") + symlinkFixture.db.close() + expect((await generateRepairPlan(symlinkFixture.dbPath, "aggressive")).operations).toHaveLength(0) + + const cross = createFixture("directory-cross") + cross.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", "/mnt/c/project", "[]") + cross.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", "C:\\project", "C:\\project", "title", "1") + cross.db.close() + expect((await generateRepairPlan(cross.dbPath, "aggressive")).operations).toHaveLength(0) + + const sub = createFixture("directory-sub") + sub.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", sub.worktree, "[]") + sub.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", join(sub.worktree, "nested"), join(sub.worktree, "nested"), "title", "1") + sub.db.close() + expect((await generateRepairPlan(sub.dbPath, "aggressive")).operations).toHaveLength(0) + }) +}) + +function tempDir() { + const dir = mkdtempSync(join(tmpdir(), "opencode-db-doctor-")) + cleanup.push(dir) + return dir +} + +function createFixture(name: string) { + const dir = tempDir() + const worktree = join(dir, `${name}-worktree`) + mkdirSync(worktree) + const dbPath = join(dir, `${name}.db`) + const db = new BunDatabase(dbPath) + db.exec(` + CREATE TABLE project ( + id text PRIMARY KEY, + worktree text NOT NULL, + vcs text, + name text, + icon_url text, + icon_url_override text, + icon_color text, + time_created integer NOT NULL DEFAULT 0, + time_updated integer NOT NULL DEFAULT 0, + time_initialized integer, + sandboxes text NOT NULL, + commands text + ); + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + version text NOT NULL, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + metadata text, + cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + revert text, + permission text, + agent text, + model text, + time_created integer NOT NULL DEFAULT 0, + time_updated integer NOT NULL DEFAULT 0, + time_compacting integer, + time_archived integer + ); + CREATE TABLE session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) + return { dir, worktree, dbPath, db } +} From 680de6927f962e86c102ed96c0bd232d65f9c2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 02:09:29 +0200 Subject: [PATCH 2/8] fix(opencode): constrain db repair scope --- packages/core/src/database/health.ts | 135 +-------------------- packages/core/src/database/repair.ts | 32 +---- packages/opencode/src/cli/cmd/db-runner.ts | 5 +- packages/opencode/src/cli/cmd/db.ts | 31 ++--- packages/opencode/src/index.ts | 14 ++- packages/opencode/test/db/cli.test.ts | 33 +---- packages/opencode/test/db/full-cli.test.ts | 56 +++++++++ packages/opencode/test/db/health.test.ts | 79 +----------- 8 files changed, 96 insertions(+), 289 deletions(-) create mode 100644 packages/opencode/test/db/full-cli.test.ts diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index 4f861b0ab3a5..1cfa3e725f77 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -1,10 +1,7 @@ export * as DatabaseHealth from "./health" import { Database as BunDatabase } from "bun:sqlite" -import { existsSync, lstatSync, realpathSync } from "node:fs" -import path from "node:path" - -export type RepairMode = "safe" | "aggressive" +export type RepairMode = "safe" export type IssueSeverity = "info" | "warning" | "error" export type Confidence = "low" | "medium" | "high" @@ -84,20 +81,6 @@ interface SessionMessageRow { data: string } -interface DirectoryRow { - session_id: string - session_directory: string - project_id: string | null - project_worktree: string | null - project_matches: number -} - -const noColumns = { - session: new Set(), - sessionMessage: new Set(), - project: new Set(), -} - export async function generateDoctorReport(dbPath: string): Promise { if (!(await Bun.file(dbPath).exists())) { return unreadableDoctorReport(dbPath, "database_not_found", "Database file does not exist") @@ -112,12 +95,7 @@ export async function generateDoctorReport(dbPath: string): Promise { - const mode = args.mode === "aggressive" ? "aggressive" : "safe" - const plan = await generateRepairPlan(dbPath, mode) + const plan = await generateRepairPlan(dbPath) if (args.dryRun || args["dry-run"]) { if (args.json) console.log(JSON.stringify(plan, null, 2)) else printRepairPlan(plan) diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index d56d500ee2ef..559aeb52a183 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -8,8 +8,6 @@ import { cmd } from "./cmd" import { JsonMigration } from "@/storage/json-migration" import { EOL } from "os" import { errorMessage } from "../../util/error" -import { Effect } from "effect" -import { effectCmd, fail } from "../effect-cmd" import { runDoctorCommand, runRepairCommand } from "./db-runner" const QueryCommand = cmd({ @@ -63,10 +61,9 @@ const PathCommand = cmd({ }, }) -const DoctorCommand = effectCmd({ +const DoctorCommand = cmd({ command: "doctor", describe: "diagnose database health issues", - instance: false, builder: (yargs: Argv) => { return yargs.option("json", { type: "boolean", @@ -74,16 +71,14 @@ const DoctorCommand = effectCmd({ describe: "Output in JSON format", }) }, - handler: Effect.fn("Cli.db.doctor")(function* (args: { json: boolean }) { - const result = yield* Effect.promise(() => runDoctorCommand(Database.Path, args)) - if (result.exitCode !== 0) yield* fail(`Database doctor found ${result.issueCount} issue(s)`, result.exitCode) - }), + handler: async (args: { json: boolean }) => { + process.exitCode = (await runDoctorCommand(Database.Path, args)).exitCode + }, }) -const RepairCommand = effectCmd({ +const RepairCommand = cmd({ command: "repair", describe: "plan or apply database repairs", - instance: false, builder: (yargs: Argv) => { return yargs .option("dry-run", { @@ -101,12 +96,6 @@ const RepairCommand = effectCmd({ default: false, describe: "Output in JSON format", }) - .option("mode", { - type: "string", - choices: ["safe", "aggressive"], - default: "safe", - describe: "Repair mode (aggressive includes directory mismatch repair)", - }) .check((argv) => { if (argv.dryRun && argv.apply) { throw new Error("Cannot use both --dry-run and --apply") @@ -117,16 +106,14 @@ const RepairCommand = effectCmd({ return true }) }, - handler: Effect.fn("Cli.db.repair")(function* (args: { + handler: async (args: { dryRun?: boolean "dry-run"?: boolean apply: boolean json: boolean - mode: string - }) { - const result = yield* Effect.promise(() => runRepairCommand(Database.Path, args)) - if (result.exitCode !== 0) yield* fail(result.message, result.exitCode) - }), + }) => { + process.exitCode = (await runRepairCommand(Database.Path, args)).exitCode + }, }) const MigrateCommand = cmd({ diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e5e46f8a665a..8123b961a8ca 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -115,7 +115,7 @@ const cli = yargs(args) }) const marker = Database.Path - const skipDbCreation = args[0] === "db" && (args[1] === "doctor" || args[1] === "repair") + const skipDbCreation = isReadOnlyDbCommand(args) if (!skipDbCreation && !(await Filesystem.exists(marker))) { const tty = process.stderr.isTTY process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) @@ -246,5 +246,15 @@ try { // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. - process.exit() + process.exit(process.exitCode ?? 0) +} + +function isReadOnlyDbCommand(args: string[]) { + const dbIndex = args.findIndex((arg) => arg === "db") + if (dbIndex === -1) return false + + const subcommand = args.slice(dbIndex + 1).find((arg) => !arg.startsWith("-")) + if (subcommand === "doctor") return true + if (subcommand !== "repair") return false + return !args.slice(dbIndex + 1).some((arg) => arg === "--apply" || arg === "--apply=true") } diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts index 6f2fadf9aa13..d92cccea4548 100644 --- a/packages/opencode/test/db/cli.test.ts +++ b/packages/opencode/test/db/cli.test.ts @@ -37,8 +37,8 @@ describe("opencode db CLI doctor and repair", () => { insertRepairableAssistantIssue(fixture) const before = statSync(fixture.dbPath).mtimeMs - const dryRun = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "safe" })) - const dryRunJson = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: true, mode: "safe" })) + const dryRun = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false })) + const dryRunJson = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: true })) expect(dryRun.exitCode).toBe(1) expect(dryRun.stdout).toContain("No changes were made.") @@ -47,7 +47,7 @@ describe("opencode db CLI doctor and repair", () => { expect(statSync(fixture.dbPath).mtimeMs).toBe(before) expect(await hasBackup(fixture.dbPath)).toBe(false) - const apply = await capture(() => runRepairCommand(fixture.dbPath, { apply: true, json: false, mode: "safe" })) + const apply = await capture(() => runRepairCommand(fixture.dbPath, { apply: true, json: false })) const db = new BunDatabase(fixture.dbPath, { readonly: true }) const data = JSON.parse((db.query("SELECT data FROM session_message WHERE id = ?").get("msg") as { data: string }).data) as { agent: string } db.close() @@ -63,8 +63,8 @@ describe("opencode db CLI doctor and repair", () => { writeFileSync(dbPath, "not a sqlite database") const doctor = await capture(() => runDoctorCommand(dbPath, { json: true })) - const dryRun = await capture(() => runRepairCommand(dbPath, { dryRun: true, apply: false, json: true, mode: "safe" })) - const apply = await capture(() => runRepairCommand(dbPath, { apply: true, json: true, mode: "safe" })) + const dryRun = await capture(() => runRepairCommand(dbPath, { dryRun: true, apply: false, json: true })) + const apply = await capture(() => runRepairCommand(dbPath, { apply: true, json: true })) expect(doctor.exitCode).toBe(2) expect(JSON.parse(doctor.stdout).issues[0].code).toBe("database_unreadable") @@ -75,29 +75,6 @@ describe("opencode db CLI doctor and repair", () => { expect(await hasBackup(dbPath)).toBe(false) }) - test("aggressive directory repair CLI succeeds with warning", async () => { - const fixture = createFixture("aggressive") - const stale = join(fixture.dir, "stale") - fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") - fixture.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", stale, stale, "title", "1") - fixture.db.close() - - const safe = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "safe" })) - const dryRun = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: false, mode: "aggressive" })) - const apply = await capture(() => runRepairCommand(fixture.dbPath, { apply: true, json: false, mode: "aggressive" })) - const db = new BunDatabase(fixture.dbPath, { readonly: true }) - const session = db.query("SELECT directory FROM session WHERE id = ?").get("ses") as { directory: string } - db.close() - - expect(safe.exitCode).toBe(0) - expect(dryRun.exitCode).toBe(1) - expect(dryRun.stdout).toContain("WARNING") - expect(apply.exitCode).toBe(0) - expect(apply.stdout).toContain("WARNING") - expect(session.directory).toBe(fixture.worktree) - }) }) async function capture(run: () => Promise<{ exitCode: 0 | 1 | 2 }>) { diff --git a/packages/opencode/test/db/full-cli.test.ts b/packages/opencode/test/db/full-cli.test.ts new file mode 100644 index 000000000000..c140d5f9c182 --- /dev/null +++ b/packages/opencode/test/db/full-cli.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const cleanup: string[] = [] + +afterEach(() => { + cleanup.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe("opencode db full CLI", () => { + test("doctor stays read-only even with global flags before db", async () => { + const dir = tempDir() + const dbPath = join(dir, "missing.db") + + const result = await runCli(["--print-logs", "db", "doctor", "--json"], dbPath) + + expect(result.exitCode).toBe(2) + expect(existsSync(dbPath)).toBe(false) + expect(result.stdout).toContain('"code": "database_not_found"') + }) + + test("repair dry-run stays read-only even with global flags before db", async () => { + const dir = tempDir() + const dbPath = join(dir, "missing.db") + + const result = await runCli(["--print-logs", "db", "repair", "--dry-run", "--json"], dbPath) + + expect(result.exitCode).toBe(2) + expect(existsSync(dbPath)).toBe(false) + expect(result.stdout).toContain('"Database file does not exist"') + }) +}) + +function tempDir() { + const dir = mkdtempSync(join(tmpdir(), "opencode-db-full-cli-")) + cleanup.push(dir) + return dir +} + +async function runCli(args: string[], dbPath: string) { + const proc = Bun.spawn(["bun", "run", "--conditions=browser", "./src/index.ts", ...args], { + cwd: import.meta.dirname.replace(/\\test\\db$/, ""), + env: { + ...process.env, + OPENCODE_DB: dbPath, + OPENCODE_PURE: "1", + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]) + return { stdout, stderr, exitCode } +} diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index abd3e6948e83..63e7940dca20 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Database as BunDatabase } from "bun:sqlite" -import { mkdtempSync, rmSync, mkdirSync, existsSync, statSync, symlinkSync, writeFileSync } from "node:fs" +import { mkdtempSync, rmSync, mkdirSync, existsSync, statSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { generateDoctorReport, generateRepairPlan } from "@opencode-ai/core/database/health" @@ -165,83 +165,6 @@ describe("database doctor and repair", () => { expect(session.agent).toBeNull() }) - test("directory mismatch is aggressive-only and refuses missing targets", async () => { - const fixture = createFixture("directory") - const stale = join(fixture.dir, "stale") - fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") - fixture.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", stale, stale, "title", "1") - fixture.db.close() - - expect((await generateRepairPlan(fixture.dbPath, "safe")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) - expect((await generateRepairPlan(fixture.dbPath, "aggressive")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(true) - - rmSync(fixture.worktree, { recursive: true, force: true }) - expect((await generateRepairPlan(fixture.dbPath, "aggressive")).operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) - }) - - test("aggressive directory repair applies, is idempotent, and fails on precondition drift", async () => { - const fixture = createFixture("directory-apply") - const stale = join(fixture.dir, "stale") - fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") - fixture.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", stale, stale, "title", "1") - fixture.db.close() - - const result = await applyRepairPlan(await generateRepairPlan(fixture.dbPath, "aggressive")) - const second = await generateRepairPlan(fixture.dbPath, "aggressive") - const db = new BunDatabase(fixture.dbPath, { readonly: true }) - const session = db.query("SELECT directory FROM session WHERE id = ?").get("ses") as { directory: string } - db.close() - - expect(result.success).toBe(true) - expect(session.directory).toBe(fixture.worktree) - expect(second.operations.some((operation) => operation.issueCode === "directory_mismatch")).toBe(false) - - const drift = createFixture("directory-drift") - const driftStale = join(drift.dir, "stale") - drift.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", drift.worktree, "[]") - drift.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", driftStale, driftStale, "title", "1") - drift.db.close() - const plan = await generateRepairPlan(drift.dbPath, "aggressive") - const driftDb = new BunDatabase(drift.dbPath) - driftDb.query("UPDATE session SET directory = ? WHERE id = ?").run("changed", "ses") - driftDb.close() - expect((await applyRepairPlan(plan)).success).toBe(false) - }) - - test("directory mismatch refuses symlink, cross-platform, and subdirectory targets", async () => { - const symlinkFixture = createFixture("directory-symlink") - const link = join(symlinkFixture.dir, "link-worktree") - symlinkSync(symlinkFixture.worktree, link, "junction") - symlinkFixture.db.query("UPDATE project SET worktree = ? WHERE id = ?").run(link, "missing") - symlinkFixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", link, "[]") - symlinkFixture.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", join(symlinkFixture.dir, "stale"), join(symlinkFixture.dir, "stale"), "title", "1") - symlinkFixture.db.close() - expect((await generateRepairPlan(symlinkFixture.dbPath, "aggressive")).operations).toHaveLength(0) - - const cross = createFixture("directory-cross") - cross.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", "/mnt/c/project", "[]") - cross.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", "C:\\project", "C:\\project", "title", "1") - cross.db.close() - expect((await generateRepairPlan(cross.dbPath, "aggressive")).operations).toHaveLength(0) - - const sub = createFixture("directory-sub") - sub.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", sub.worktree, "[]") - sub.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", join(sub.worktree, "nested"), join(sub.worktree, "nested"), "title", "1") - sub.db.close() - expect((await generateRepairPlan(sub.dbPath, "aggressive")).operations).toHaveLength(0) - }) }) function tempDir() { From 8a86b1305b184b3f95aa3cc30d54f11c186e10ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 09:38:44 +0200 Subject: [PATCH 3/8] fix(opencode): repair legacy part ids --- packages/core/src/database/health.ts | 80 ++++++++++++++++++++++-- packages/core/src/database/repair.ts | 14 +++++ packages/opencode/test/db/health.test.ts | 37 +++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index 1cfa3e725f77..4b9a4e2fd2e4 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -34,7 +34,7 @@ export interface DoctorReport { export interface RepairOperation { id: string issueCode: string - table: "session" | "session_message" + table: "session" | "session_message" | "part" rowId: string before: unknown after: unknown @@ -61,6 +61,7 @@ interface SchemaStatus { columns: { session: Set sessionMessage: Set + part: Set project: Set } } @@ -81,6 +82,12 @@ interface SessionMessageRow { data: string } +interface PartRow { + id: string + message_id: string + session_id: string +} + export async function generateDoctorReport(dbPath: string): Promise { if (!(await Bun.file(dbPath).exists())) { return unreadableDoctorReport(dbPath, "database_not_found", "Database file does not exist") @@ -95,7 +102,8 @@ export async function generateDoctorReport(dbPath: string): Promise 0) { - return { supported: false, issues, columns: { session, sessionMessage, project } } + return { supported: false, issues, columns: { session, sessionMessage, part, project } } } const columnIssues: Issue[] = [ ...missingColumnIssues("session_message", sessionMessage, ["id", "session_id", "type", "data"]), ...missingColumnIssues("session", session, ["id", "project_id", "directory", "path", "agent", "model"]), + ...optionalMissingColumnIssues("part", part, ["id", "message_id", "session_id"]), ...missingColumnIssues("project", project, ["id", "worktree"]), ] @@ -172,7 +183,7 @@ export function analyzeSchema(db: BunDatabase): SchemaStatus { return { supported: !columnIssues.some((issue) => issue.severity === "error"), issues: columnIssues, - columns: { session, sessionMessage, project }, + columns: { session, sessionMessage, part, project }, } } @@ -197,6 +208,20 @@ export function analyzeMessages(db: BunDatabase): { count: number; issues: Issue } } +export function analyzeParts(db: BunDatabase, columns?: Set): { count: number; issues: Issue[] } { + if (columns?.size === 0) return { count: 0, issues: [] } + const count = readCount(db, "part") + if (columns && !(columns.has("id") && columns.has("message_id") && columns.has("session_id"))) return { count, issues: [] } + + return { + count, + issues: db + .query("SELECT id, message_id, session_id FROM part WHERE id LIKE 'part\\_%' ESCAPE '\\'") + .all() + .flatMap((row) => partIDIssues(db, row as PartRow)), + } +} + function buildReport(dbPath: string, schema: SchemaStatus, sessionCount: number, messageCount: number, issues: Issue[]) { return { dbPath, @@ -283,6 +308,11 @@ function missingColumnIssues(table: string, columns: Set, required: stri })) } +function optionalMissingColumnIssues(table: string, columns: Set, required: string[]): Issue[] { + if (columns.size === 0) return [] + return missingColumnIssues(table, columns, required) +} + function readCount(db: BunDatabase, table: string) { return (db.query(`SELECT count(*) AS count FROM ${table}`).get() as { count: number } | null)?.count ?? 0 } @@ -331,6 +361,27 @@ function sessionMetadataIssues(db: BunDatabase, row: SessionRow): Issue[] { ] } +function partIDIssues(db: BunDatabase, row: PartRow): Issue[] { + const repaired = `prt_${row.id.slice("part_".length)}` + const exists = (db.query("SELECT count(*) AS count FROM part WHERE id = ?").get(repaired) as { count: number } | null)?.count !== 0 + return [ + { + code: "part_legacy_id_prefix", + severity: "error" as const, + table: "part", + rowId: row.id, + sessionId: row.session_id, + messageId: row.message_id, + repairable: !exists, + reason: exists ? "part.id uses the legacy part_ prefix, but the target prt_ id already exists" : "part.id uses the legacy part_ prefix; current schemas require prt_", + suggestedRepair: exists ? undefined : "rename_part_id_prefix", + confidence: exists ? undefined : ("high" as const), + before: { id: row.id }, + after: exists ? undefined : { id: repaired }, + }, + ] +} + function metadataIssue(row: SessionRow, field: "agent" | "model" | "path", derived: unknown): Issue[] { if (nonEmptyString(row[field])) return [] return [ @@ -377,6 +428,7 @@ function deriveSessionModel(db: BunDatabase, sessionID: string) { function operationForIssue(db: BunDatabase, issue: Issue): RepairOperation[] { if (issue.code === "assistant_message_missing_agent" && issue.rowId) return [assistantOperation(db, issue)] if (issue.code.startsWith("session_") && issue.code.endsWith("_missing") && issue.rowId && issue.repairable) return [sessionMetadataOperation(db, issue)] + if (issue.code === "part_legacy_id_prefix" && issue.rowId && issue.repairable) return [partIDOperation(db, issue)] return [] } @@ -440,3 +492,21 @@ function singleValue(values: unknown[]) { if (unique.length !== 1) return undefined return unique[0] } + +function partIDOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing part repair row id") + const row = db.query("SELECT id, message_id, session_id FROM part WHERE id = ?").get(issue.rowId) as PartRow + return { + id: `repair_part_id_${row.id}`, + issueCode: issue.code, + table: "part" as const, + rowId: row.id, + before: issue.before, + after: issue.after, + preconditions: { id: row.id, message_id: row.message_id, session_id: row.session_id }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "safe" as const, + } +} diff --git a/packages/core/src/database/repair.ts b/packages/core/src/database/repair.ts index e1867a148a74..960fb3e8d588 100644 --- a/packages/core/src/database/repair.ts +++ b/packages/core/src/database/repair.ts @@ -95,11 +95,25 @@ export async function applyRepairPlan(plan: RepairPlan) { } function applyOperation(db: BunDatabase, operation: RepairOperation) { + if (operation.issueCode === "part_legacy_id_prefix") return applyPartIDRepair(db, operation) if (operation.issueCode === "assistant_message_missing_agent") return applyAssistantAgentRepair(db, operation) if (operation.issueCode.startsWith("session_") && operation.issueCode.endsWith("_missing")) return applySessionMetadataRepair(db, operation) throw new Error(`Unsupported repair operation: ${operation.issueCode}`) } +function applyPartIDRepair(db: BunDatabase, operation: RepairOperation) { + const value = (operation.after as Record).id + if (typeof value !== "string" || !value.startsWith("prt_")) throw new Error(`Invalid part id repair value for ${operation.id}`) + const row = db.query("SELECT id, message_id, session_id FROM part WHERE id = ?").get(operation.rowId) as { id: string; message_id: string; session_id: string } | null + if (!row || row.message_id !== operation.preconditions.message_id || row.session_id !== operation.preconditions.session_id) { + throw new Error(`Precondition failed for ${operation.id}`) + } + if (!row.id.startsWith("part_")) throw new Error(`Precondition failed for ${operation.id}: id already repaired`) + const existing = db.query("SELECT count(*) AS count FROM part WHERE id = ?").get(value) as { count: number } | null + if (existing?.count !== 0) throw new Error(`Precondition failed for ${operation.id}: target id already exists`) + db.query("UPDATE part SET id = ? WHERE id = ? AND message_id = ? AND session_id = ?").run(value, row.id, row.message_id, row.session_id) +} + function applyAssistantAgentRepair(db: BunDatabase, operation: RepairOperation) { const row = db.query("SELECT type, data FROM session_message WHERE id = ?").get(operation.rowId) as { type: string; data: string } | null if (!row || row.type !== operation.preconditions.type || row.data !== operation.preconditions.data) { diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index 63e7940dca20..29079c78987a 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -114,6 +114,35 @@ describe("database doctor and repair", () => { expect(second.operations).toHaveLength(0) }) + test("repairs legacy part id prefixes required by current schemas", async () => { + const fixture = createFixture("part-prefix") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ agent: "build", model: { providerID: "p", modelID: "m" } })) + fixture.db + .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("part_ccf86b97b002MOqoKxDVMesjG9", "msg", "ses", Date.now(), Date.now(), JSON.stringify({ type: "text", text: "hello" })) + fixture.db.close() + + const report = await generateDoctorReport(fixture.dbPath) + const plan = await generateRepairPlan(fixture.dbPath) + const result = await applyRepairPlan(plan) + const second = await generateRepairPlan(fixture.dbPath) + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const rows = db.query("SELECT id FROM part ORDER BY id").all() as { id: string }[] + db.close() + + expect(report.issues.some((issue) => issue.code === "part_legacy_id_prefix" && issue.repairable)).toBe(true) + expect(plan.operations.map((operation) => operation.issueCode)).toContain("part_legacy_id_prefix") + expect(result.success).toBe(true) + expect(rows.map((row) => row.id)).toEqual(["prt_ccf86b97b002MOqoKxDVMesjG9"]) + expect(second.operations).toHaveLength(0) + }) + test("rollback leaves rows unchanged when a precondition fails", async () => { const fixture = createFixture("rollback") fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") @@ -233,6 +262,14 @@ function createFixture(name: string) { time_updated integer NOT NULL, data text NOT NULL ); + CREATE TABLE part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); `) return { dir, worktree, dbPath, db } } From 51f91bf0ae60b333ca759b4523799fd205fa00da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 09:41:48 +0200 Subject: [PATCH 4/8] docs(opencode): catalog db repairs --- packages/core/src/database/health.ts | 59 ++++++++++++++++++++++ packages/opencode/src/cli/cmd/db-runner.ts | 6 +++ packages/opencode/test/db/cli.test.ts | 5 ++ packages/opencode/test/db/health.test.ts | 14 ++++- 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index 4b9a4e2fd2e4..e0ca58b8d570 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -5,6 +5,58 @@ export type RepairMode = "safe" export type IssueSeverity = "info" | "warning" | "error" export type Confidence = "low" | "medium" | "high" +export interface SupportedRepair { + code: string + table: "session" | "session_message" | "part" + repairable: boolean + description: string + repair: string + safety: string +} + +export const SUPPORTED_REPAIRS = [ + { + code: "part_legacy_id_prefix", + table: "part", + repairable: true, + description: "Legacy message part rows use part_ IDs, while current schemas validate message part IDs with the prt_ prefix.", + repair: "Rename part.id from part_ to prt_ when the target id does not already exist.", + safety: "Primary-key update only; message/session foreign key columns and part data are unchanged; apply rechecks the source row and target-id absence.", + }, + { + code: "assistant_message_missing_agent", + table: "session_message", + repairable: true, + description: "Legacy assistant session_message.data rows may have mode but no agent.", + repair: "Copy data.mode to data.agent when mode is a single non-empty string and agent is missing.", + safety: "JSON update only; apply rechecks the original JSON payload and mode before writing.", + }, + { + code: "session_agent_missing", + table: "session", + repairable: true, + description: "Legacy session rows may miss the denormalized session.agent field required by current callers.", + repair: "Set session.agent only when one unambiguous value can be derived from assistant or agent-switched messages.", + safety: "Skipped when no single value is derivable; apply rederives the value before writing.", + }, + { + code: "session_model_missing", + table: "session", + repairable: true, + description: "Legacy session rows may miss the denormalized session.model field required by current callers.", + repair: "Set session.model only when one unambiguous model object can be derived from assistant or model-switched messages.", + safety: "Skipped when no single value is derivable; apply rederives the value before writing.", + }, + { + code: "session_path_missing", + table: "session", + repairable: true, + description: "Legacy session rows may miss session.path after the current schema expects it.", + repair: "Set session.path from the same row's session.directory when directory is non-empty.", + safety: "Does not rewrite directory/worktree semantics; apply rechecks the original empty path and derived directory value.", + }, +] satisfies SupportedRepair[] + export interface Issue { code: string severity: IssueSeverity @@ -25,6 +77,7 @@ export interface DoctorReport { dbPath: string checkedAt: string schemaSupported: boolean + supportedRepairs: SupportedRepair[] sessionCount?: number messageCount?: number issues: Issue[] @@ -50,6 +103,7 @@ export interface RepairPlan { dbPath: string generatedAt: string mode: RepairMode + supportedRepairs: SupportedRepair[] operations: RepairOperation[] warnings: string[] exitCode: 0 | 1 | 2 @@ -123,6 +177,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf dbPath, generatedAt: new Date().toISOString(), mode, + supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: schema.issues.map((issue) => issue.reason), exitCode: 2 as const, @@ -138,6 +193,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf dbPath, generatedAt: new Date().toISOString(), mode, + supportedRepairs: SUPPORTED_REPAIRS, operations, warnings: operations.flatMap((operation) => (operation.warning ? [operation.warning] : [])), exitCode: operations.length > 0 ? 1 : 0, @@ -227,6 +283,7 @@ function buildReport(dbPath: string, schema: SchemaStatus, sessionCount: number, dbPath, checkedAt: new Date().toISOString(), schemaSupported: schema.supported, + supportedRepairs: SUPPORTED_REPAIRS, sessionCount, messageCount, issues, @@ -239,6 +296,7 @@ function unreadableDoctorReport(dbPath: string, code: string, reason: string) { dbPath, checkedAt: new Date().toISOString(), schemaSupported: false, + supportedRepairs: SUPPORTED_REPAIRS, issues: [ { code, @@ -256,6 +314,7 @@ function unreadableRepairPlan(dbPath: string, mode: RepairMode, reason: string) dbPath, generatedAt: new Date().toISOString(), mode, + supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: [reason], exitCode: 2 as const, diff --git a/packages/opencode/src/cli/cmd/db-runner.ts b/packages/opencode/src/cli/cmd/db-runner.ts index 836d4bf91d4b..b6aecc973cfb 100644 --- a/packages/opencode/src/cli/cmd/db-runner.ts +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -35,6 +35,9 @@ function printDoctorReport(report: Awaited console.log(`- ${repair.code}: ${repair.repair}`)) + console.log("") if (report.issues.length === 0) console.log("Issues: None") report.issues.forEach((issue) => { console.log(`- [${issue.severity}] ${issue.code}: ${issue.reason}`) @@ -53,6 +56,9 @@ function printRepairPlan(plan: RepairPlan) { console.log(`Database: ${plan.dbPath}`) console.log(`Mode: ${plan.mode}`) console.log("") + console.log("Supported repairs:") + plan.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: ${repair.repair}`)) + console.log("") if (plan.operations.length === 0) console.log("Repair plan: No repairs needed") plan.operations.forEach((operation) => { console.log(`- ${operation.id}`) diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts index d92cccea4548..b9f9a2ffab32 100644 --- a/packages/opencode/test/db/cli.test.ts +++ b/packages/opencode/test/db/cli.test.ts @@ -17,6 +17,8 @@ describe("opencode db CLI doctor and repair", () => { healthy.db.close() const healthyResult = await capture(() => runDoctorCommand(healthy.dbPath, { json: false })) expect(healthyResult.exitCode).toBe(0) + expect(healthyResult.stdout).toContain("Supported repairs:") + expect(healthyResult.stdout).toContain("part_legacy_id_prefix") expect(healthyResult.stdout).toContain("No changes were made.") const broken = createFixture("broken") @@ -24,6 +26,7 @@ describe("opencode db CLI doctor and repair", () => { const brokenJson = await capture(() => runDoctorCommand(broken.dbPath, { json: true })) expect(brokenJson.exitCode).toBe(1) expect(JSON.parse(brokenJson.stdout).issues.some((issue: { code: string }) => issue.code === "assistant_message_missing_agent")).toBe(true) + expect(JSON.parse(brokenJson.stdout).supportedRepairs.some((repair: { code: string }) => repair.code === "part_legacy_id_prefix")).toBe(true) const missing = join(tempDir(), "missing.db") const missingResult = await capture(() => runDoctorCommand(missing, { json: true })) @@ -42,8 +45,10 @@ describe("opencode db CLI doctor and repair", () => { expect(dryRun.exitCode).toBe(1) expect(dryRun.stdout).toContain("No changes were made.") + expect(dryRun.stdout).toContain("Supported repairs:") expect(dryRun.stdout).toContain("repair_assistant_agent_msg") expect(JSON.parse(dryRunJson.stdout).operations[0].issueCode).toBe("assistant_message_missing_agent") + expect(JSON.parse(dryRunJson.stdout).supportedRepairs.some((repair: { code: string }) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(statSync(fixture.dbPath).mtimeMs).toBe(before) expect(await hasBackup(fixture.dbPath)).toBe(false) diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index 29079c78987a..9aaf24abc1de 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -3,7 +3,7 @@ import { Database as BunDatabase } from "bun:sqlite" import { mkdtempSync, rmSync, mkdirSync, existsSync, statSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" -import { generateDoctorReport, generateRepairPlan } from "@opencode-ai/core/database/health" +import { SUPPORTED_REPAIRS, generateDoctorReport, generateRepairPlan } from "@opencode-ai/core/database/health" import { applyRepairPlan } from "@opencode-ai/core/database/repair" const cleanup: string[] = [] @@ -13,6 +13,16 @@ afterEach(() => { }) describe("database doctor and repair", () => { + test("documents the supported repair catalog", () => { + expect(SUPPORTED_REPAIRS.map((repair) => repair.code).sort()).toEqual([ + "assistant_message_missing_agent", + "part_legacy_id_prefix", + "session_agent_missing", + "session_model_missing", + "session_path_missing", + ]) + }) + test("reports a missing database without creating it", async () => { const dir = tempDir() const dbPath = join(dir, "missing.db") @@ -21,7 +31,9 @@ describe("database doctor and repair", () => { const plan = await generateRepairPlan(dbPath) expect(report.exitCode).toBe(2) + expect(report.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(plan.exitCode).toBe(2) + expect(plan.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(existsSync(dbPath)).toBe(false) }) From e756ec239cd330b5be4b7cd0aa48248b60275832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 09:46:33 +0200 Subject: [PATCH 5/8] fix(opencode): version db repair catalog --- packages/core/src/database/health.ts | 59 ++++++++++++++++++++-- packages/opencode/src/cli/cmd/db-runner.ts | 8 ++- packages/opencode/test/db/cli.test.ts | 2 + packages/opencode/test/db/health.test.ts | 5 ++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index e0ca58b8d570..e48bfc3b680a 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -1,6 +1,7 @@ export * as DatabaseHealth from "./health" import { Database as BunDatabase } from "bun:sqlite" +import { InstallationVersion } from "../installation/version" export type RepairMode = "safe" export type IssueSeverity = "info" | "warning" | "error" export type Confidence = "low" | "medium" | "high" @@ -9,16 +10,30 @@ export interface SupportedRepair { code: string table: "session" | "session_message" | "part" repairable: boolean + sourceVersions: string + targetVersion: string + targetInvariant: string + introducedBy?: string description: string repair: string safety: string } +export interface CompatibilityContext { + targetOpenCodeVersion: string + sessionVersions: { version: string; count: number }[] + appliedMigrations: string[] + latestAppliedMigration?: string +} + export const SUPPORTED_REPAIRS = [ { code: "part_legacy_id_prefix", table: "part", repairable: true, + sourceVersions: "Observed on affected session.version values 1.2.21 and 1.2.22; older part_ rows are matched by persisted shape, not by version guesswork.", + targetVersion: InstallationVersion, + targetInvariant: "Session message part IDs must satisfy PartID, which currently requires the prt_ prefix.", description: "Legacy message part rows use part_ IDs, while current schemas validate message part IDs with the prt_ prefix.", repair: "Rename part.id from part_ to prt_ when the target id does not already exist.", safety: "Primary-key update only; message/session foreign key columns and part data are unchanged; apply rechecks the source row and target-id absence.", @@ -27,6 +42,9 @@ export const SUPPORTED_REPAIRS = [ code: "assistant_message_missing_agent", table: "session_message", repairable: true, + sourceVersions: "Observed on pre-session-metadata rows; matched by missing data.agent with non-empty data.mode.", + targetVersion: InstallationVersion, + targetInvariant: "Assistant session_message.data should carry agent, not only mode.", description: "Legacy assistant session_message.data rows may have mode but no agent.", repair: "Copy data.mode to data.agent when mode is a single non-empty string and agent is missing.", safety: "JSON update only; apply rechecks the original JSON payload and mode before writing.", @@ -35,6 +53,10 @@ export const SUPPORTED_REPAIRS = [ code: "session_agent_missing", table: "session", repairable: true, + sourceVersions: "Rows created before session metadata was denormalized onto session rows.", + targetVersion: InstallationVersion, + targetInvariant: "session.agent is present when a single unambiguous agent can be derived.", + introducedBy: "20260511173437_session-metadata", description: "Legacy session rows may miss the denormalized session.agent field required by current callers.", repair: "Set session.agent only when one unambiguous value can be derived from assistant or agent-switched messages.", safety: "Skipped when no single value is derivable; apply rederives the value before writing.", @@ -43,6 +65,10 @@ export const SUPPORTED_REPAIRS = [ code: "session_model_missing", table: "session", repairable: true, + sourceVersions: "Rows created before session metadata was denormalized onto session rows.", + targetVersion: InstallationVersion, + targetInvariant: "session.model is present when a single unambiguous model can be derived.", + introducedBy: "20260511173437_session-metadata", description: "Legacy session rows may miss the denormalized session.model field required by current callers.", repair: "Set session.model only when one unambiguous model object can be derived from assistant or model-switched messages.", safety: "Skipped when no single value is derivable; apply rederives the value before writing.", @@ -51,6 +77,10 @@ export const SUPPORTED_REPAIRS = [ code: "session_path_missing", table: "session", repairable: true, + sourceVersions: "Rows created before session.path was added or rows left null by earlier migrations.", + targetVersion: InstallationVersion, + targetInvariant: "session.path is present when session.directory is non-empty.", + introducedBy: "20260428004200_add_session_path", description: "Legacy session rows may miss session.path after the current schema expects it.", repair: "Set session.path from the same row's session.directory when directory is non-empty.", safety: "Does not rewrite directory/worktree semantics; apply rechecks the original empty path and derived directory value.", @@ -77,6 +107,7 @@ export interface DoctorReport { dbPath: string checkedAt: string schemaSupported: boolean + compatibility: CompatibilityContext supportedRepairs: SupportedRepair[] sessionCount?: number messageCount?: number @@ -103,6 +134,7 @@ export interface RepairPlan { dbPath: string generatedAt: string mode: RepairMode + compatibility: CompatibilityContext supportedRepairs: SupportedRepair[] operations: RepairOperation[] warnings: string[] @@ -151,13 +183,13 @@ export async function generateDoctorReport(dbPath: string): Promise { const schema = analyzeSchema(db) if (!schema.supported) { - return buildReport(dbPath, schema, 0, 0, schema.issues) + return buildReport(dbPath, schema, readCompatibility(db), 0, 0, schema.issues) } const sessions = analyzeSessions(db, schema.columns.session) const messages = analyzeMessages(db) const parts = analyzeParts(db, schema.columns.part) - return buildReport(dbPath, schema, sessions.count, messages.count, [...schema.issues, ...sessions.issues, ...messages.issues, ...parts.issues]) + return buildReport(dbPath, schema, readCompatibility(db), sessions.count, messages.count, [...schema.issues, ...sessions.issues, ...messages.issues, ...parts.issues]) }) } catch (error) { return unreadableDoctorReport(dbPath, "database_unreadable", `Database is unreadable: ${errorMessage(error)}`) @@ -177,6 +209,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf dbPath, generatedAt: new Date().toISOString(), mode, + compatibility: readCompatibility(db), supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: schema.issues.map((issue) => issue.reason), @@ -193,6 +226,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf dbPath, generatedAt: new Date().toISOString(), mode, + compatibility: readCompatibility(db), supportedRepairs: SUPPORTED_REPAIRS, operations, warnings: operations.flatMap((operation) => (operation.warning ? [operation.warning] : [])), @@ -278,11 +312,12 @@ export function analyzeParts(db: BunDatabase, columns?: Set): { count: n } } -function buildReport(dbPath: string, schema: SchemaStatus, sessionCount: number, messageCount: number, issues: Issue[]) { +function buildReport(dbPath: string, schema: SchemaStatus, compatibility: CompatibilityContext, sessionCount: number, messageCount: number, issues: Issue[]) { return { dbPath, checkedAt: new Date().toISOString(), schemaSupported: schema.supported, + compatibility, supportedRepairs: SUPPORTED_REPAIRS, sessionCount, messageCount, @@ -296,6 +331,7 @@ function unreadableDoctorReport(dbPath: string, code: string, reason: string) { dbPath, checkedAt: new Date().toISOString(), schemaSupported: false, + compatibility: readCompatibility(undefined), supportedRepairs: SUPPORTED_REPAIRS, issues: [ { @@ -314,6 +350,7 @@ function unreadableRepairPlan(dbPath: string, mode: RepairMode, reason: string) dbPath, generatedAt: new Date().toISOString(), mode, + compatibility: readCompatibility(undefined), supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: [reason], @@ -342,6 +379,22 @@ function tableColumns(db: BunDatabase, table: string) { return new Set(db.query(`PRAGMA table_info(${table})`).all().map((row) => (row as { name: string }).name)) } +function readCompatibility(db: BunDatabase | undefined): CompatibilityContext { + if (!db) return { targetOpenCodeVersion: InstallationVersion, sessionVersions: [], appliedMigrations: [] } + const sessionVersions = tableColumns(db, "session").has("version") + ? (db.query("SELECT version, count(*) AS count FROM session GROUP BY version ORDER BY count DESC").all() as { version: string; count: number }[]) + : [] + const appliedMigrations = tableColumns(db, "migration").has("id") + ? (db.query("SELECT id FROM migration ORDER BY id").all() as { id: string }[]).map((row) => row.id) + : [] + return { + targetOpenCodeVersion: InstallationVersion, + sessionVersions, + appliedMigrations, + latestAppliedMigration: appliedMigrations.at(-1), + } +} + function missingTableIssues(table: string, columns: Set): Issue[] { if (columns.size > 0) return [] return [ diff --git a/packages/opencode/src/cli/cmd/db-runner.ts b/packages/opencode/src/cli/cmd/db-runner.ts index b6aecc973cfb..831a3d02bbb5 100644 --- a/packages/opencode/src/cli/cmd/db-runner.ts +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -32,11 +32,13 @@ function printDoctorReport(report: Awaited console.log(`- ${repair.code}: ${repair.repair}`)) + report.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: ${repair.sourceVersions} Target: ${repair.targetInvariant}`)) console.log("") if (report.issues.length === 0) console.log("Issues: None") report.issues.forEach((issue) => { @@ -55,9 +57,11 @@ function printRepairPlan(plan: RepairPlan) { console.log("============================") console.log(`Database: ${plan.dbPath}`) console.log(`Mode: ${plan.mode}`) + console.log(`Target OpenCode: ${plan.compatibility.targetOpenCodeVersion}`) + console.log(`Latest migration: ${plan.compatibility.latestAppliedMigration ?? "none"}`) console.log("") console.log("Supported repairs:") - plan.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: ${repair.repair}`)) + plan.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: ${repair.sourceVersions} Target: ${repair.targetInvariant}`)) console.log("") if (plan.operations.length === 0) console.log("Repair plan: No repairs needed") plan.operations.forEach((operation) => { diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts index b9f9a2ffab32..020efb655233 100644 --- a/packages/opencode/test/db/cli.test.ts +++ b/packages/opencode/test/db/cli.test.ts @@ -17,6 +17,7 @@ describe("opencode db CLI doctor and repair", () => { healthy.db.close() const healthyResult = await capture(() => runDoctorCommand(healthy.dbPath, { json: false })) expect(healthyResult.exitCode).toBe(0) + expect(healthyResult.stdout).toContain("Target OpenCode:") expect(healthyResult.stdout).toContain("Supported repairs:") expect(healthyResult.stdout).toContain("part_legacy_id_prefix") expect(healthyResult.stdout).toContain("No changes were made.") @@ -44,6 +45,7 @@ describe("opencode db CLI doctor and repair", () => { const dryRunJson = await capture(() => runRepairCommand(fixture.dbPath, { dryRun: true, apply: false, json: true })) expect(dryRun.exitCode).toBe(1) + expect(dryRun.stdout).toContain("Target OpenCode:") expect(dryRun.stdout).toContain("No changes were made.") expect(dryRun.stdout).toContain("Supported repairs:") expect(dryRun.stdout).toContain("repair_assistant_agent_msg") diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index 9aaf24abc1de..b4ef81cb3130 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -21,6 +21,8 @@ describe("database doctor and repair", () => { "session_model_missing", "session_path_missing", ]) + expect(SUPPORTED_REPAIRS.find((repair) => repair.code === "part_legacy_id_prefix")?.sourceVersions).toContain("1.2.21") + expect(SUPPORTED_REPAIRS.every((repair) => repair.targetVersion.length > 0 && repair.targetInvariant.length > 0)).toBe(true) }) test("reports a missing database without creating it", async () => { @@ -31,8 +33,10 @@ describe("database doctor and repair", () => { const plan = await generateRepairPlan(dbPath) expect(report.exitCode).toBe(2) + expect(report.compatibility.targetOpenCodeVersion.length).toBeGreaterThan(0) expect(report.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(plan.exitCode).toBe(2) + expect(plan.compatibility.targetOpenCodeVersion.length).toBeGreaterThan(0) expect(plan.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(existsSync(dbPath)).toBe(false) }) @@ -149,6 +153,7 @@ describe("database doctor and repair", () => { db.close() expect(report.issues.some((issue) => issue.code === "part_legacy_id_prefix" && issue.repairable)).toBe(true) + expect(report.compatibility.sessionVersions).toEqual([{ version: "1", count: 1 }]) expect(plan.operations.map((operation) => operation.issueCode)).toContain("part_legacy_id_prefix") expect(result.success).toBe(true) expect(rows.map((row) => row.id)).toEqual(["prt_ccf86b97b002MOqoKxDVMesjG9"]) From 67ba92d7af368123d4471ad9f6fd37e578ad8278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 09:50:00 +0200 Subject: [PATCH 6/8] fix(opencode): clarify db repair target --- packages/core/src/database/health.ts | 58 ++++++++++++++-------- packages/opencode/src/cli/cmd/db-runner.ts | 10 ++-- packages/opencode/test/db/cli.test.ts | 2 + packages/opencode/test/db/health.test.ts | 7 ++- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index e48bfc3b680a..aeee01a2c80c 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -2,6 +2,7 @@ export * as DatabaseHealth from "./health" import { Database as BunDatabase } from "bun:sqlite" import { InstallationVersion } from "../installation/version" +import { migrations } from "./migration.gen" export type RepairMode = "safe" export type IssueSeverity = "info" | "warning" | "error" export type Confidence = "low" | "medium" | "high" @@ -10,10 +11,10 @@ export interface SupportedRepair { code: string table: "session" | "session_message" | "part" repairable: boolean - sourceVersions: string - targetVersion: string + targetOpenCodeVersion: string + targetMigration?: string targetInvariant: string - introducedBy?: string + sourceEvidence: string description: string repair: string safety: string @@ -21,6 +22,8 @@ export interface SupportedRepair { export interface CompatibilityContext { targetOpenCodeVersion: string + expectedMigrations: string[] + latestExpectedMigration?: string sessionVersions: { version: string; count: number }[] appliedMigrations: string[] latestAppliedMigration?: string @@ -31,10 +34,10 @@ export const SUPPORTED_REPAIRS = [ code: "part_legacy_id_prefix", table: "part", repairable: true, - sourceVersions: "Observed on affected session.version values 1.2.21 and 1.2.22; older part_ rows are matched by persisted shape, not by version guesswork.", - targetVersion: InstallationVersion, + targetOpenCodeVersion: InstallationVersion, targetInvariant: "Session message part IDs must satisfy PartID, which currently requires the prt_ prefix.", - description: "Legacy message part rows use part_ IDs, while current schemas validate message part IDs with the prt_ prefix.", + sourceEvidence: "Observed on affected session.version values 1.2.21 and 1.2.22. No exact SDK/OpenCode change boundary is asserted here; detection is based on violating the migrated target invariant.", + description: "Historical message part rows use part_ IDs, while the migrated target schema validates message part IDs with the prt_ prefix.", repair: "Rename part.id from part_ to prt_ when the target id does not already exist.", safety: "Primary-key update only; message/session foreign key columns and part data are unchanged; apply rechecks the source row and target-id absence.", }, @@ -42,10 +45,10 @@ export const SUPPORTED_REPAIRS = [ code: "assistant_message_missing_agent", table: "session_message", repairable: true, - sourceVersions: "Observed on pre-session-metadata rows; matched by missing data.agent with non-empty data.mode.", - targetVersion: InstallationVersion, + targetOpenCodeVersion: InstallationVersion, targetInvariant: "Assistant session_message.data should carry agent, not only mode.", - description: "Legacy assistant session_message.data rows may have mode but no agent.", + sourceEvidence: "Matched by target-shape violation: missing data.agent with non-empty data.mode.", + description: "Assistant session_message.data rows may have mode but no agent after migration to the current target shape.", repair: "Copy data.mode to data.agent when mode is a single non-empty string and agent is missing.", safety: "JSON update only; apply rechecks the original JSON payload and mode before writing.", }, @@ -53,11 +56,11 @@ export const SUPPORTED_REPAIRS = [ code: "session_agent_missing", table: "session", repairable: true, - sourceVersions: "Rows created before session metadata was denormalized onto session rows.", - targetVersion: InstallationVersion, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260511173437_session-metadata", targetInvariant: "session.agent is present when a single unambiguous agent can be derived.", - introducedBy: "20260511173437_session-metadata", - description: "Legacy session rows may miss the denormalized session.agent field required by current callers.", + sourceEvidence: "Matched by target-shape violation after the session metadata migration: missing session.agent.", + description: "Session rows may miss the denormalized session.agent field required by the migrated target shape.", repair: "Set session.agent only when one unambiguous value can be derived from assistant or agent-switched messages.", safety: "Skipped when no single value is derivable; apply rederives the value before writing.", }, @@ -65,11 +68,11 @@ export const SUPPORTED_REPAIRS = [ code: "session_model_missing", table: "session", repairable: true, - sourceVersions: "Rows created before session metadata was denormalized onto session rows.", - targetVersion: InstallationVersion, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260511173437_session-metadata", targetInvariant: "session.model is present when a single unambiguous model can be derived.", - introducedBy: "20260511173437_session-metadata", - description: "Legacy session rows may miss the denormalized session.model field required by current callers.", + sourceEvidence: "Matched by target-shape violation after the session metadata migration: missing session.model.", + description: "Session rows may miss the denormalized session.model field required by the migrated target shape.", repair: "Set session.model only when one unambiguous model object can be derived from assistant or model-switched messages.", safety: "Skipped when no single value is derivable; apply rederives the value before writing.", }, @@ -77,11 +80,11 @@ export const SUPPORTED_REPAIRS = [ code: "session_path_missing", table: "session", repairable: true, - sourceVersions: "Rows created before session.path was added or rows left null by earlier migrations.", - targetVersion: InstallationVersion, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260428004200_add_session_path", targetInvariant: "session.path is present when session.directory is non-empty.", - introducedBy: "20260428004200_add_session_path", - description: "Legacy session rows may miss session.path after the current schema expects it.", + sourceEvidence: "Matched by target-shape violation after the session.path migration: missing session.path with non-empty session.directory.", + description: "Session rows may miss session.path after migration to the current target shape.", repair: "Set session.path from the same row's session.directory when directory is non-empty.", safety: "Does not rewrite directory/worktree semantics; apply rechecks the original empty path and derived directory value.", }, @@ -380,7 +383,16 @@ function tableColumns(db: BunDatabase, table: string) { } function readCompatibility(db: BunDatabase | undefined): CompatibilityContext { - if (!db) return { targetOpenCodeVersion: InstallationVersion, sessionVersions: [], appliedMigrations: [] } + const expectedMigrations = migrations.map((migration) => migration.id) + if (!db) { + return { + targetOpenCodeVersion: InstallationVersion, + expectedMigrations, + latestExpectedMigration: expectedMigrations.at(-1), + sessionVersions: [], + appliedMigrations: [], + } + } const sessionVersions = tableColumns(db, "session").has("version") ? (db.query("SELECT version, count(*) AS count FROM session GROUP BY version ORDER BY count DESC").all() as { version: string; count: number }[]) : [] @@ -389,6 +401,8 @@ function readCompatibility(db: BunDatabase | undefined): CompatibilityContext { : [] return { targetOpenCodeVersion: InstallationVersion, + expectedMigrations, + latestExpectedMigration: expectedMigrations.at(-1), sessionVersions, appliedMigrations, latestAppliedMigration: appliedMigrations.at(-1), diff --git a/packages/opencode/src/cli/cmd/db-runner.ts b/packages/opencode/src/cli/cmd/db-runner.ts index 831a3d02bbb5..ed6fb7032e99 100644 --- a/packages/opencode/src/cli/cmd/db-runner.ts +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -33,12 +33,13 @@ function printDoctorReport(report: Awaited console.log(`- ${repair.code}: ${repair.sourceVersions} Target: ${repair.targetInvariant}`)) + report.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: target ${repair.targetMigration ?? report.compatibility.targetOpenCodeVersion}; ${repair.targetInvariant}`)) console.log("") if (report.issues.length === 0) console.log("Issues: None") report.issues.forEach((issue) => { @@ -58,10 +59,11 @@ function printRepairPlan(plan: RepairPlan) { console.log(`Database: ${plan.dbPath}`) console.log(`Mode: ${plan.mode}`) console.log(`Target OpenCode: ${plan.compatibility.targetOpenCodeVersion}`) - console.log(`Latest migration: ${plan.compatibility.latestAppliedMigration ?? "none"}`) + console.log(`Target migration: ${plan.compatibility.latestExpectedMigration ?? "none"}`) + console.log(`Applied migration: ${plan.compatibility.latestAppliedMigration ?? "none"}`) console.log("") console.log("Supported repairs:") - plan.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: ${repair.sourceVersions} Target: ${repair.targetInvariant}`)) + plan.supportedRepairs.forEach((repair) => console.log(`- ${repair.code}: target ${repair.targetMigration ?? plan.compatibility.targetOpenCodeVersion}; ${repair.targetInvariant}`)) console.log("") if (plan.operations.length === 0) console.log("Repair plan: No repairs needed") plan.operations.forEach((operation) => { diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts index 020efb655233..a56a3bffec5f 100644 --- a/packages/opencode/test/db/cli.test.ts +++ b/packages/opencode/test/db/cli.test.ts @@ -18,6 +18,7 @@ describe("opencode db CLI doctor and repair", () => { const healthyResult = await capture(() => runDoctorCommand(healthy.dbPath, { json: false })) expect(healthyResult.exitCode).toBe(0) expect(healthyResult.stdout).toContain("Target OpenCode:") + expect(healthyResult.stdout).toContain("Target migration:") expect(healthyResult.stdout).toContain("Supported repairs:") expect(healthyResult.stdout).toContain("part_legacy_id_prefix") expect(healthyResult.stdout).toContain("No changes were made.") @@ -46,6 +47,7 @@ describe("opencode db CLI doctor and repair", () => { expect(dryRun.exitCode).toBe(1) expect(dryRun.stdout).toContain("Target OpenCode:") + expect(dryRun.stdout).toContain("Target migration:") expect(dryRun.stdout).toContain("No changes were made.") expect(dryRun.stdout).toContain("Supported repairs:") expect(dryRun.stdout).toContain("repair_assistant_agent_msg") diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index b4ef81cb3130..901ca69765de 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -21,8 +21,9 @@ describe("database doctor and repair", () => { "session_model_missing", "session_path_missing", ]) - expect(SUPPORTED_REPAIRS.find((repair) => repair.code === "part_legacy_id_prefix")?.sourceVersions).toContain("1.2.21") - expect(SUPPORTED_REPAIRS.every((repair) => repair.targetVersion.length > 0 && repair.targetInvariant.length > 0)).toBe(true) + expect(SUPPORTED_REPAIRS.find((repair) => repair.code === "part_legacy_id_prefix")?.sourceEvidence).toContain("1.2.21") + expect(SUPPORTED_REPAIRS.find((repair) => repair.code === "session_path_missing")?.targetMigration).toBe("20260428004200_add_session_path") + expect(SUPPORTED_REPAIRS.every((repair) => repair.targetOpenCodeVersion.length > 0 && repair.targetInvariant.length > 0)).toBe(true) }) test("reports a missing database without creating it", async () => { @@ -34,9 +35,11 @@ describe("database doctor and repair", () => { expect(report.exitCode).toBe(2) expect(report.compatibility.targetOpenCodeVersion.length).toBeGreaterThan(0) + expect(report.compatibility.latestExpectedMigration).toBe("20260612174303_project_dir_strategy") expect(report.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(plan.exitCode).toBe(2) expect(plan.compatibility.targetOpenCodeVersion.length).toBeGreaterThan(0) + expect(plan.compatibility.latestExpectedMigration).toBe("20260612174303_project_dir_strategy") expect(plan.supportedRepairs.some((repair) => repair.code === "part_legacy_id_prefix")).toBe(true) expect(existsSync(dbPath)).toBe(false) }) From 75378caf9a9079decfe6ed24b250a92592d2b83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 10:07:35 +0200 Subject: [PATCH 7/8] fix(opencode): repair migrated message fields --- packages/core/src/database/health.ts | 299 ++++++++++++++++++++++- packages/core/src/database/repair.ts | 106 ++++++++ packages/opencode/test/db/health.test.ts | 54 ++++ 3 files changed, 447 insertions(+), 12 deletions(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index aeee01a2c80c..60fb32103f14 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -9,7 +9,7 @@ export type Confidence = "low" | "medium" | "high" export interface SupportedRepair { code: string - table: "session" | "session_message" | "part" + table: "session" | "session_message" | "message" | "part" repairable: boolean targetOpenCodeVersion: string targetMigration?: string @@ -88,6 +88,94 @@ export const SUPPORTED_REPAIRS = [ repair: "Set session.path from the same row's session.directory when directory is non-empty.", safety: "Does not rewrite directory/worktree semantics; apply rechecks the original empty path and derived directory value.", }, + { + code: "message_assistant_missing_parent", + table: "message", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "Assistant message data must include parentID for the current MessageV2.WithParts response schema.", + sourceEvidence: "Reported in #29908 as Missing key at message info parentID after schema tightening without a backfill migration.", + description: "Assistant message rows can miss parentID after migration to the current target shape.", + repair: "Set parentID to the immediately preceding message in the same session when one exists.", + safety: "Skipped when no preceding message exists; apply rechecks the original JSON payload before writing.", + }, + { + code: "message_user_missing_agent", + table: "message", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "User message data must include agent for the current MessageV2.WithParts response schema.", + sourceEvidence: "Reported in #29908 as Missing key at user message agent after schema tightening without a backfill migration.", + description: "User message rows can miss agent after migration to the current target shape.", + repair: "Set agent only when one unambiguous agent can be derived from messages in the same session.", + safety: "Skipped when no single value is derivable; apply rederives the value before writing.", + }, + { + code: "message_user_missing_model", + table: "message", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "User message data must include model.providerID and model.modelID for the current MessageV2.WithParts response schema.", + sourceEvidence: "Reported in #29908 as Missing key at user message model after schema tightening without a backfill migration.", + description: "User message rows can miss model after migration to the current target shape.", + repair: "Set model only when one unambiguous model can be derived from assistant messages in the same session.", + safety: "Skipped when no single value is derivable; apply rederives the value before writing.", + }, + { + code: "part_step_finish_missing_reason", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "step-finish parts must include reason for the current Part schema.", + sourceEvidence: "Reported in #29908 as Missing key at parts reason after schema tightening without a backfill migration.", + description: "step-finish part rows can miss reason after migration to the current target shape.", + repair: "Set reason to stop when the key is missing.", + safety: "Only adds the missing reason key; apply rechecks the original JSON payload before writing.", + }, + { + code: "part_compaction_missing_auto", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "compaction parts must include auto for the current Part schema.", + sourceEvidence: "Reported in #29908 as compaction parts missing auto after schema tightening without a backfill migration.", + description: "compaction part rows can miss auto after migration to the current target shape.", + repair: "Set auto to false when the key is missing.", + safety: "Only adds the missing auto key; apply rechecks the original JSON payload before writing.", + }, + { + code: "part_tool_completed_missing_metadata", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "completed tool parts must include state.metadata for the current Part schema.", + sourceEvidence: "Reported in #29908 as completed tool parts missing state.metadata after schema tightening without a backfill migration.", + description: "completed tool part rows can miss state.metadata after migration to the current target shape.", + repair: "Set state.metadata to an empty object when the key is missing.", + safety: "Only adds the missing metadata key; apply rechecks the original JSON payload before writing.", + }, + { + code: "part_tool_completed_missing_title", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "completed tool parts must include state.title for the current Part schema.", + sourceEvidence: "Reported in #29908 as completed tool parts missing state.title after schema tightening without a backfill migration.", + description: "completed tool part rows can miss state.title after migration to the current target shape.", + repair: "Set state.title from the part tool name when available.", + safety: "Skipped when the tool name is not a non-empty string; apply rechecks the original JSON payload before writing.", + }, + { + code: "part_tool_completed_missing_time", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "completed tool parts must include state.time.start and state.time.end for the current Part schema.", + sourceEvidence: "Reported in #29908 as completed tool parts missing state.time after schema tightening without a backfill migration.", + description: "completed tool part rows can miss state.time after migration to the current target shape.", + repair: "Set missing start/end timestamps from the part row time_created.", + safety: "Only fills missing time keys from the row timestamp; apply rechecks the original JSON payload before writing.", + }, ] satisfies SupportedRepair[] export interface Issue { @@ -121,7 +209,7 @@ export interface DoctorReport { export interface RepairOperation { id: string issueCode: string - table: "session" | "session_message" | "part" + table: "session" | "session_message" | "message" | "part" rowId: string before: unknown after: unknown @@ -150,6 +238,7 @@ interface SchemaStatus { columns: { session: Set sessionMessage: Set + message: Set part: Set project: Set } @@ -171,10 +260,19 @@ interface SessionMessageRow { data: string } +interface MessageRow { + id: string + session_id: string + time_created: number + data: string +} + interface PartRow { id: string message_id: string session_id: string + time_created?: number + data?: string } export async function generateDoctorReport(dbPath: string): Promise { @@ -190,9 +288,10 @@ export async function generateDoctorReport(dbPath: string): Promise 0) { - return { supported: false, issues, columns: { session, sessionMessage, part, project } } + return { supported: false, issues, columns: { session, sessionMessage, message, part, project } } } const columnIssues: Issue[] = [ ...missingColumnIssues("session_message", sessionMessage, ["id", "session_id", "type", "data"]), + ...optionalMissingColumnIssues("message", message, ["id", "session_id", "time_created", "data"]), ...missingColumnIssues("session", session, ["id", "project_id", "directory", "path", "agent", "model"]), - ...optionalMissingColumnIssues("part", part, ["id", "message_id", "session_id"]), + ...optionalMissingColumnIssues("part", part, ["id", "message_id", "session_id", "time_created", "data"]), ...missingColumnIssues("project", project, ["id", "worktree"]), ] @@ -276,7 +378,7 @@ export function analyzeSchema(db: BunDatabase): SchemaStatus { return { supported: !columnIssues.some((issue) => issue.severity === "error"), issues: columnIssues, - columns: { session, sessionMessage, part, project }, + columns: { session, sessionMessage, message, part, project }, } } @@ -293,7 +395,7 @@ export function analyzeSessions(db: BunDatabase, columns?: Set): { count } } -export function analyzeMessages(db: BunDatabase): { count: number; issues: Issue[] } { +export function analyzeSessionMessages(db: BunDatabase): { count: number; issues: Issue[] } { const rows = db.query("SELECT id, session_id, type, data FROM session_message WHERE type = ?").all("assistant") return { count: readCount(db, "session_message"), @@ -301,6 +403,22 @@ export function analyzeMessages(db: BunDatabase): { count: number; issues: Issue } } +export function analyzeMessages(db: BunDatabase, columns?: Set): { count: number; issues: Issue[] } { + if (columns?.size === 0) return { count: 0, issues: [] } + const count = readCount(db, "message") + if (columns && !(columns.has("id") && columns.has("session_id") && columns.has("time_created") && columns.has("data"))) return { count, issues: [] } + + return { + count, + issues: db + .query( + "SELECT id, session_id, time_created, data FROM message WHERE (json_extract(data, '$.role') = 'assistant' AND json_extract(data, '$.parentID') IS NULL) OR (json_extract(data, '$.role') = 'user' AND (json_extract(data, '$.agent') IS NULL OR json_extract(data, '$.model') IS NULL))", + ) + .all() + .flatMap((row) => messageIssues(db, row as MessageRow)), + } +} + export function analyzeParts(db: BunDatabase, columns?: Set): { count: number; issues: Issue[] } { if (columns?.size === 0) return { count: 0, issues: [] } const count = readCount(db, "part") @@ -309,9 +427,11 @@ export function analyzeParts(db: BunDatabase, columns?: Set): { count: n return { count, issues: db - .query("SELECT id, message_id, session_id FROM part WHERE id LIKE 'part\\_%' ESCAPE '\\'") + .query( + "SELECT id, message_id, session_id, time_created, data FROM part WHERE id LIKE 'part\\_%' ESCAPE '\\' OR (json_extract(data, '$.type') = 'step-finish' AND json_extract(data, '$.reason') IS NULL) OR (json_extract(data, '$.type') = 'compaction' AND json_extract(data, '$.auto') IS NULL) OR (json_extract(data, '$.type') = 'tool' AND json_extract(data, '$.state.status') = 'completed' AND (json_extract(data, '$.state.metadata') IS NULL OR json_extract(data, '$.state.title') IS NULL OR json_extract(data, '$.state.time.start') IS NULL OR json_extract(data, '$.state.time.end') IS NULL))", + ) .all() - .flatMap((row) => partIDIssues(db, row as PartRow)), + .flatMap((row) => partIssues(db, row as PartRow)), } } @@ -487,6 +607,85 @@ function sessionMetadataIssues(db: BunDatabase, row: SessionRow): Issue[] { ] } +function messageIssues(db: BunDatabase, row: MessageRow): Issue[] { + const data = parseObject(row.data) + if (!data) return malformedDataIssue("message", row.id, row.session_id, row.id, "message.data contains malformed JSON") + if (data.role === "assistant") { + return missingFieldIssues(row, [ + ["parentID", deriveAssistantParentID(db, row), "message_assistant_missing_parent", "set_message_parent"], + ]) + } + if (data.role === "user") { + return missingFieldIssues(row, [ + ["agent", deriveMessageAgent(db, row.session_id), "message_user_missing_agent", "set_message_agent"], + ["model", deriveMessageModel(db, row.session_id), "message_user_missing_model", "set_message_model"], + ]) + } + return [] +} + +function partIssues(db: BunDatabase, row: PartRow): Issue[] { + return [...(row.id.startsWith("part_") ? partIDIssues(db, row) : []), ...partDataIssues(row)] +} + +function partDataIssues(row: PartRow): Issue[] { + if (!row.data) return [] + const data = parseObject(row.data) + if (!data) return malformedDataIssue("part", row.id, row.session_id, row.message_id, "part.data contains malformed JSON") + if (data.type === "step-finish" && data.reason === undefined) return [jsonFieldIssue(row, "part_step_finish_missing_reason", "reason", "stop", "set_step_finish_reason")] + if (data.type === "compaction" && data.auto === undefined) return [jsonFieldIssue(row, "part_compaction_missing_auto", "auto", false, "set_compaction_auto")] + if (data.type !== "tool" || !isRecord(data.state) || data.state.status !== "completed") return [] + return [ + ...(data.state.metadata === undefined ? [jsonFieldIssue(row, "part_tool_completed_missing_metadata", "state.metadata", {}, "set_tool_state_metadata")] : []), + ...(data.state.title === undefined && nonEmptyString(data.tool) ? [jsonFieldIssue(row, "part_tool_completed_missing_title", "state.title", data.tool, "set_tool_state_title")] : []), + ...(isRecord(data.state.time) && data.state.time.start !== undefined && data.state.time.end !== undefined + ? [] + : [jsonFieldIssue(row, "part_tool_completed_missing_time", "state.time", { start: row.time_created ?? 0, end: row.time_created ?? 0 }, "set_tool_state_time")]), + ] +} + +function malformedDataIssue(table: "message" | "part", rowId: string, sessionId: string, messageId: string, reason: string): Issue[] { + return [{ code: `${table}_malformed_json`, severity: "warning" as const, table, rowId, sessionId, messageId, repairable: false, reason }] +} + +function missingFieldIssues(row: MessageRow, fields: [string, unknown, string, string][]): Issue[] { + const data = parseObject(row.data) + if (!data) return [] + return fields + .filter(([field]) => data[field] === undefined || data[field] === null || data[field] === "") + .map(([field, derived, code, suggestedRepair]) => ({ + code, + severity: "error" as const, + table: "message", + rowId: row.id, + sessionId: row.session_id, + messageId: row.id, + repairable: derived !== undefined, + reason: derived === undefined ? `message.data.${field} is missing and no single unambiguous value is derivable` : `message.data.${field} is missing`, + suggestedRepair: derived === undefined ? undefined : suggestedRepair, + confidence: derived === undefined ? undefined : ("high" as const), + before: { data: row.data, [field]: data[field] }, + after: derived === undefined ? undefined : { [field]: derived }, + })) +} + +function jsonFieldIssue(row: PartRow, code: string, field: string, value: unknown, suggestedRepair: string): Issue { + return { + code, + severity: "error" as const, + table: "part", + rowId: row.id, + sessionId: row.session_id, + messageId: row.message_id, + repairable: true, + reason: `part.data.${field} is missing`, + suggestedRepair, + confidence: "high" as const, + before: { data: row.data, [field]: undefined }, + after: { [field]: value }, + } +} + function partIDIssues(db: BunDatabase, row: PartRow): Issue[] { const repaired = `prt_${row.id.slice("part_".length)}` const exists = (db.query("SELECT count(*) AS count FROM part WHERE id = ?").get(repaired) as { count: number } | null)?.count !== 0 @@ -551,9 +750,49 @@ function deriveSessionModel(db: BunDatabase, sessionID: string) { ) } +function deriveAssistantParentID(db: BunDatabase, row: MessageRow) { + return ( + db + .query("SELECT id FROM message WHERE session_id = ? AND (time_created < ? OR (time_created = ? AND id < ?)) ORDER BY time_created DESC, id DESC LIMIT 1") + .get(row.session_id, row.time_created, row.time_created, row.id) as { id: string } | null + )?.id +} + +function deriveMessageAgent(db: BunDatabase, sessionID: string) { + const sessionAgent = (db.query("SELECT agent FROM session WHERE id = ?").get(sessionID) as { agent: string | null } | null)?.agent + if (nonEmptyString(sessionAgent)) return sessionAgent + return singleValue( + db + .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .all(sessionID) + .map((row) => parseObject((row as { data: string }).data)?.agent) + .filter(nonEmptyString), + ) +} + +function deriveMessageModel(db: BunDatabase, sessionID: string) { + const unique = [ + ...new Set( + db + .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .all(sessionID) + .map((row) => { + const data = parseObject((row as { data: string }).data) + if (!nonEmptyString(data?.providerID) || !nonEmptyString(data?.modelID)) return undefined + return JSON.stringify({ providerID: data.providerID, modelID: data.modelID, ...(nonEmptyString(data.variant) ? { variant: data.variant } : {}) }) + }) + .filter(nonEmptyString), + ), + ] + if (unique.length !== 1) return undefined + return JSON.parse(unique[0]) as Record +} + function operationForIssue(db: BunDatabase, issue: Issue): RepairOperation[] { if (issue.code === "assistant_message_missing_agent" && issue.rowId) return [assistantOperation(db, issue)] if (issue.code.startsWith("session_") && issue.code.endsWith("_missing") && issue.rowId && issue.repairable) return [sessionMetadataOperation(db, issue)] + if (issue.code.startsWith("message_") && issue.rowId && issue.repairable) return [messageOperation(db, issue)] + if (issue.code.startsWith("part_") && issue.code !== "part_legacy_id_prefix" && issue.rowId && issue.repairable) return [partDataOperation(db, issue)] if (issue.code === "part_legacy_id_prefix" && issue.rowId && issue.repairable) return [partIDOperation(db, issue)] return [] } @@ -595,6 +834,42 @@ function sessionMetadataOperation(db: BunDatabase, issue: Issue) { } } +function messageOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing message repair row id") + const row = db.query("SELECT id, session_id, time_created, data FROM message WHERE id = ?").get(issue.rowId) as MessageRow + return { + id: `repair_${issue.code}_${row.id}`, + issueCode: issue.code, + table: "message" as const, + rowId: row.id, + before: issue.before, + after: issue.after, + preconditions: { id: row.id, session_id: row.session_id, time_created: row.time_created, data: row.data }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "safe" as const, + } +} + +function partDataOperation(db: BunDatabase, issue: Issue) { + if (!issue.rowId) throw new Error("Missing part repair row id") + const row = db.query("SELECT id, message_id, session_id, time_created, data FROM part WHERE id = ?").get(issue.rowId) as PartRow + return { + id: `repair_${issue.code}_${row.id}`, + issueCode: issue.code, + table: "part" as const, + rowId: row.id, + before: issue.before, + after: issue.after, + preconditions: { id: row.id, message_id: row.message_id, session_id: row.session_id, time_created: row.time_created, data: row.data }, + reason: issue.reason, + confidence: "high" as const, + backupRequired: true, + mode: "safe" as const, + } +} + function parseObject(input: string) { try { const value: unknown = JSON.parse(input) diff --git a/packages/core/src/database/repair.ts b/packages/core/src/database/repair.ts index 960fb3e8d588..41bf9757967e 100644 --- a/packages/core/src/database/repair.ts +++ b/packages/core/src/database/repair.ts @@ -96,11 +96,76 @@ export async function applyRepairPlan(plan: RepairPlan) { function applyOperation(db: BunDatabase, operation: RepairOperation) { if (operation.issueCode === "part_legacy_id_prefix") return applyPartIDRepair(db, operation) + if (operation.issueCode.startsWith("message_")) return applyMessageDataRepair(db, operation) + if (operation.issueCode.startsWith("part_") && operation.issueCode !== "part_legacy_id_prefix") return applyPartDataRepair(db, operation) if (operation.issueCode === "assistant_message_missing_agent") return applyAssistantAgentRepair(db, operation) if (operation.issueCode.startsWith("session_") && operation.issueCode.endsWith("_missing")) return applySessionMetadataRepair(db, operation) throw new Error(`Unsupported repair operation: ${operation.issueCode}`) } +function applyMessageDataRepair(db: BunDatabase, operation: RepairOperation) { + const row = db.query("SELECT session_id, time_created, data FROM message WHERE id = ?").get(operation.rowId) as { session_id: string; time_created: number; data: string } | null + if (!row || row.session_id !== operation.preconditions.session_id || row.time_created !== operation.preconditions.time_created) { + throw new Error(`Precondition failed for ${operation.id}`) + } + const data = parseObject(row.data) + if (!data) throw new Error(`Precondition failed for ${operation.id}: malformed JSON`) + const after = operation.after as Record + if (operation.issueCode === "message_assistant_missing_parent") { + if (data.parentID !== undefined) throw new Error(`Precondition failed for ${operation.id}: parentID already set`) + if (!nonEmptyString(after.parentID)) throw new Error(`Invalid parentID repair value for ${operation.id}`) + if (deriveMessageRepairValue(db, operation) !== after.parentID) throw new Error(`Precondition failed for ${operation.id}: parentID derivation changed`) + data.parentID = after.parentID + } else if (operation.issueCode === "message_user_missing_agent") { + if (data.agent !== undefined) throw new Error(`Precondition failed for ${operation.id}: agent already set`) + if (!nonEmptyString(after.agent)) throw new Error(`Invalid agent repair value for ${operation.id}`) + if (deriveMessageRepairValue(db, operation) !== after.agent) throw new Error(`Precondition failed for ${operation.id}: agent derivation changed`) + data.agent = after.agent + } else if (operation.issueCode === "message_user_missing_model") { + if (data.model !== undefined) throw new Error(`Precondition failed for ${operation.id}: model already set`) + if (!isRecord(after.model)) throw new Error(`Invalid model repair value for ${operation.id}`) + if (JSON.stringify(deriveMessageRepairValue(db, operation)) !== JSON.stringify(after.model)) throw new Error(`Precondition failed for ${operation.id}: model derivation changed`) + data.model = after.model + } else { + throw new Error(`Unsupported message repair operation: ${operation.issueCode}`) + } + db.query("UPDATE message SET data = ? WHERE id = ?").run(JSON.stringify(data), operation.rowId) +} + +function applyPartDataRepair(db: BunDatabase, operation: RepairOperation) { + const row = db.query("SELECT message_id, session_id, time_created, data FROM part WHERE id = ?").get(operation.rowId) as { message_id: string; session_id: string; time_created: number; data: string } | null + if (!row || row.message_id !== operation.preconditions.message_id || row.session_id !== operation.preconditions.session_id || row.time_created !== operation.preconditions.time_created) { + throw new Error(`Precondition failed for ${operation.id}`) + } + const data = parseObject(row.data) + if (!data) throw new Error(`Precondition failed for ${operation.id}: malformed JSON`) + const after = operation.after as Record + if (operation.issueCode === "part_step_finish_missing_reason") { + if (data.reason !== undefined) throw new Error(`Precondition failed for ${operation.id}: reason already set`) + data.reason = after.reason + } else if (operation.issueCode === "part_compaction_missing_auto") { + if (data.auto !== undefined) throw new Error(`Precondition failed for ${operation.id}: auto already set`) + data.auto = after.auto + } else if (operation.issueCode.startsWith("part_tool_completed_")) { + if (!isRecord(data.state) || data.state.status !== "completed") throw new Error(`Precondition failed for ${operation.id}: tool state changed`) + if (operation.issueCode === "part_tool_completed_missing_metadata") { + if (data.state.metadata !== undefined) throw new Error(`Precondition failed for ${operation.id}: metadata already set`) + data.state.metadata = after["state.metadata"] + } else if (operation.issueCode === "part_tool_completed_missing_title") { + if (data.state.title !== undefined) throw new Error(`Precondition failed for ${operation.id}: title already set`) + data.state.title = after["state.title"] + } else if (operation.issueCode === "part_tool_completed_missing_time") { + if (isRecord(data.state.time) && data.state.time.start !== undefined && data.state.time.end !== undefined) throw new Error(`Precondition failed for ${operation.id}: time already set`) + data.state.time = after["state.time"] + } else { + throw new Error(`Unsupported part repair operation: ${operation.issueCode}`) + } + } else { + throw new Error(`Unsupported part repair operation: ${operation.issueCode}`) + } + db.query("UPDATE part SET data = ? WHERE id = ?").run(JSON.stringify(data), operation.rowId) +} + function applyPartIDRepair(db: BunDatabase, operation: RepairOperation) { const value = (operation.after as Record).id if (typeof value !== "string" || !value.startsWith("prt_")) throw new Error(`Invalid part id repair value for ${operation.id}`) @@ -154,6 +219,47 @@ function sessionMetadataField(issueCode: string) { throw new Error(`Unsupported session metadata operation: ${issueCode}`) } +function deriveMessageRepairValue(db: BunDatabase, operation: RepairOperation) { + const row = db.query("SELECT id, session_id, time_created FROM message WHERE id = ?").get(operation.rowId) as { id: string; session_id: string; time_created: number } | null + if (!row) return undefined + if (operation.issueCode === "message_assistant_missing_parent") { + return ( + db + .query("SELECT id FROM message WHERE session_id = ? AND (time_created < ? OR (time_created = ? AND id < ?)) ORDER BY time_created DESC, id DESC LIMIT 1") + .get(row.session_id, row.time_created, row.time_created, row.id) as { id: string } | null + )?.id + } + if (operation.issueCode === "message_user_missing_agent") { + const sessionAgent = (db.query("SELECT agent FROM session WHERE id = ?").get(row.session_id) as { agent: string | null } | null)?.agent + if (nonEmptyString(sessionAgent)) return sessionAgent + return singleValue( + db + .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .all(row.session_id) + .map((item) => parseObject((item as { data: string }).data)?.agent) + .filter(nonEmptyString), + ) + } + if (operation.issueCode === "message_user_missing_model") { + const unique = [ + ...new Set( + db + .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .all(row.session_id) + .map((item) => { + const data = parseObject((item as { data: string }).data) + if (!nonEmptyString(data?.providerID) || !nonEmptyString(data?.modelID)) return undefined + return JSON.stringify({ providerID: data.providerID, modelID: data.modelID, ...(nonEmptyString(data.variant) ? { variant: data.variant } : {}) }) + }) + .filter(nonEmptyString), + ), + ] + if (unique.length !== 1) return undefined + return JSON.parse(unique[0]) as Record + } + return undefined +} + function deriveSessionMetadataValue(db: BunDatabase, sessionID: string, field: "agent" | "model" | "path") { if (field === "path") { return (db.query("SELECT directory FROM session WHERE id = ?").get(sessionID) as { directory: string } | null)?.directory diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index 901ca69765de..f73990160c92 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -16,7 +16,15 @@ describe("database doctor and repair", () => { test("documents the supported repair catalog", () => { expect(SUPPORTED_REPAIRS.map((repair) => repair.code).sort()).toEqual([ "assistant_message_missing_agent", + "message_assistant_missing_parent", + "message_user_missing_agent", + "message_user_missing_model", + "part_compaction_missing_auto", "part_legacy_id_prefix", + "part_step_finish_missing_reason", + "part_tool_completed_missing_metadata", + "part_tool_completed_missing_time", + "part_tool_completed_missing_title", "session_agent_missing", "session_model_missing", "session_path_missing", @@ -163,6 +171,45 @@ describe("database doctor and repair", () => { expect(second.operations).toHaveLength(0) }) + test("repairs missing message and part fields required by current schemas", async () => { + const fixture = createFixture("message-part-fields") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) + fixture.db + .query("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)") + .run("msg_user", "ses", 1, 1, JSON.stringify({ role: "user", time: { created: 1 } })) + fixture.db + .query("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)") + .run("msg_assistant", "ses", 2, 2, JSON.stringify({ role: "assistant", time: { created: 2 }, agent: "build", providerID: "p", modelID: "m" })) + fixture.db + .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("prt_step", "msg_assistant", "ses", 3, 3, JSON.stringify({ type: "step-finish", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } })) + fixture.db.close() + + const plan = await generateRepairPlan(fixture.dbPath) + const result = await applyRepairPlan(plan) + const db = new BunDatabase(fixture.dbPath, { readonly: true }) + const user = JSON.parse((db.query("SELECT data FROM message WHERE id = ?").get("msg_user") as { data: string }).data) as { agent: string; model: { providerID: string; modelID: string } } + const assistant = JSON.parse((db.query("SELECT data FROM message WHERE id = ?").get("msg_assistant") as { data: string }).data) as { parentID: string } + const part = JSON.parse((db.query("SELECT data FROM part WHERE id = ?").get("prt_step") as { data: string }).data) as { reason: string } + db.close() + + expect(plan.operations.map((operation) => operation.issueCode).sort()).toEqual([ + "message_assistant_missing_parent", + "message_user_missing_agent", + "message_user_missing_model", + "part_step_finish_missing_reason", + ]) + expect(result.success).toBe(true) + expect(user.agent).toBe("build") + expect(user.model).toEqual({ providerID: "p", modelID: "m" }) + expect(assistant.parentID).toBe("msg_user") + expect(part.reason).toBe("stop") + expect((await generateRepairPlan(fixture.dbPath)).operations).toHaveLength(0) + }) + test("rollback leaves rows unchanged when a precondition fails", async () => { const fixture = createFixture("rollback") fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") @@ -282,6 +329,13 @@ function createFixture(name: string) { time_updated integer NOT NULL, data text NOT NULL ); + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); CREATE TABLE part ( id text PRIMARY KEY, message_id text NOT NULL, From 8889bbee547fc4d37fd16403037109724738c70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 10:27:24 +0200 Subject: [PATCH 8/8] fix(opencode): harden db repair planning --- packages/core/src/database/health.ts | 30 ++++++-- packages/core/src/database/repair.ts | 17 +++-- packages/opencode/src/cli/cmd/db-runner.ts | 2 +- packages/opencode/test/db/health.test.ts | 89 +++++++++++++++++++++- 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts index 60fb32103f14..e44ce74eae85 100644 --- a/packages/core/src/database/health.ts +++ b/packages/core/src/database/health.ts @@ -229,6 +229,7 @@ export interface RepairPlan { supportedRepairs: SupportedRepair[] operations: RepairOperation[] warnings: string[] + unrepairableErrors: string[] exitCode: 0 | 1 | 2 } @@ -315,6 +316,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: schema.issues.map((issue) => issue.reason), + unrepairableErrors: schema.issues.filter((issue) => issue.severity === "error").map((issue) => issue.reason), exitCode: 2 as const, } satisfies RepairPlan } @@ -325,6 +327,7 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf const parts = analyzeParts(db, schema.columns.part) const issues = [...sessions.issues, ...sessionMessages.issues, ...messages.issues, ...parts.issues] const operations = issues.flatMap((issue) => operationForIssue(db, issue)) + const unrepairableErrors = issues.filter((issue) => issue.severity === "error" && !issue.repairable).map((issue) => issue.reason) return { dbPath, generatedAt: new Date().toISOString(), @@ -332,8 +335,12 @@ export async function generateRepairPlan(dbPath: string, mode: RepairMode = "saf compatibility: readCompatibility(db), supportedRepairs: SUPPORTED_REPAIRS, operations, - warnings: operations.flatMap((operation) => (operation.warning ? [operation.warning] : [])), - exitCode: operations.length > 0 ? 1 : 0, + unrepairableErrors, + warnings: [ + ...issues.filter((issue) => !issue.repairable).map((issue) => issue.reason), + ...operations.flatMap((operation) => (operation.warning ? [operation.warning] : [])), + ], + exitCode: operations.length > 0 || issues.some((issue) => issue.severity === "error") ? 1 : 0, } satisfies RepairPlan }) } catch (error) { @@ -412,7 +419,7 @@ export function analyzeMessages(db: BunDatabase, columns?: Set): { count count, issues: db .query( - "SELECT id, session_id, time_created, data FROM message WHERE (json_extract(data, '$.role') = 'assistant' AND json_extract(data, '$.parentID') IS NULL) OR (json_extract(data, '$.role') = 'user' AND (json_extract(data, '$.agent') IS NULL OR json_extract(data, '$.model') IS NULL))", + "SELECT id, session_id, time_created, data FROM message WHERE CASE WHEN json_valid(data) = 0 THEN 1 WHEN json_type(data) != 'object' THEN 1 ELSE ((json_extract(data, '$.role') = 'assistant' AND json_extract(data, '$.parentID') IS NULL) OR (json_extract(data, '$.role') = 'user' AND (json_extract(data, '$.agent') IS NULL OR json_extract(data, '$.model') IS NULL))) END", ) .all() .flatMap((row) => messageIssues(db, row as MessageRow)), @@ -428,7 +435,7 @@ export function analyzeParts(db: BunDatabase, columns?: Set): { count: n count, issues: db .query( - "SELECT id, message_id, session_id, time_created, data FROM part WHERE id LIKE 'part\\_%' ESCAPE '\\' OR (json_extract(data, '$.type') = 'step-finish' AND json_extract(data, '$.reason') IS NULL) OR (json_extract(data, '$.type') = 'compaction' AND json_extract(data, '$.auto') IS NULL) OR (json_extract(data, '$.type') = 'tool' AND json_extract(data, '$.state.status') = 'completed' AND (json_extract(data, '$.state.metadata') IS NULL OR json_extract(data, '$.state.title') IS NULL OR json_extract(data, '$.state.time.start') IS NULL OR json_extract(data, '$.state.time.end') IS NULL))", + "SELECT id, message_id, session_id, time_created, data FROM part WHERE id LIKE 'part\\_%' ESCAPE '\\' OR CASE WHEN json_valid(data) = 0 THEN 1 WHEN json_type(data) != 'object' THEN 1 ELSE ((json_extract(data, '$.type') = 'step-finish' AND json_extract(data, '$.reason') IS NULL) OR (json_extract(data, '$.type') = 'compaction' AND json_extract(data, '$.auto') IS NULL) OR (json_extract(data, '$.type') = 'tool' AND json_extract(data, '$.state.status') = 'completed' AND (json_extract(data, '$.state.metadata') IS NULL OR json_extract(data, '$.state.title') IS NULL OR json_extract(data, '$.state.time.start') IS NULL OR json_extract(data, '$.state.time.end') IS NULL))) END", ) .all() .flatMap((row) => partIssues(db, row as PartRow)), @@ -477,6 +484,7 @@ function unreadableRepairPlan(dbPath: string, mode: RepairMode, reason: string) supportedRepairs: SUPPORTED_REPAIRS, operations: [], warnings: [reason], + unrepairableErrors: [reason], exitCode: 2 as const, } satisfies RepairPlan } @@ -640,7 +648,15 @@ function partDataIssues(row: PartRow): Issue[] { ...(data.state.title === undefined && nonEmptyString(data.tool) ? [jsonFieldIssue(row, "part_tool_completed_missing_title", "state.title", data.tool, "set_tool_state_title")] : []), ...(isRecord(data.state.time) && data.state.time.start !== undefined && data.state.time.end !== undefined ? [] - : [jsonFieldIssue(row, "part_tool_completed_missing_time", "state.time", { start: row.time_created ?? 0, end: row.time_created ?? 0 }, "set_tool_state_time")]), + : [ + jsonFieldIssue( + row, + "part_tool_completed_missing_time", + "state.time", + { start: isRecord(data.state.time) && data.state.time.start !== undefined ? data.state.time.start : (row.time_created ?? 0), end: isRecord(data.state.time) && data.state.time.end !== undefined ? data.state.time.end : (row.time_created ?? 0) }, + "set_tool_state_time", + ), + ]), ] } @@ -763,7 +779,7 @@ function deriveMessageAgent(db: BunDatabase, sessionID: string) { if (nonEmptyString(sessionAgent)) return sessionAgent return singleValue( db - .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .query("SELECT data FROM message WHERE session_id = ? AND json_valid(data) = 1 AND json_extract(data, '$.role') = 'assistant'") .all(sessionID) .map((row) => parseObject((row as { data: string }).data)?.agent) .filter(nonEmptyString), @@ -774,7 +790,7 @@ function deriveMessageModel(db: BunDatabase, sessionID: string) { const unique = [ ...new Set( db - .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .query("SELECT data FROM message WHERE session_id = ? AND json_valid(data) = 1 AND json_extract(data, '$.role') = 'assistant'") .all(sessionID) .map((row) => { const data = parseObject((row as { data: string }).data) diff --git a/packages/core/src/database/repair.ts b/packages/core/src/database/repair.ts index 41bf9757967e..1e295e226d9a 100644 --- a/packages/core/src/database/repair.ts +++ b/packages/core/src/database/repair.ts @@ -41,7 +41,12 @@ export async function applyRepairPlan(plan: RepairPlan) { return failedResult(plan.dbPath, "Cannot apply repairs to an unsupported or unreadable database") } + if (plan.unrepairableErrors.length > 0) { + return failedResult(plan.dbPath, "Repair plan has database errors with no safe repair") + } + if (plan.operations.length === 0) { + if (plan.exitCode !== 0) return failedResult(plan.dbPath, "Repair plan has database errors but no safe operations to apply") return { success: true, backup: { path: "", createdAt: "", originalPath: plan.dbPath }, @@ -85,12 +90,14 @@ export async function applyRepairPlan(plan: RepairPlan) { } satisfies ApplyResult } + const postCheckIssues = postCheck.issues.filter((issue) => issue.severity === "error").length return { - success: true, + success: postCheckIssues === 0, backup, operationsApplied: plan.operations.length, - operationsFailed: 0, - postCheckIssues: postCheck.issues.filter((issue) => issue.severity === "error").length, + operationsFailed: postCheckIssues === 0 ? 0 : 1, + postCheckIssues, + error: postCheckIssues === 0 ? undefined : "Post-check found remaining database errors after repair commit", } satisfies ApplyResult } @@ -234,7 +241,7 @@ function deriveMessageRepairValue(db: BunDatabase, operation: RepairOperation) { if (nonEmptyString(sessionAgent)) return sessionAgent return singleValue( db - .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .query("SELECT data FROM message WHERE session_id = ? AND json_valid(data) = 1 AND json_extract(data, '$.role') = 'assistant'") .all(row.session_id) .map((item) => parseObject((item as { data: string }).data)?.agent) .filter(nonEmptyString), @@ -244,7 +251,7 @@ function deriveMessageRepairValue(db: BunDatabase, operation: RepairOperation) { const unique = [ ...new Set( db - .query("SELECT data FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'") + .query("SELECT data FROM message WHERE session_id = ? AND json_valid(data) = 1 AND json_extract(data, '$.role') = 'assistant'") .all(row.session_id) .map((item) => { const data = parseObject((item as { data: string }).data) diff --git a/packages/opencode/src/cli/cmd/db-runner.ts b/packages/opencode/src/cli/cmd/db-runner.ts index ed6fb7032e99..cbe90a317a9f 100644 --- a/packages/opencode/src/cli/cmd/db-runner.ts +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -91,7 +91,7 @@ function printApplyResult(plan: RepairPlan, result: ApplyResult) { if (result.backup.path) console.log(`Backup created: ${result.backup.path}`) if (!result.success) { console.log(`Repair failed: ${result.error}`) - console.log("No changes were applied. Database transaction was rolled back.") + console.log(result.operationsApplied === 0 ? "No changes were applied. Database transaction was rolled back." : "Repairs were committed, but the post-check found remaining database errors. Review the backup before continuing.") console.log("Exit code: 2") return } diff --git a/packages/opencode/test/db/health.test.ts b/packages/opencode/test/db/health.test.ts index f73990160c92..0886c13c4261 100644 --- a/packages/opencode/test/db/health.test.ts +++ b/packages/opencode/test/db/health.test.ts @@ -88,6 +88,50 @@ describe("database doctor and repair", () => { expect(plan.operations).toHaveLength(0) }) + test("detects malformed message and part JSON without treating the database as unreadable", async () => { + const fixture = createFixture("malformed-v2") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) + fixture.db.query("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)").run("msg", "ses", 1, 1, "{") + fixture.db.query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)").run("prt", "msg", "ses", 1, 1, "[]") + fixture.db.close() + + const report = await generateDoctorReport(fixture.dbPath) + const plan = await generateRepairPlan(fixture.dbPath) + + expect(report.exitCode).toBe(0) + expect(report.issues.some((issue) => issue.code === "message_malformed_json")).toBe(true) + expect(report.issues.some((issue) => issue.code === "part_malformed_json")).toBe(true) + expect(plan.exitCode).toBe(0) + expect(plan.operations).toHaveLength(0) + expect(plan.warnings).toContain("message.data contains malformed JSON") + expect(plan.warnings).toContain("part.data contains malformed JSON") + }) + + test("ignores malformed assistant messages while deriving user message repairs", async () => { + const fixture = createFixture("malformed-derive") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, path, title, version) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1") + fixture.db.query("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)").run("bad", "ses", 1, 1, "{") + fixture.db + .query("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)") + .run("user", "ses", 2, 2, JSON.stringify({ role: "user", time: { created: 2 } })) + fixture.db.close() + + const report = await generateDoctorReport(fixture.dbPath) + const plan = await generateRepairPlan(fixture.dbPath) + + expect(report.exitCode).toBe(1) + expect(report.issues.some((issue) => issue.code === "message_malformed_json")).toBe(true) + expect(report.issues.some((issue) => issue.code === "message_user_missing_agent" && !issue.repairable)).toBe(true) + expect(plan.exitCode).toBe(1) + expect(plan.operations).toHaveLength(0) + }) + test("dry-run is read-only and plans assistant/session metadata repair", async () => { const fixture = createFixture("safe-plan") fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") @@ -145,8 +189,8 @@ describe("database doctor and repair", () => { const fixture = createFixture("part-prefix") fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") fixture.db - .query("INSERT INTO session (id, project_id, slug, directory, path, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") - .run("ses", "proj", "slug", fixture.worktree, fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) + .query("INSERT INTO session (id, project_id, slug, directory, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) fixture.db .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ agent: "build", model: { providerID: "p", modelID: "m" } })) @@ -171,6 +215,37 @@ describe("database doctor and repair", () => { expect(second.operations).toHaveLength(0) }) + test("reports non-repairable legacy part id collisions in repair dry-run", async () => { + const fixture = createFixture("part-prefix-collision") + fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") + fixture.db + .query("INSERT INTO session (id, project_id, slug, directory, title, version, agent, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + .run("ses", "proj", "slug", fixture.worktree, "title", "1", "build", JSON.stringify({ providerID: "p", modelID: "m" })) + fixture.db + .query("INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("msg", "ses", "assistant", Date.now(), Date.now(), JSON.stringify({ agent: "build", model: { providerID: "p", modelID: "m" } })) + fixture.db + .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("part_same", "msg", "ses", 1, 1, JSON.stringify({ type: "text", text: "legacy" })) + fixture.db + .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("prt_same", "msg", "ses", 1, 1, JSON.stringify({ type: "text", text: "target" })) + fixture.db.close() + + const report = await generateDoctorReport(fixture.dbPath) + const plan = await generateRepairPlan(fixture.dbPath) + const result = await applyRepairPlan(plan) + + expect(report.exitCode).toBe(1) + expect(report.issues.some((issue) => issue.code === "part_legacy_id_prefix" && !issue.repairable)).toBe(true) + expect(plan.exitCode).toBe(1) + expect(plan.operations.map((operation) => operation.issueCode)).toEqual(["session_path_missing"]) + expect(plan.unrepairableErrors).toContain("part.id uses the legacy part_ prefix, but the target prt_ id already exists") + expect(plan.warnings).toContain("part.id uses the legacy part_ prefix, but the target prt_ id already exists") + expect(result.success).toBe(false) + expect(result.backup.path).toBe("") + }) + test("repairs missing message and part fields required by current schemas", async () => { const fixture = createFixture("message-part-fields") fixture.db.query("INSERT INTO project (id, worktree, sandboxes) VALUES (?, ?, ?)").run("proj", fixture.worktree, "[]") @@ -186,6 +261,9 @@ describe("database doctor and repair", () => { fixture.db .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") .run("prt_step", "msg_assistant", "ses", 3, 3, JSON.stringify({ type: "step-finish", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } })) + fixture.db + .query("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)") + .run("prt_tool", "msg_assistant", "ses", 5, 5, JSON.stringify({ type: "tool", tool: "bash", state: { status: "completed", time: { start: 4 } } })) fixture.db.close() const plan = await generateRepairPlan(fixture.dbPath) @@ -194,6 +272,7 @@ describe("database doctor and repair", () => { const user = JSON.parse((db.query("SELECT data FROM message WHERE id = ?").get("msg_user") as { data: string }).data) as { agent: string; model: { providerID: string; modelID: string } } const assistant = JSON.parse((db.query("SELECT data FROM message WHERE id = ?").get("msg_assistant") as { data: string }).data) as { parentID: string } const part = JSON.parse((db.query("SELECT data FROM part WHERE id = ?").get("prt_step") as { data: string }).data) as { reason: string } + const tool = JSON.parse((db.query("SELECT data FROM part WHERE id = ?").get("prt_tool") as { data: string }).data) as { state: { metadata: Record; title: string; time: { start: number; end: number } } } db.close() expect(plan.operations.map((operation) => operation.issueCode).sort()).toEqual([ @@ -201,12 +280,18 @@ describe("database doctor and repair", () => { "message_user_missing_agent", "message_user_missing_model", "part_step_finish_missing_reason", + "part_tool_completed_missing_metadata", + "part_tool_completed_missing_time", + "part_tool_completed_missing_title", ]) expect(result.success).toBe(true) expect(user.agent).toBe("build") expect(user.model).toEqual({ providerID: "p", modelID: "m" }) expect(assistant.parentID).toBe("msg_user") expect(part.reason).toBe("stop") + expect(tool.state.metadata).toEqual({}) + expect(tool.state.title).toBe("bash") + expect(tool.state.time).toEqual({ start: 4, end: 5 }) expect((await generateRepairPlan(fixture.dbPath)).operations).toHaveLength(0) })