From a5bcd4abe387ab0f1768b4df644f8044c223d327 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 8 Aug 2026 19:47:52 +0900 Subject: [PATCH] fix: tolerate incompatible Codex history schemas --- src/codex/history-provider.ts | 23 +++++++++++++++++++++++ tests/codex-history-provider.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 46a21e980..886353566 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -519,6 +519,25 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu return { rows: rows.length, files }; } +function hasCompatibleHistorySchema(stateDbPath: string): boolean { + try { + const db = new Database(stateDbPath, { readonly: true }); + try { + db.query(` + SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message + FROM threads + LIMIT 0 + `).all(); + return true; + } finally { + db.close(); + } + } catch (error) { + if (isRecoverableHistoryError(error)) throw error; + return false; + } +} + export function classifyRecoverableHistoryError(error: unknown): CodexHistoryFailureReason | null { const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); @@ -681,6 +700,10 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C const manifest = readBackup(backupPath, stateDbPath); const entries = Object.values(manifest.entries); + // With no manifest there is nothing to restore unless this is a legacy opencodex DB. + // Older, empty, or corrupt Codex databases may not have the history schema at all. + if (entries.length === 0 && !hasCompatibleHistorySchema(stateDbPath)) return { rows: 0, files: 0 }; + const db = openStateDb(stateDbPath); try { if (entries.length === 0) { diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index 856a68da6..ad1a2e41e 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -267,6 +267,29 @@ describe("Codex history provider sync", () => { expect(existsSync(backupPath)).toBe(false); }); + test.each([ + ["no threads table", "CREATE TABLE metadata (key TEXT PRIMARY KEY)"], + ["no first_user_message column", "CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, source TEXT, has_user_event INTEGER)"], + ])("no-backup restore ignores an incompatible state DB with %s", (_case, schema) => { + const dir = join(tmpdir(), `ocx-incompatible-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + const db = new Database(dbPath); + db.run(schema); + db.close(); + + expect(syncCodexHistoryProvider("openai", dbPath, join(dir, "missing-backup.json"))).toEqual({ rows: 0, files: 0 }); + }); + + test("no-backup restore ignores a corrupt state DB", () => { + const dir = join(tmpdir(), `ocx-corrupt-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + writeFileSync(dbPath, "not a sqlite database"); + + expect(syncCodexHistoryProvider("openai", dbPath, join(dir, "missing-backup.json"))).toEqual({ rows: 0, files: 0 }); + }); + test("explicitly recovers legacy opencodex user rows to openai", () => { const { dbPath, execRollout, legacyRollout } = makeFixture({ includeExec: true, includeLegacy: true });