Files
2026-07-13 12:31:29 +08:00

1222 lines
45 KiB
JavaScript

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: /);
});