import { spawn } from "node:child_process"; import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createBackup, getBackupSummary, pruneBackups, restoreBackup, updateSessionBackupManifest } from "../src/backup.js"; import { getStatus, renderStatus, runRestore, runSwitch, runSync } from "../src/service.js"; import { DB_FILE_BASENAME, DEFAULT_BACKUP_RETENTION_COUNT, SQLITE_DIR_BASENAME } from "../src/constants.js"; import { getUnsupportedNodeVersionMessage } from "../src/node-version.js"; import { applySessionChanges, collectSessionChanges } from "../src/session-files.js"; import { openDatabase } from "../src/sqlite.js"; async function makeTempCodexHome() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-provider-sync-")); const codexHome = path.join(root, ".codex"); await fs.mkdir(path.join(codexHome, "sessions", "2026", "03", "19"), { recursive: true }); await fs.mkdir(path.join(codexHome, "archived_sessions", "2026", "03", "18"), { recursive: true }); return { root, codexHome }; } async function writeRollout(filePath, id, provider) { const payload = { id, timestamp: "2026-03-19T00:00:00.000Z", cwd: "C:\\AITemp", source: "cli", cli_version: "0.115.0", model_provider: provider }; const lines = [ JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload }), JSON.stringify({ timestamp: payload.timestamp, type: "event_msg", payload: { type: "user_message", message: "hi" } }) ]; await fs.writeFile(filePath, `${lines.join("\n")}\n`, "utf8"); } async function writeCustomRollout(filePath, payload, message = "hi") { const lines = [ JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload }), JSON.stringify({ timestamp: payload.timestamp, type: "event_msg", payload: { type: "user_message", message } }) ]; await fs.writeFile(filePath, `${lines.join("\n")}\n`, "utf8"); } function backupRoot(codexHome) { return path.join(codexHome, "backups_state", "provider-sync"); } async function writeBackup(codexHome, directoryName, files) { const backupDir = path.join(backupRoot(codexHome), directoryName); await fs.mkdir(backupDir, { recursive: true }); let totalBytes = 0; if (!files.some(([relativePath]) => relativePath === "metadata.json")) { const metadataPath = path.join(backupDir, "metadata.json"); const metadataContent = JSON.stringify({ version: 1, namespace: "provider-sync", codexHome, targetProvider: "openai", createdAt: "2026-03-24T00:00:00.000Z", dbFiles: [], changedSessionFiles: 0 }, null, 2); await fs.writeFile(metadataPath, metadataContent, "utf8"); const metadataStat = await fs.stat(metadataPath); totalBytes += metadataStat.size; } for (const [relativePath, content] of files) { const fullPath = path.join(backupDir, relativePath); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content, "utf8"); const stat = await fs.stat(fullPath); totalBytes += stat.size; } return totalBytes; } async function writeConfig(codexHome, modelProviderLine = "") { const config = `${modelProviderLine}${modelProviderLine ? "\n" : ""}sandbox_mode = "danger-full-access"\n\n[model_providers.apigather]\nbase_url = "https://example.com"\n`; await fs.writeFile(path.join(codexHome, "config.toml"), config, "utf8"); } async function writeGlobalState(codexHome, value) { const text = `${JSON.stringify(value, null, 2)}\n`; await fs.writeFile(path.join(codexHome, ".codex-global-state.json"), text, "utf8"); await fs.writeFile(path.join(codexHome, ".codex-global-state.json.bak"), text, "utf8"); } function stateDbPath(codexHome) { return path.join(codexHome, SQLITE_DIR_BASENAME, DB_FILE_BASENAME); } function legacyStateDbPath(codexHome) { return path.join(codexHome, DB_FILE_BASENAME); } async function writeStateDbAt(dbPath, rows) { await fs.mkdir(path.dirname(dbPath), { recursive: true }); const db = await openDatabase(dbPath); try { db.exec(` CREATE TABLE threads ( id TEXT PRIMARY KEY, model_provider TEXT, cwd TEXT NOT NULL DEFAULT '', archived INTEGER NOT NULL DEFAULT 0, first_user_message TEXT NOT NULL DEFAULT '' ) `); const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, archived, first_user_message) VALUES (?, ?, ?, ?, ?)"); for (const row of rows) { stmt.run(row.id, row.model_provider, row.cwd ?? "C:\\AITemp", row.archived ? 1 : 0, row.first_user_message ?? "hello"); } } finally { db.close(); } } async function writeStateDb(codexHome, rows) { await writeStateDbAt(stateDbPath(codexHome), rows); } async function writeLegacyStateDb(codexHome, rows) { await writeStateDbAt(legacyStateDbPath(codexHome), rows); } async function writeStateDbWithUserEventColumn(codexHome, rows) { const dbPath = stateDbPath(codexHome); await fs.mkdir(path.dirname(dbPath), { recursive: true }); const db = await openDatabase(dbPath); try { db.exec(` CREATE TABLE threads ( id TEXT PRIMARY KEY, model_provider TEXT, cwd TEXT NOT NULL DEFAULT '', archived INTEGER NOT NULL DEFAULT 0, has_user_event INTEGER NOT NULL DEFAULT 0, first_user_message TEXT NOT NULL DEFAULT '' ) `); const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, archived, has_user_event, first_user_message) VALUES (?, ?, ?, ?, ?, ?)"); for (const row of rows) { stmt.run(row.id, row.model_provider, row.cwd ?? "C:\\AITemp", row.archived ? 1 : 0, row.has_user_event ? 1 : 0, row.first_user_message ?? "hello"); } } finally { db.close(); } } async function writeStateDbForProjectVisibility(codexHome, rows) { const dbPath = stateDbPath(codexHome); await fs.mkdir(path.dirname(dbPath), { recursive: true }); const db = await openDatabase(dbPath); try { db.exec(` CREATE TABLE threads ( id TEXT PRIMARY KEY, model_provider TEXT, cwd TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'cli', archived INTEGER NOT NULL DEFAULT 0, first_user_message TEXT NOT NULL DEFAULT '', updated_at_ms INTEGER NOT NULL DEFAULT 0 ) `); const stmt = db.prepare("INSERT INTO threads (id, model_provider, cwd, source, archived, first_user_message, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)"); for (const row of rows) { stmt.run( row.id, row.model_provider ?? "dal", row.cwd, row.source ?? "cli", row.archived ? 1 : 0, row.first_user_message ?? "hello", row.updated_at_ms ?? 0 ); } } finally { db.close(); } } async function lockRolloutFile(filePath, shareMode = "None") { const script = ` & { param([string]$path, [string]$shareMode) $share = [System.Enum]::Parse([System.IO.FileShare], $shareMode) $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, $share) try { Write-Output 'locked' [Console]::Out.Flush() Start-Sleep -Seconds 30 } finally { $stream.Close() } } `.trim(); const child = spawn("powershell.exe", [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script, filePath, shareMode ], { stdio: ["ignore", "pipe", "pipe"] }); await new Promise((resolve, reject) => { let settled = false; let stdout = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); if (!settled && stdout.includes("locked")) { settled = true; resolve(); } }); child.once("error", (error) => { if (!settled) { settled = true; reject(error); } }); child.once("exit", (code, signal) => { if (!settled) { settled = true; reject(new Error(`Failed to acquire rollout file lock. Exit code: ${code ?? "null"}, signal: ${signal ?? "null"}`)); } }); }); return child; } async function runCli(args) { const cliPath = path.resolve("src", "cli.js"); return await new Promise((resolve, reject) => { const child = spawn(process.execPath, [cliPath, ...args], { cwd: path.resolve("."), stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.once("error", reject); child.once("exit", (code) => { resolve({ code, stdout, stderr }); }); }); } test("runSync rewrites rollout files and sqlite, then restore reverts both", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-b.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeRollout(archivedPath, "thread-b", "newapi"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false }, { id: "thread-b", model_provider: "newapi", archived: true } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.targetProvider, "openai"); assert.equal(typeof syncResult.backupDurationMs, "number"); assert.ok(syncResult.backupDurationMs >= 0); assert.equal(syncResult.changedSessionFiles, 2); assert.deepEqual(syncResult.skippedLockedRolloutFiles, []); assert.equal(syncResult.sqliteRowsUpdated, 2); const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); assert.deepEqual( backupMetadata.dbFiles.map((fileName) => fileName.replaceAll("\\", "/")), ["sqlite/state_5.sqlite"] ); const syncedSession = await fs.readFile(sessionPath, "utf8"); const syncedArchived = await fs.readFile(archivedPath, "utf8"); assert.match(syncedSession, /"model_provider":"openai"/); assert.match(syncedArchived, /"model_provider":"openai"/); const db = await openDatabase(stateDbPath(codexHome)); try { const providers = db .prepare("SELECT id, model_provider FROM threads ORDER BY id") .all() .map((row) => ({ ...row })); assert.deepEqual(providers, [ { id: "thread-a", model_provider: "openai" }, { id: "thread-b", model_provider: "openai" } ]); } finally { db.close(); } await runRestore({ codexHome, backupDir: syncResult.backupDir }); const restoredSession = await fs.readFile(sessionPath, "utf8"); const restoredArchived = await fs.readFile(archivedPath, "utf8"); assert.match(restoredSession, /"model_provider":"apigather"/); assert.match(restoredArchived, /"model_provider":"newapi"/); }); test("runSync updates legacy root sqlite database when sqlite-dir state is stale", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-a.jsonl"); const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-active-b.jsonl"); await writeRollout(sessionPath, "thread-active-a", "dal"); await writeRollout(archivedPath, "thread-active-b", "dal"); await writeStateDb(codexHome, [ { id: "thread-active-a", model_provider: "dal", archived: false } ]); await writeLegacyStateDb(codexHome, [ { id: "thread-active-a", model_provider: "dal", archived: false }, { id: "thread-active-b", model_provider: "dal", archived: true } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.sqliteRowsUpdated, 2); const backupMetadata = JSON.parse(await fs.readFile(path.join(syncResult.backupDir, "metadata.json"), "utf8")); assert.deepEqual(backupMetadata.dbFiles, [DB_FILE_BASENAME]); const legacyDb = await openDatabase(legacyStateDbPath(codexHome)); try { assert.deepEqual( legacyDb.prepare("SELECT id, model_provider FROM threads ORDER BY id").all().map((row) => ({ ...row })), [ { id: "thread-active-a", model_provider: "openai" }, { id: "thread-active-b", model_provider: "openai" } ] ); } finally { legacyDb.close(); } const staleDb = await openDatabase(stateDbPath(codexHome)); try { assert.deepEqual( staleDb.prepare("SELECT id, model_provider FROM threads ORDER BY id").all().map((row) => ({ ...row })), [ { id: "thread-active-a", model_provider: "dal" } ] ); } finally { staleDb.close(); } }); test("runSync reports stage progress and backup duration", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); const progressEvents = []; const result = await runSync({ codexHome, onProgress(event) { progressEvents.push(event); } }); assert.ok(result.backupDurationMs >= 0); assert.deepEqual( progressEvents .filter((event) => event.status === "start") .map((event) => event.stage), [ "scan_rollout_files", "check_locked_rollout_files", "create_backup", "update_sqlite", "rewrite_rollout_files", "clean_backups" ] ); const backupCompleteEvent = progressEvents.find((event) => event.stage === "create_backup" && event.status === "complete"); assert.ok(backupCompleteEvent); assert.equal(backupCompleteEvent.backupDir, result.backupDir); assert.ok(backupCompleteEvent.durationMs >= 0); }); test("runSync repairs SQLite has_user_event from rollout user messages", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "openai"); await writeStateDbWithUserEventColumn(codexHome, [ { id: "thread-a", model_provider: "openai", archived: false, has_user_event: false } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.changedSessionFiles, 0); assert.equal(syncResult.sqliteRowsUpdated, 1); assert.equal(syncResult.sqliteUserEventRowsUpdated, 1); const db = await openDatabase(stateDbPath(codexHome)); try { const row = db .prepare("SELECT has_user_event FROM threads WHERE id = ?") .get("thread-a"); assert.equal(row.has_user_event, 1); } finally { db.close(); } }); test("runSync repairs SQLite cwd from rollout session metadata", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cwd.jsonl"); await writeCustomRollout(sessionPath, { id: "thread-cwd", timestamp: "2026-03-19T00:00:00.000Z", cwd: "D:\\GitHubProject\\oss-maintainer-hub", source: "vscode", cli_version: "0.115.0", model_provider: "openai" }); await writeStateDb(codexHome, [ { id: "thread-cwd", model_provider: "openai", archived: false, cwd: "\\\\?\\D:\\GitHubProject\\oss-maintainer-hub" } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.changedSessionFiles, 0); assert.equal(syncResult.sqliteRowsUpdated, 1); assert.equal(syncResult.sqliteCwdRowsUpdated, 1); const db = await openDatabase(stateDbPath(codexHome)); try { const row = db .prepare("SELECT cwd FROM threads WHERE id = ?") .get("thread-cwd"); assert.equal(row.cwd, "D:\\GitHubProject\\oss-maintainer-hub"); } finally { db.close(); } }); test("runSync normalizes extended rollout cwd before repairing SQLite", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-cwd-extended.jsonl"); await writeCustomRollout(sessionPath, { id: "thread-cwd-extended", timestamp: "2026-03-19T00:00:00.000Z", cwd: "\\\\?\\E:\\GitHubProject\\lin-framework", source: "vscode", cli_version: "0.115.0", model_provider: "openai" }); await writeStateDb(codexHome, [ { id: "thread-cwd-extended", model_provider: "openai", archived: false, cwd: "\\\\?\\E:\\GitHubProject\\lin-framework" } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.sqliteRowsUpdated, 1); assert.equal(syncResult.sqliteCwdRowsUpdated, 1); const db = await openDatabase(stateDbPath(codexHome)); try { const row = db .prepare("SELECT cwd FROM threads WHERE id = ?") .get("thread-cwd-extended"); assert.equal(row.cwd, "E:\\GitHubProject\\lin-framework"); } finally { db.close(); } }); test("runSync restores workspace roots from project order and normalizes them to Desktop path variants", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const originalState = { "electron-saved-workspace-roots": [ "\\\\?\\D:\\GitHubProject\\codex-provider-sync" ], "project-order": [ "\\\\?\\D:\\GitHubProject\\codex-provider-sync", "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" ], "active-workspace-roots": [ "\\\\?\\D:\\GitHubProject\\codex-provider-sync" ], "electron-workspace-root-labels": { "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets": "BrainLifeAssets" } }; await writeGlobalState(codexHome, originalState); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "openai", archived: false, cwd: "\\\\?\\D:\\GitHubProject\\codex-provider-sync" }, { id: "thread-b", model_provider: "openai", archived: false, cwd: "\\\\?\\E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" } ]); const syncResult = await runSync({ codexHome }); assert.equal(syncResult.updatedWorkspaceRoots, 2); const syncedState = JSON.parse(await fs.readFile(path.join(codexHome, ".codex-global-state.json"), "utf8")); assert.deepEqual(syncedState["electron-saved-workspace-roots"], [ "D:\\GitHubProject\\codex-provider-sync", "E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" ]); assert.deepEqual(syncedState["project-order"], [ "D:\\GitHubProject\\codex-provider-sync", "E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets" ]); assert.deepEqual(syncedState["active-workspace-roots"], [ "D:\\GitHubProject\\codex-provider-sync" ]); assert.equal( syncedState["electron-workspace-root-labels"]["E:\\NewRich\\BrainLife\\Code\\BrainLife\\Assets"], "BrainLifeAssets" ); await runRestore({ codexHome, backupDir: syncResult.backupDir }); const restoredState = JSON.parse(await fs.readFile(path.join(codexHome, ".codex-global-state.json"), "utf8")); assert.deepEqual(restoredState["electron-saved-workspace-roots"], originalState["electron-saved-workspace-roots"]); assert.deepEqual(restoredState["project-order"], originalState["project-order"]); }); test("runSwitch updates config and syncs provider metadata", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "openai"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "openai", archived: false } ]); const result = await runSwitch({ codexHome, provider: "apigather" }); assert.equal(result.targetProvider, "apigather"); const config = await fs.readFile(path.join(codexHome, "config.toml"), "utf8"); assert.match(config, /^model_provider = "apigather"/m); const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"apigather"/); }); test("status reports implicit default provider and rollout/sqlite counts", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-b.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeRollout(archivedPath, "thread-b", "openai"); const backupOneBytes = await writeBackup(codexHome, "20260319T000000000Z", [["note.txt", "backup-one"]]); const backupTwoBytes = await writeBackup(codexHome, "20260320T000000000Z", [["note.txt", "backup-two"]]); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false }, { id: "thread-b", model_provider: "openai", archived: true } ]); const status = await getStatus({ codexHome }); assert.equal(status.currentProvider, "openai"); assert.equal(status.currentProviderImplicit, true); assert.deepEqual(status.rolloutCounts.sessions, { apigather: 1 }); assert.deepEqual(status.sqliteCounts.archived_sessions, { openai: 1 }); assert.equal(status.stateDbLocation.source, "sqlite-dir"); assert.equal(status.stateDbLocation.path, stateDbPath(codexHome)); assert.equal(status.backupSummary.count, 2); assert.equal(status.backupSummary.totalBytes, backupOneBytes + backupTwoBytes); assert.match(renderStatus(status), new RegExp(`database: ${stateDbPath(codexHome).replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}`)); }); test("status falls back to legacy root sqlite database", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); const sqliteDir = path.dirname(stateDbPath(codexHome)); await fs.mkdir(sqliteDir, { recursive: true }); const db = await openDatabase(legacyStateDbPath(codexHome)); try { db.exec(` CREATE TABLE threads ( id TEXT PRIMARY KEY, model_provider TEXT, archived INTEGER NOT NULL DEFAULT 0 ) `); db.prepare("INSERT INTO threads (id, model_provider, archived) VALUES (?, ?, ?)").run("legacy-thread", "openai", 0); } finally { db.close(); } const status = await getStatus({ codexHome }); assert.equal(status.stateDbLocation.source, "legacy-root"); assert.equal(status.stateDbLocation.path, legacyStateDbPath(codexHome)); assert.deepEqual(status.sqliteCounts.sessions, { openai: 1 }); assert.match(renderStatus(status), /legacy root/); }); test("status chooses legacy root sqlite database when sqlite-dir state is stale", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); await writeRollout( path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-a.jsonl"), "thread-active-a", "openai" ); await writeRollout( path.join(codexHome, "sessions", "2026", "03", "19", "rollout-active-b.jsonl"), "thread-active-b", "openai" ); await writeRollout( path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-active-c.jsonl"), "thread-active-c", "openai" ); await writeStateDb(codexHome, [ { id: "thread-active-a", model_provider: "custom", archived: false } ]); await writeLegacyStateDb(codexHome, [ { id: "thread-active-a", model_provider: "openai", archived: false }, { id: "thread-active-b", model_provider: "openai", archived: false }, { id: "thread-active-c", model_provider: "openai", archived: true } ]); const status = await getStatus({ codexHome }); assert.equal(status.stateDbLocation.source, "legacy-root"); assert.equal(status.stateDbLocation.path, legacyStateDbPath(codexHome)); assert.deepEqual(status.sqliteCounts.sessions, { openai: 2 }); assert.deepEqual(status.sqliteCounts.archived_sessions, { openai: 1 }); assert.match(renderStatus(status), /legacy root/); }); test("status reports pending SQLite user-event and cwd repairs", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-repair-status.jsonl"); await writeCustomRollout(sessionPath, { id: "thread-repair-status", timestamp: "2026-03-19T00:00:00.000Z", cwd: "E:\\GitHubProject\\lin-framework", source: "vscode", cli_version: "0.115.0", model_provider: "openai" }); await writeStateDbWithUserEventColumn(codexHome, [ { id: "thread-repair-status", model_provider: "openai", archived: false, has_user_event: false, cwd: "\\\\?\\E:\\GitHubProject\\lin-framework" } ]); const status = await getStatus({ codexHome }); assert.equal(status.sqliteRepairStats.userEventRowsNeedingRepair, 1); assert.equal(status.sqliteRepairStats.cwdRowsNeedingRepair, 1); assert.match(renderStatus(status), /user-event flags needing repair: 1/); assert.match(renderStatus(status), /cwd paths needing repair: 1/); }); test("status reports project visibility ranks and cwd exact-match diagnostics", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "dal"'); await writeGlobalState(codexHome, { "electron-saved-workspace-roots": [ "E:\\GitHubProject\\lin-framework" ] }); const unrelatedRows = Array.from({ length: 51 }, (_, index) => ({ id: `thread-other-${String(index).padStart(2, "0")}`, cwd: "D:\\OtherProject", updated_at_ms: 1000 - index })); await writeStateDbForProjectVisibility(codexHome, [ ...unrelatedRows, { id: "thread-lin", cwd: "\\\\?\\E:\\GitHubProject\\lin-framework", updated_at_ms: 1 } ]); const status = await getStatus({ codexHome }); const [project] = status.projectThreadVisibility; assert.equal(project.root, "E:\\GitHubProject\\lin-framework"); assert.equal(project.interactiveThreads, 1); assert.equal(project.firstPageThreads, 0); assert.deepEqual(project.ranks, [52]); assert.equal(project.exactCwdMatches, 0); assert.equal(project.verbatimCwdRows, 1); assert.match(renderStatus(status), /Project visibility:/); assert.match(renderStatus(status), /first page 0\/50, ranks 52, exact cwd 0\/1, verbatim cwd 1/); }); test("runSwitch rejects unknown custom providers", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome); await assert.rejects( () => runSwitch({ codexHome, provider: "missing" }), /Provider "missing" is not available/ ); }); test("runSync leaves rollout files and sqlite untouched when sqlite is locked", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); const lockDb = await openDatabase(stateDbPath(codexHome)); try { lockDb.exec("BEGIN IMMEDIATE"); await assert.rejects( () => runSync({ codexHome, sqliteBusyTimeoutMs: 0 }), /state_5\.sqlite is currently in use/ ); } finally { try { lockDb.exec("ROLLBACK"); } catch { // Ignore cleanup failures in tests. } lockDb.close(); } const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"apigather"/); const db = await openDatabase(stateDbPath(codexHome)); try { const row = db .prepare("SELECT model_provider FROM threads WHERE id = ?") .get("thread-a"); assert.equal(row.model_provider, "apigather"); } finally { db.close(); } }); test("runSync skips locked rollout files and still updates sqlite", async () => { if (process.platform !== "win32") { return; } const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); const lockProcess = await lockRolloutFile(sessionPath); let result; try { result = await runSync({ codexHome, sqliteBusyTimeoutMs: 0 }); } finally { lockProcess.kill(); await new Promise((resolve) => lockProcess.once("exit", resolve)); } assert.equal(result.changedSessionFiles, 0); assert.equal(result.sqliteRowsUpdated, 1); assert.deepEqual(result.skippedLockedRolloutFiles, [sessionPath]); const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"apigather"/); const db = await openDatabase(stateDbPath(codexHome)); try { const row = db .prepare("SELECT model_provider FROM threads WHERE id = ?") .get("thread-a"); assert.equal(row.model_provider, "openai"); } finally { db.close(); } }); test("applySessionChanges skips rollout files that changed after collection", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); const { changes } = await collectSessionChanges(codexHome, "openai"); await fs.appendFile( sessionPath, '{"timestamp":"2026-03-19T00:00:01.000Z","type":"event_msg","payload":{"type":"assistant_message","message":"later"}}\n', "utf8" ); const result = await applySessionChanges(changes); assert.equal(result.appliedChanges, 0); assert.deepEqual(result.skippedPaths, [sessionPath]); const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"apigather"/); assert.match(rollout, /"message":"later"/); }); test("applySessionChanges preserves large UTF-8 session metadata", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-large.jsonl"); const payload = { id: "thread-large", timestamp: "2026-03-19T00:00:00.000Z", cwd: "C:\\AITemp\\中文", source: "cli", cli_version: "0.115.0", model_provider: "apigather", title: "中文会话", note: "保留 UTF-8 内容", large_blob: "数据块".repeat(40000) }; await writeCustomRollout(sessionPath, payload, "你好"); const { changes } = await collectSessionChanges(codexHome, "openai"); const result = await applySessionChanges(changes); assert.equal(result.appliedChanges, 1); assert.deepEqual(result.skippedPaths, []); const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"openai"/); assert.match(rollout, /"title":"中文会话"/); assert.match(rollout, /"note":"保留 UTF-8 内容"/); assert.match(rollout, /"message":"你好"/); assert.match(rollout, /"large_blob":"数据块数据块/); }); test("applySessionChanges restores original rollout mtime", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-mtime.jsonl"); await writeRollout(sessionPath, "thread-mtime", "apigather"); const originalTime = new Date("2026-01-02T03:04:05.000Z"); await fs.utimes(sessionPath, originalTime, originalTime); const { changes } = await collectSessionChanges(codexHome, "openai"); const result = await applySessionChanges(changes); assert.equal(result.appliedChanges, 1); const stat = await fs.stat(sessionPath); assert.equal(Math.round(stat.mtimeMs), originalTime.getTime()); }); test("collectSessionChanges reports encrypted_content counts by provider and scope", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-enc.jsonl"); const archivedPath = path.join(codexHome, "archived_sessions", "2026", "03", "18", "rollout-enc-archived.jsonl"); await writeRollout(sessionPath, "thread-enc", "apigather"); await fs.appendFile(sessionPath, '{"type":"event_msg","payload":{"encrypted_content":"gAAA"}}\n', "utf8"); await writeRollout(archivedPath, "thread-enc-archived", "openai"); await fs.appendFile(archivedPath, '{"type":"event_msg","payload":{"encrypted_content":"gBBB"}}\n', "utf8"); const { encryptedContentCounts } = await collectSessionChanges(codexHome, "openai"); assert.deepEqual(encryptedContentCounts, { sessions: { apigather: 1 }, archived_sessions: { openai: 1 } }); }); test("collectSessionChanges scans large rollout content without full-file reads", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-streamed.jsonl"); const payload = { id: "thread-streamed", timestamp: "2026-03-19T00:00:00.000Z", cwd: "C:\\AITemp", source: "cli", cli_version: "0.115.0", model_provider: "apigather" }; await fs.writeFile( sessionPath, `${JSON.stringify({ timestamp: payload.timestamp, type: "session_meta", payload })}\n`, "utf8" ); const chunkBytes = 1024 * 1024; const tokenPrefix = "encrypted_"; await fs.appendFile( sessionPath, `${"x".repeat(chunkBytes - tokenPrefix.length)}${tokenPrefix}content\n${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "after large content" } })}\n`, "utf8" ); const originalReadFile = fs.readFile; fs.readFile = async (filePath, ...args) => { if (path.resolve(String(filePath)) === path.resolve(sessionPath)) { throw new Error("rollout scan should not read the full file"); } return originalReadFile.call(fs, filePath, ...args); }; try { const { encryptedContentCounts, userEventThreadIds } = await collectSessionChanges(codexHome, "openai"); assert.deepEqual(encryptedContentCounts, { sessions: { apigather: 1 }, archived_sessions: {} }); assert.equal(userEventThreadIds.has("thread-streamed"), true); } finally { fs.readFile = originalReadFile; } }); test("applySessionChanges skips only the rollout file that becomes locked on Windows", async () => { if (process.platform !== "win32") { return; } const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const lockedPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-locked.jsonl"); const writablePath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-writable.jsonl"); await writeRollout(lockedPath, "thread-locked", "apigather"); await writeRollout(writablePath, "thread-writable", "apigather"); const { changes } = await collectSessionChanges(codexHome, "openai"); const lockProcess = await lockRolloutFile(lockedPath); let result; try { result = await applySessionChanges(changes); } finally { lockProcess.kill(); await new Promise((resolve) => lockProcess.once("exit", resolve)); } assert.equal(result.appliedChanges, 1); assert.deepEqual(result.appliedPaths, [writablePath]); assert.deepEqual(result.skippedPaths, [lockedPath]); const lockedRollout = await fs.readFile(lockedPath, "utf8"); const writableRollout = await fs.readFile(writablePath, "utf8"); assert.match(lockedRollout, /"model_provider":"apigather"/); assert.match(writableRollout, /"model_provider":"openai"/); }); test("restoreBackup only restores rollout files that were actually applied", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const configPath = path.join(codexHome, "config.toml"); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); const { changes } = await collectSessionChanges(codexHome, "openai"); const backupDir = await createBackup({ codexHome, targetProvider: "openai", sessionChanges: changes, configPath }); await updateSessionBackupManifest(backupDir, []); await writeRollout(sessionPath, "thread-a", "manual"); await restoreBackup(backupDir, codexHome, { restoreConfig: false, restoreDatabase: false, restoreSessions: true }); const rollout = await fs.readFile(sessionPath, "utf8"); assert.match(rollout, /"model_provider":"manual"/); }); test("restoreBackup can skip config, database, and sessions", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const configPath = path.join(codexHome, "config.toml"); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-skip.jsonl"); await writeRollout(sessionPath, "thread-skip", "apigather"); await writeStateDb(codexHome, [ { id: "thread-skip", model_provider: "apigather", archived: false } ]); const { changes } = await collectSessionChanges(codexHome, "openai"); const backupDir = await createBackup({ codexHome, targetProvider: "openai", sessionChanges: changes, configPath }); await writeConfig(codexHome, 'model_provider = "manual"'); await writeRollout(sessionPath, "thread-skip", "manual"); await restoreBackup(backupDir, codexHome, { restoreConfig: false, restoreDatabase: false, restoreSessions: false }); assert.match(await fs.readFile(configPath, "utf8"), /^model_provider = "manual"/m); assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"manual"/); }); test("runSync fails before rollout rewrite when SQLite is malformed", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-malformed-db.jsonl"); await writeRollout(sessionPath, "thread-malformed", "apigather"); await fs.mkdir(path.dirname(stateDbPath(codexHome)), { recursive: true }); await fs.writeFile(stateDbPath(codexHome), "not sqlite", "utf8"); await assert.rejects( () => runSync({ codexHome }), /state_5\.sqlite is malformed or unreadable/ ); assert.match(await fs.readFile(sessionPath, "utf8"), /"model_provider":"apigather"/); }); test("status reports malformed SQLite without failing", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); await writeRollout(path.join(codexHome, "sessions", "2026", "03", "19", "rollout-status-db.jsonl"), "thread-status", "openai"); await fs.mkdir(path.dirname(stateDbPath(codexHome)), { recursive: true }); await fs.writeFile(stateDbPath(codexHome), "not sqlite", "utf8"); const status = await getStatus({ codexHome }); assert.equal(status.sqliteCounts.unreadable, true); assert.match(renderStatus(status), /state_5\.sqlite is malformed or unreadable/); }); test("status skips locked rollout files without failing", async () => { if (process.platform !== "win32") { return; } const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-status-locked.jsonl"); await writeRollout(sessionPath, "thread-status-locked", "openai"); const lockProcess = await lockRolloutFile(sessionPath); try { const status = await getStatus({ codexHome }); assert.deepEqual(status.lockedRolloutFiles, [sessionPath]); assert.match(renderStatus(status), /Locked rollout files skipped during status scan: 1/); } finally { lockProcess.kill(); await new Promise((resolve) => lockProcess.once("exit", resolve)); } }); test("pruneBackups removes the oldest backup directories", async () => { const { codexHome } = await makeTempCodexHome(); const oldestBytes = await writeBackup(codexHome, "20260319T000000000Z", [ ["note.txt", "oldest"], ["db/state_5.sqlite", "sqlite"] ]); await writeBackup(codexHome, "20260320T000000000Z", [["note.txt", "middle"]]); await writeBackup(codexHome, "20260321T000000000Z", [["note.txt", "newest"]]); const result = await pruneBackups(codexHome, 2); assert.equal(result.backupRoot, backupRoot(codexHome)); assert.equal(result.deletedCount, 1); assert.equal(result.remainingCount, 2); assert.equal(result.freedBytes, oldestBytes); await assert.rejects(fs.access(path.join(backupRoot(codexHome), "20260319T000000000Z"))); await fs.access(path.join(backupRoot(codexHome), "20260320T000000000Z")); await fs.access(path.join(backupRoot(codexHome), "20260321T000000000Z")); }); test("pruneBackups ignores directories without managed backup metadata", async () => { const { codexHome } = await makeTempCodexHome(); await writeBackup(codexHome, "20260320T000000000Z", [ ["metadata.json", JSON.stringify({ namespace: "provider-sync" })] ]); const junkDirectory = path.join(backupRoot(codexHome), "manual-notes"); await fs.mkdir(junkDirectory, { recursive: true }); await fs.writeFile(path.join(junkDirectory, "readme.txt"), "keep me", "utf8"); const result = await pruneBackups(codexHome, 0); assert.equal(result.deletedCount, 1); assert.equal(result.remainingCount, 0); await fs.access(junkDirectory); }); test("runSync auto-prunes backups to the default retention count", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); for (let index = 0; index < DEFAULT_BACKUP_RETENTION_COUNT; index += 1) { await writeBackup(codexHome, `20240101T0000${String(index).padStart(2, "0")}000Z`, [ ["note.txt", `backup-${index}`] ]); } const result = await runSync({ codexHome }); const summary = await getBackupSummary(codexHome); assert.equal(summary.count, DEFAULT_BACKUP_RETENTION_COUNT); await fs.access(result.backupDir); assert.equal(result.autoPruneResult.deletedCount, 1); assert.equal(result.autoPruneResult.remainingCount, DEFAULT_BACKUP_RETENTION_COUNT); assert.equal(result.autoPruneWarning, null); }); test("runSync uses a custom automatic backup retention count", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); for (let index = 0; index < 4; index += 1) { await writeBackup(codexHome, `20240101T0000${String(index).padStart(2, "0")}000Z`, [ ["note.txt", `backup-${index}`] ]); } const result = await runSync({ codexHome, keepCount: 2 }); const summary = await getBackupSummary(codexHome); assert.equal(summary.count, 2); await fs.access(result.backupDir); assert.equal(result.autoPruneResult.deletedCount, 3); assert.equal(result.autoPruneResult.remainingCount, 2); assert.equal(result.autoPruneWarning, null); }); test("cli rejects non-integer keep values", async () => { const result = await runCli(["prune-backups", "--keep", "1.5"]); assert.equal(result.code, 1); assert.match(result.stderr, /Invalid --keep value: 1\.5/); }); test("node version guard allows Node 16 and rejects older releases", () => { assert.equal(getUnsupportedNodeVersionMessage("16.0.0"), null); assert.match( getUnsupportedNodeVersionMessage("14.21.3"), /requires Node\.js 16\+/ ); }); test("cli sync prints stage progress and backup timing", async () => { const { codexHome } = await makeTempCodexHome(); await writeConfig(codexHome, 'model_provider = "openai"'); const sessionPath = path.join(codexHome, "sessions", "2026", "03", "19", "rollout-a.jsonl"); await writeRollout(sessionPath, "thread-a", "apigather"); await writeStateDb(codexHome, [ { id: "thread-a", model_provider: "apigather", archived: false } ]); const result = await runCli(["sync", "--codex-home", codexHome]); assert.equal(result.code, 0); assert.match(result.stdout, /\[1\/6\] Scanning rollout files\.\.\./); assert.match(result.stdout, /\[2\/6\] Checking locked rollout files\.\.\./); assert.match(result.stdout, /\[3\/6\] Creating backup\.\.\./); assert.match(result.stdout, /\[4\/6\] Updating SQLite\.\.\./); assert.match(result.stdout, /\[5\/6\] Rewriting rollout files\.\.\./); assert.match(result.stdout, /\[6\/6\] Cleaning backups\.\.\./); assert.match(result.stdout, /Backup created in .*: .+/); assert.match(result.stdout, /Backup creation time: /); });