Skip to content
Draft
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
23 changes: 23 additions & 0 deletions src/codex/history-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions tests/codex-history-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Loading