diff --git a/packages/core/src/database/health.ts b/packages/core/src/database/health.ts new file mode 100644 index 000000000000..e44ce74eae85 --- /dev/null +++ b/packages/core/src/database/health.ts @@ -0,0 +1,929 @@ +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" + +export interface SupportedRepair { + code: string + table: "session" | "session_message" | "message" | "part" + repairable: boolean + targetOpenCodeVersion: string + targetMigration?: string + targetInvariant: string + sourceEvidence: string + description: string + repair: string + safety: string +} + +export interface CompatibilityContext { + targetOpenCodeVersion: string + expectedMigrations: string[] + latestExpectedMigration?: string + sessionVersions: { version: string; count: number }[] + appliedMigrations: string[] + latestAppliedMigration?: string +} + +export const SUPPORTED_REPAIRS = [ + { + code: "part_legacy_id_prefix", + table: "part", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "Session message part IDs must satisfy PartID, which currently requires 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.", + }, + { + code: "assistant_message_missing_agent", + table: "session_message", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetInvariant: "Assistant session_message.data should carry agent, not only mode.", + 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.", + }, + { + code: "session_agent_missing", + table: "session", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260511173437_session-metadata", + targetInvariant: "session.agent is present when a single unambiguous agent can be derived.", + 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.", + }, + { + code: "session_model_missing", + table: "session", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260511173437_session-metadata", + targetInvariant: "session.model is present when a single unambiguous model can be derived.", + 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.", + }, + { + code: "session_path_missing", + table: "session", + repairable: true, + targetOpenCodeVersion: InstallationVersion, + targetMigration: "20260428004200_add_session_path", + targetInvariant: "session.path is present when session.directory is non-empty.", + 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.", + }, + { + 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 { + 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 + compatibility: CompatibilityContext + supportedRepairs: SupportedRepair[] + sessionCount?: number + messageCount?: number + issues: Issue[] + exitCode: 0 | 1 | 2 +} + +export interface RepairOperation { + id: string + issueCode: string + table: "session" | "session_message" | "message" | "part" + 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 + compatibility: CompatibilityContext + supportedRepairs: SupportedRepair[] + operations: RepairOperation[] + warnings: string[] + unrepairableErrors: string[] + exitCode: 0 | 1 | 2 +} + +interface SchemaStatus { + supported: boolean + issues: Issue[] + columns: { + session: Set + sessionMessage: Set + message: Set + part: 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 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 { + 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, readCompatibility(db), 0, 0, schema.issues) + } + + const sessions = analyzeSessions(db, schema.columns.session) + const sessionMessages = analyzeSessionMessages(db) + const messages = analyzeMessages(db, schema.columns.message) + const parts = analyzeParts(db, schema.columns.part) + return buildReport(dbPath, schema, readCompatibility(db), sessions.count, messages.count, [...schema.issues, ...sessions.issues, ...sessionMessages.issues, ...messages.issues, ...parts.issues]) + }) + } 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, + compatibility: readCompatibility(db), + 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 + } + + const sessions = analyzeSessions(db, schema.columns.session) + const sessionMessages = analyzeSessionMessages(db) + const messages = analyzeMessages(db, schema.columns.message) + 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(), + mode, + compatibility: readCompatibility(db), + supportedRepairs: SUPPORTED_REPAIRS, + operations, + 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) { + 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 message = tableColumns(db, "message") + const part = tableColumns(db, "part") + 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, 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", "time_created", "data"]), + ...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, message, part, 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 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"), + issues: rows.flatMap((row) => assistantMessageIssues(row as SessionMessageRow)), + } +} + +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 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)), + } +} + +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, 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)), + } +} + +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, + 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, + compatibility: readCompatibility(undefined), + supportedRepairs: SUPPORTED_REPAIRS, + 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, + compatibility: readCompatibility(undefined), + supportedRepairs: SUPPORTED_REPAIRS, + operations: [], + warnings: [reason], + unrepairableErrors: [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 readCompatibility(db: BunDatabase | undefined): CompatibilityContext { + 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 }[]) + : [] + 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, + expectedMigrations, + latestExpectedMigration: expectedMigrations.at(-1), + sessionVersions, + appliedMigrations, + latestAppliedMigration: appliedMigrations.at(-1), + } +} + +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 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 +} + +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 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: 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", + ), + ]), + ] +} + +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 + 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 [ + { + 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 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_valid(data) = 1 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_valid(data) = 1 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 [] +} + +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 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) + 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 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 new file mode 100644 index 000000000000..1e295e226d9a --- /dev/null +++ b/packages/core/src/database/repair.ts @@ -0,0 +1,329 @@ +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, + } + + const db = new BunDatabase(dbPath) + try { + db.exec(`VACUUM main INTO '${backup.path.replaceAll("'", "''")}'`) + } finally { + db.close() + } + 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.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 }, + 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 + } + + const postCheckIssues = postCheck.issues.filter((issue) => issue.severity === "error").length + return { + success: postCheckIssues === 0, + backup, + operationsApplied: plan.operations.length, + operationsFailed: postCheckIssues === 0 ? 0 : 1, + postCheckIssues, + error: postCheckIssues === 0 ? undefined : "Post-check found remaining database errors after repair commit", + } satisfies ApplyResult +} + +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}`) + 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) { + 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 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 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_valid(data) = 1 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_valid(data) = 1 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 + } + 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..cbe90a317a9f --- /dev/null +++ b/packages/opencode/src/cli/cmd/db-runner.ts @@ -0,0 +1,102 @@ +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 }, +): Promise<{ exitCode: CommandExitCode; message: string }> { + const plan = await generateRepairPlan(dbPath) + 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(`Target OpenCode: ${report.compatibility.targetOpenCodeVersion}`) + console.log(`Target migration: ${report.compatibility.latestExpectedMigration ?? "none"}`) + console.log(`Applied migration: ${report.compatibility.latestAppliedMigration ?? "none"}`) + console.log(`Sessions: ${report.sessionCount ?? 0}`) + console.log(`Messages: ${report.messageCount ?? 0}`) + console.log("") + console.log("Supported repairs:") + 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) => { + 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(`Target OpenCode: ${plan.compatibility.targetOpenCodeVersion}`) + 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}: 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) => { + 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(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 + } + 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 9e7e37e18e91..9b08597c285d 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -3,7 +3,9 @@ import { spawn } from "child_process" import { Database } from "@opencode-ai/core/database/database" import { Effect } from "effect" import { sql } from "drizzle-orm" +import { cmd } from "./cmd" import { effectCmd } from "../effect-cmd" +import { runDoctorCommand, runRepairCommand } from "./db-runner" const QueryCommand = effectCmd({ command: "$0 [query]", @@ -51,12 +53,67 @@ const PathCommand = effectCmd({ }), }) +const DoctorCommand = cmd({ + command: "doctor", + describe: "diagnose database health issues", + builder: (yargs: Argv) => { + return yargs.option("json", { + type: "boolean", + default: false, + describe: "Output in JSON format", + }) + }, + handler: async (args: { json: boolean }) => { + process.exitCode = (await runDoctorCommand(Database.path(), args)).exitCode + }, +}) + +const RepairCommand = cmd({ + command: "repair", + describe: "plan or apply database repairs", + 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", + }) + .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: async (args: { + dryRun?: boolean + "dry-run"?: boolean + apply: boolean + json: boolean + }) => { + process.exitCode = (await runRepairCommand(Database.path(), args)).exitCode + }, +}) + export const DbCommand = effectCmd({ command: "db", describe: "database tools", instance: false, builder: (yargs: Argv) => { - return yargs.command(QueryCommand).command(PathCommand).demandCommand() + return yargs.command(QueryCommand).command(PathCommand).command(DoctorCommand).command(RepairCommand).demandCommand() }, handler: Effect.fn("Cli.db")(function* () {}), }) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..e95275bf379f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -132,11 +132,11 @@ try { UI.error("Unexpected error" + 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 // run using `docker run --init`. // Explicitly exit to avoid any hanging subprocesses. - process.exit() + process.exit(process.exitCode ?? 0) } diff --git a/packages/opencode/test/db/cli.test.ts b/packages/opencode/test/db/cli.test.ts new file mode 100644 index 000000000000..a56a3bffec5f --- /dev/null +++ b/packages/opencode/test/db/cli.test.ts @@ -0,0 +1,158 @@ +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("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.") + + 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) + 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 })) + 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 })) + 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("Target migration:") + 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) + + 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() + + 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 })) + 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") + 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) + }) + +}) + +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/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 new file mode 100644 index 000000000000..0886c13c4261 --- /dev/null +++ b/packages/opencode/test/db/health.test.ts @@ -0,0 +1,434 @@ +import { afterEach, describe, expect, test } from "bun:test" +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 { SUPPORTED_REPAIRS, 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("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", + ]) + 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 () => { + 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(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) + }) + + 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("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, "[]") + 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("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, 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_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(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"]) + 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, "[]") + 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 + .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) + 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 } + 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([ + "message_assistant_missing_parent", + "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) + }) + + 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() + }) + +}) + +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 + ); + 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, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + ); + `) + return { dir, worktree, dbPath, db } +}