Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
929 changes: 929 additions & 0 deletions packages/core/src/database/health.ts

Large diffs are not rendered by default.

329 changes: 329 additions & 0 deletions packages/core/src/database/repair.ts

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions packages/opencode/src/cli/cmd/db-runner.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof generateDoctorReport>>) {
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")
}
59 changes: 58 additions & 1 deletion packages/opencode/src/cli/cmd/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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* () {}),
})
4 changes: 2 additions & 2 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
158 changes: 158 additions & 0 deletions packages/opencode/test/db/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createFixture>) {
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
);
`)
}
Loading
Loading