/** * `restore-db` — put `conductor.db` back to a restorable snapshot. * * The store is the verb ledger, the decision rows, the run rows and the * material-event ledger (#579); a restore replaces the live file wholesale * from a snapshot the snapshot primitive produced. It refuses to run while a * daemon is live — overwriting the store a live dispatch loop is writing is a * corruption path, and `livingDaemon()` is the existing detect for it — and * replaces the file atomically (temp file in the same directory, then rename), * then drops any stale `-wal`/`-shm` sidecars so the restored database opens * cleanly. The pre-restore `conductor.db-wal`/`-shm` are gone by design: the * old WAL belongs to the file being replaced. */ import { Database } from "bun:sqlite"; import { randomUUID } from "node:crypto"; import { copyFileSync, existsSync, readdirSync, renameSync, rmSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import type { CommandContext } from "./context.ts"; import { dbBackupDirFor, loadConfig, stateDir } from "../config.ts"; import { livingDaemon } from "../lifecycle.ts"; import { DB_SNAPSHOT_STEM, dbPath } from "../store.ts"; const RESTORE_DB_USAGE = `omp-conductor restore-db [SNAPSHOT] Put conductor.db back to a restorable snapshot, replacing the live file wholesale and dropping stale -wal/-shm sidecars so the result opens cleanly. Refuses to run while a daemon is live — restoring under a running daemon is a corruption path, not an interruption. Stop the daemon first (\`omp-conductor stop\`). usage: omp-conductor restore-db restore the newest snapshot in the configured db backup directory omp-conductor restore-db PATH restore this exact snapshot file SNAPSHOT is a file the store snapshot primitive produced. A relative PATH is resolved against the working directory. Before restoring, the command verifies the restored database reads cleanly (PRAGMA integrity_check) and prints the verb_ledger / decisions / runs counts.`; /** * The newest snapshot already published under `dir`, by mtime — the default * restore source. Undefined when none exists (a fresh store with no snapshot). */ function newestSnapshot(dir: string): string | undefined { let names: string[]; try { names = readdirSync(dir).filter((name) => name.startsWith(DB_SNAPSHOT_STEM)); } catch { return undefined; } if (names.length === 0) return undefined; const withMtime = names .map((name) => ({ name, mtime: statSync(join(dir, name)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); return join(dir, withMtime[0]!.name); } /** Replace `conductor.db` with `snapshot`, atomically, dropping stale sidecars. */ function overwriteStoreFrom(snapshot: string): void { const target = dbPath(); const temporary = join(stateDir(), `.${DB_SNAPSHOT_STEM}-restore.${process.pid}.${randomUUID()}.tmp`); copyFileSync(snapshot, temporary); renameSync(temporary, target); rmSync(`${target}-wal`, { force: true }); rmSync(`${target}-shm`, { force: true }); } interface RowsRead { integrity: string; runs: number; decisions: number; verbLedger: number; } /** Read the restored store back — the proof the restore landed cleanly. */ function readRestoredStore(target: string): RowsRead { const db = new Database(target); try { const integrity = (db.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get() as { integrity_check: string; }).integrity_check; const count = (table: string): number => (db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; return { integrity, runs: count("runs"), decisions: count("decisions"), verbLedger: count("verb_ledger") }; } finally { db.close(); } } export async function restoreDbCommand(ctx: CommandContext): Promise { // Help first: parsing stops before any config read or liveness check. if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") { process.stdout.write(`${RESTORE_DB_USAGE}\n`); return; } // Host-scoped: the store is one host-wide file, so --project cannot narrow it. if (ctx.projectFlag !== undefined) { process.stderr.write(`omp-conductor: restore-db does not take a project — the store is host-wide\n`); process.exit(2); } const positionals = ctx.argv.slice(1).filter((a) => !a.startsWith("--")); const flags = ctx.argv.slice(1).filter((a) => a.startsWith("--")); if (flags.length > 0) { process.stderr.write(`omp-conductor: restore-db: unexpected argument "${flags[0]}"\n`); process.exit(2); } if (positionals.length > 1) { process.stderr.write(`omp-conductor: restore-db takes at most one snapshot path\n`); process.exit(2); } // The corruption-path guard: never restore under a daemon that is writing. const daemon = livingDaemon(); if (daemon !== undefined) { throw new Error( `restore-db refuses while a daemon is running (pid ${daemon.pid}) — it would overwrite the store a live dispatch is writing; stop it first (\`omp-conductor stop\`)`, ); } const cfg = loadConfig(); const snapDir = dbBackupDirFor(cfg); const requested = positionals[0]; const snapshot = requested === undefined ? newestSnapshot(snapDir) : resolve(requested); if (snapshot === undefined) { throw new Error( `no conductor.db snapshot in ${snapDir} — run \`omp-conductor snapshot-db\` or pass an explicit snapshot path`, ); } if (!existsSync(snapshot)) { throw new Error(`snapshot ${snapshot} does not exist`); } overwriteStoreFrom(snapshot); const restored = readRestoredStore(dbPath()); const integrityLine = restored.integrity === "ok" ? "integrity ok" : `integrity: ${restored.integrity}`; process.stdout.write( `restored conductor.db from ${snapshot}\n` + `\u2003${integrityLine} · runs ${restored.runs} · decisions ${restored.decisions} · verb_ledger ${restored.verbLedger}\n`, ); }