diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index eee868ee..db3a66aa 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -32,6 +32,7 @@ import { getCodexCliConfigPath, loadCodexCliState, } from "./codex-cli/state.js"; +import { getLatestCodexCliSyncRollbackPlan } from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { applyUiThemeFromDashboardSettings, @@ -80,6 +81,7 @@ import { getActionableNamedBackupRestores, getNamedBackupsDirectoryPath, getStoragePath, + listAccountSnapshots, listNamedBackups, listRotatingBackups, loadAccounts, @@ -3574,6 +3576,7 @@ async function runDoctor(args: string[]): Promise { setStoragePath(null); const storagePath = getStoragePath(); + const walPath = `${storagePath}.wal`; const checks: DoctorCheck[] = []; const addCheck = (check: DoctorCheck): void => { checks.push(check); @@ -3608,6 +3611,79 @@ async function runDoctor(args: string[]): Promise { } } + addCheck({ + key: "storage-journal", + severity: existsSync(walPath) ? "ok" : "warn", + message: existsSync(walPath) + ? "Write-ahead journal found" + : "Write-ahead journal missing; recovery will rely on backups", + details: walPath, + }); + + const rotatingBackups = await listRotatingBackups(); + const validRotatingBackups = rotatingBackups.filter((backup) => backup.valid); + const invalidRotatingBackups = rotatingBackups.filter( + (backup) => !backup.valid, + ); + addCheck({ + key: "rotating-backups", + severity: + validRotatingBackups.length > 0 + ? "ok" + : rotatingBackups.length > 0 + ? "error" + : "warn", + message: + validRotatingBackups.length > 0 + ? `${validRotatingBackups.length} rotating backup(s) available` + : rotatingBackups.length > 0 + ? "Rotating backups are unreadable" + : "No rotating backups found yet", + details: + invalidRotatingBackups.length > 0 + ? `${invalidRotatingBackups.length} invalid backup(s); recreate by saving accounts` + : dirname(storagePath), + }); + + const snapshotBackups = await listAccountSnapshots(); + const validSnapshots = snapshotBackups.filter((snapshot) => snapshot.valid); + const invalidSnapshots = snapshotBackups.filter( + (snapshot) => !snapshot.valid, + ); + addCheck({ + key: "snapshot-backups", + severity: + validSnapshots.length > 0 + ? "ok" + : snapshotBackups.length > 0 + ? "error" + : "warn", + message: + validSnapshots.length > 0 + ? `${validSnapshots.length} recovery snapshot(s) available` + : snapshotBackups.length > 0 + ? "Snapshot backups are unreadable" + : "No recovery snapshots found", + details: + invalidSnapshots.length > 0 + ? `${invalidSnapshots.length} invalid snapshot(s); create a fresh snapshot before destructive actions` + : getNamedBackupsDirectoryPath(), + }); + + const hasAnyRecoveryArtifact = + existsSync(storagePath) || + existsSync(walPath) || + validRotatingBackups.length > 0 || + validSnapshots.length > 0; + addCheck({ + key: "recovery-chain", + severity: hasAnyRecoveryArtifact ? "ok" : "warn", + message: hasAnyRecoveryArtifact + ? "Recovery artifacts present" + : "No recovery artifacts found; create a snapshot or backup before destructive actions", + details: `storage=${existsSync(storagePath)}, wal=${existsSync(walPath)}, rotating=${validRotatingBackups.length}, snapshots=${validSnapshots.length}`, + }); + const codexAuthPath = getCodexCliAuthPath(); const codexConfigPath = getCodexCliConfigPath(); let codexAuthEmail: string | undefined; @@ -3720,8 +3796,80 @@ async function runDoctor(args: string[]): Promise { details: codexCliState?.path, }); + const rollbackPlan = await getLatestCodexCliSyncRollbackPlan(); + const rollbackReason = (rollbackPlan.reason ?? "").trim(); + const rollbackSnapshotPath = rollbackPlan.snapshot?.path; + const rollbackSnapshotName = rollbackPlan.snapshot?.name; + const rollbackReasonLower = rollbackReason.toLowerCase(); + if (rollbackPlan.status === "ready") { + const count = + rollbackPlan.accountCount ?? rollbackPlan.storage?.accounts.length; + addCheck({ + key: "codex-cli-rollback-checkpoint", + severity: "ok", + message: `Latest manual Codex CLI rollback checkpoint ready (${count ?? "?"} account${count === 1 ? "" : "s"})`, + details: rollbackSnapshotPath, + }); + } else if (rollbackPlan.snapshot) { + const isMissing = + rollbackReasonLower.includes("missing") || + rollbackReasonLower.includes("not found") || + rollbackReasonLower.includes("enoent"); + const key = isMissing + ? "codex-cli-rollback-checkpoint-missing" + : "codex-cli-rollback-checkpoint-invalid"; + const recoveryAction = isMissing + ? "Action: Recreate the rollback checkpoint by running a manual Codex CLI sync with storage backups enabled." + : "Action: Recreate the rollback checkpoint with a fresh manual Codex CLI sync before attempting rollback."; + const detailParts = [ + rollbackSnapshotPath ?? rollbackSnapshotName, + rollbackReason || undefined, + recoveryAction, + ].filter(Boolean); + addCheck({ + key, + severity: "error", + message: isMissing + ? "Latest manual Codex CLI rollback checkpoint is missing; rollback is blocked." + : "Latest manual Codex CLI rollback checkpoint cannot be restored.", + details: detailParts.join(" | ") || undefined, + }); + } else { + addCheck({ + key: "codex-cli-rollback-checkpoint", + severity: "warn", + message: "No manual Codex CLI rollback checkpoint has been recorded yet", + details: + "Action: Run a manual Codex CLI sync with backups enabled to capture a rollback checkpoint before applying changes.", + }); + } + const storage = await loadAccounts(); let fixChanged = false; + + const actionableNamedBackupRestores = await getActionableNamedBackupRestores({ + currentStorage: storage, + }); + const actionableCount = actionableNamedBackupRestores.assessments.length; + const backupRecoveryAction = + actionableCount > 0 + ? undefined + : `Action: Add or copy a named backup into ${getNamedBackupsDirectoryPath()} before attempting recovery.`; + addCheck({ + key: "named-backup-restores", + severity: actionableCount > 0 ? "ok" : "warn", + message: + actionableCount > 0 + ? `Found ${actionableCount} actionable named backup restore${actionableCount === 1 ? "" : "s"}` + : "No actionable named backup restores available", + details: [ + `total backups: ${actionableNamedBackupRestores.totalBackups}`, + backupRecoveryAction, + ] + .filter(Boolean) + .join(" | "), + }); + let fixActions: DoctorFixAction[] = []; if (options.fix && storage && storage.accounts.length > 0) { const fixed = applyDoctorFixes(storage); diff --git a/lib/storage.ts b/lib/storage.ts index 77889b86..e039d594 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -43,6 +43,9 @@ const BACKUP_COPY_MAX_ATTEMPTS = 5; const BACKUP_COPY_BASE_DELAY_MS = 10; const NAMED_BACKUP_DIRECTORY = "backups"; const NAMED_BACKUP_EXTENSION = ".json"; +export const ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3; +const AUTO_SNAPSHOT_NAME_PATTERN = + /^accounts-(?[a-z0-9-]+)-snapshot-(?\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})$/i; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -486,6 +489,15 @@ function deriveBackupNameFromFile(fileName: string): string { return normalizeBackupName(fileName); } +export function isAccountSnapshotName(name: string): boolean { + return AUTO_SNAPSHOT_NAME_PATTERN.test(name); +} + +function getAccountSnapshotReason(name: string): string | null { + const match = name.match(AUTO_SNAPSHOT_NAME_PATTERN); + return match?.groups?.reason?.toLowerCase() ?? null; +} + function clampActiveIndexForPreview( currentIndex: number, accounts: AccountLike[], @@ -1471,6 +1483,11 @@ export async function listNamedBackups(): Promise { } } +export async function listAccountSnapshots(): Promise { + const backups = await listNamedBackups(); + return backups.filter((backup) => isAccountSnapshotName(backup.name)); +} + export async function listRotatingBackups(): Promise { const storagePath = getStoragePath(); const candidates = getAccountsBackupRecoveryCandidates(storagePath); @@ -1567,6 +1584,50 @@ function buildAccountSnapshotName( return `accounts-${reason}-snapshot-${formatTimestampForSnapshot(timestamp)}`; } +async function enforceSnapshotRetention(): Promise { + const snapshots = await listAccountSnapshots(); + if (snapshots.length === 0) return; + + const grouped = snapshots.reduce>( + (acc, snapshot) => { + const reason = getAccountSnapshotReason(snapshot.name) ?? "unknown"; + acc[reason] = acc[reason] ?? []; + acc[reason].push(snapshot); + return acc; + }, + {}, + ); + + for (const [reason, entries] of Object.entries(grouped)) { + const sorted = [...entries].sort( + (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0), + ); + const keep = sorted.slice(0, ACCOUNT_SNAPSHOT_RETENTION_PER_REASON); + const drop = sorted.slice(ACCOUNT_SNAPSHOT_RETENTION_PER_REASON); + for (const snapshot of drop) { + try { + await fs.unlink(snapshot.path); + log.debug("Pruned snapshot backup", { path: snapshot.path }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to prune snapshot backup", { + path: snapshot.path, + reason, + error: String(error), + }); + } + } + } + if (keep.length > 0) { + log.debug("Snapshot retention kept latest backups", { + reason, + kept: keep.map((entry) => entry.name), + }); + } + } +} + export async function snapshotAccountStorage( options: AccountSnapshotOptions, ): Promise { @@ -1586,7 +1647,9 @@ export async function snapshotAccountStorage( const backupName = buildAccountSnapshotName(reason, now); try { - return await createBackup(backupName, { force }); + const snapshot = await createBackup(backupName, { force }); + await enforceSnapshotRetention(); + return snapshot; } catch (error) { if (failurePolicy === "error") { throw error; @@ -1600,6 +1663,137 @@ export async function snapshotAccountStorage( } } +type AutoSnapshotDetails = { + name: string; + reason: string; + timestampMs: number; + backup: NamedBackupMetadata; +}; + +async function getLatestManualCodexCliRollbackSnapshotNames(): Promise< + Set +> { + try { + const syncHistoryModule = await import("./sync-history.js"); + if (typeof syncHistoryModule.readSyncHistory !== "function") { + return new Set(); + } + const history = await syncHistoryModule.readSyncHistory({ + kind: "codex-cli-sync", + }); + for (let i = history.length - 1; i >= 0; i -= 1) { + const entry = history[i]; + if (!entry || entry.kind !== "codex-cli-sync") continue; + if (entry.run?.trigger !== "manual") continue; + const snapshotName = entry.run.rollbackSnapshot?.name; + if (snapshotName && snapshotName.trim()) { + return new Set([snapshotName]); + } + break; + } + } catch (error) { + log.debug("Failed to load sync history for rollback snapshot retention", { + error: String(error), + }); + } + return new Set(); +} + +function parseAutoSnapshot( + details: NamedBackupMetadata, +): AutoSnapshotDetails | null { + const match = details.name.match(AUTO_SNAPSHOT_NAME_PATTERN); + if (!match?.groups) return null; + + const { reason, timestamp } = match.groups as { + reason?: string; + timestamp?: string; + }; + if (!reason || !timestamp) return null; + + const [datePart, timePart] = timestamp.split("_"); + if (!datePart || !timePart) return null; + const formatted = `${datePart}T${timePart.replace(/-/g, ":")}Z`; + const parsed = Date.parse(formatted); + if (!Number.isFinite(parsed)) return null; + + return { + name: details.name, + reason: reason.toLowerCase(), + timestampMs: parsed, + backup: details, + }; +} + +export interface AutoSnapshotPruneOptions { + backups?: NamedBackupMetadata[]; + preserveNames?: Iterable; + keepLatestPerReason?: number; +} + +export interface AutoSnapshotPruneResult { + pruned: NamedBackupMetadata[]; + kept: NamedBackupMetadata[]; +} + +export async function pruneAutoGeneratedSnapshots( + options: AutoSnapshotPruneOptions = {}, +): Promise { + const backups = options.backups ?? (await listNamedBackups()); + const keepLatestPerReason = Math.max(1, options.keepLatestPerReason ?? 1); + const preserveNames = new Set(options.preserveNames ?? []); + for (const rollbackName of await getLatestManualCodexCliRollbackSnapshotNames()) { + preserveNames.add(rollbackName); + } + + const autoSnapshots: AutoSnapshotDetails[] = []; + for (const backup of backups) { + const parsed = parseAutoSnapshot(backup); + if (parsed) { + autoSnapshots.push(parsed); + } + } + + const keepSet = new Set(preserveNames); + const snapshotsByReason = new Map(); + for (const snapshot of autoSnapshots) { + const bucket = snapshotsByReason.get(snapshot.reason) ?? []; + bucket.push(snapshot); + snapshotsByReason.set(snapshot.reason, bucket); + } + + for (const snapshots of snapshotsByReason.values()) { + snapshots.sort((a, b) => b.timestampMs - a.timestampMs); + for (const snapshot of snapshots.slice(0, keepLatestPerReason)) { + keepSet.add(snapshot.name); + } + } + + const pruned: NamedBackupMetadata[] = []; + for (const snapshot of autoSnapshots) { + if (keepSet.has(snapshot.name)) continue; + pruned.push(snapshot.backup); + } + + for (const backup of pruned) { + try { + await fs.rm(backup.path, { force: true }); + } catch (error) { + log.debug("Failed to prune auto-generated snapshot", { + name: backup.name, + path: backup.path, + error: String(error), + }); + } + } + + const kept = autoSnapshots + .filter((snapshot) => keepSet.has(snapshot.name)) + .map((snapshot) => snapshot.backup); + + return { pruned, kept }; +} + export async function assessNamedBackupRestore( name: string, options: { currentStorage?: AccountStorageV3 | null } = {}, diff --git a/lib/sync-history.ts b/lib/sync-history.ts index 071364fd..7eb60137 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -6,6 +6,7 @@ import { createLogger } from "./logger.js"; import { getCodexLogDir } from "./runtime-paths.js"; const log = createLogger("sync-history"); +const SYNC_HISTORY_MAX_ENTRIES = 200; type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -84,6 +85,67 @@ function cloneEntry(entry: T): T { return JSON.parse(JSON.stringify(entry)) as T; } +export interface PrunedSyncHistory { + entries: SyncHistoryEntry[]; + removed: number; + latest: SyncHistoryEntry | null; +} + +export function pruneSyncHistoryEntries( + entries: SyncHistoryEntry[], + maxEntries: number = SYNC_HISTORY_MAX_ENTRIES, +): PrunedSyncHistory { + const boundedMaxEntries = Math.max(0, maxEntries); + if (entries.length === 0) { + return { entries: [], removed: 0, latest: null }; + } + + const latestByKind = new Map(); + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry) continue; + if (!latestByKind.has(entry.kind)) { + latestByKind.set(entry.kind, entry); + } + } + + const required = new Set(latestByKind.values()); + const kept: SyncHistoryEntry[] = []; + const seen = new Set(); + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!entry || seen.has(entry)) continue; + const keepEntry = kept.length < boundedMaxEntries || required.has(entry); + if (keepEntry) { + kept.push(entry); + seen.add(entry); + } + } + + const chronological = kept.reverse(); + const latest = chronological.at(-1) ?? null; + return { + entries: chronological, + removed: entries.length - chronological.length, + latest, + }; +} + +async function rewriteLatestEntry( + latest: SyncHistoryEntry | null, + paths: SyncHistoryPaths, +): Promise { + if (!latest) { + await fs.rm(paths.latestPath, { force: true }); + return; + } + const latestContent = `${JSON.stringify(latest, null, 2)}\n`; + await fs.writeFile(paths.latestPath, latestContent, { + encoding: "utf8", + mode: 0o600, + }); +} + export async function appendSyncHistoryEntry( entry: SyncHistoryEntry, ): Promise { @@ -163,6 +225,44 @@ export function readLatestSyncHistorySync(): SyncHistoryEntry | null { } } +export async function pruneSyncHistory( + options: { maxEntries?: number } = {}, +): Promise<{ removed: number; kept: number; latest: SyncHistoryEntry | null }> { + const maxEntries = options.maxEntries ?? SYNC_HISTORY_MAX_ENTRIES; + await waitForPendingHistoryWrites(); + return withHistoryLock(async () => { + const paths = getSyncHistoryPaths(); + await ensureHistoryDir(paths.directory); + let entries: SyncHistoryEntry[] = []; + try { + entries = await readSyncHistory(); + } catch { + entries = []; + } + + const { + entries: prunedEntries, + removed, + latest, + } = pruneSyncHistoryEntries(entries, maxEntries); + if (prunedEntries.length === 0) { + await fs.rm(paths.historyPath, { force: true }); + } else { + const serialized = + prunedEntries.map((entry) => serializeEntry(entry)).join("\n") + + (prunedEntries.length > 0 ? "\n" : ""); + await fs.writeFile(paths.historyPath, serialized, { + encoding: "utf8", + mode: 0o600, + }); + } + await rewriteLatestEntry(latest, paths); + lastAppendPaths = paths; + lastAppendError = null; + return { removed, kept: prunedEntries.length, latest }; + }); +} + export function configureSyncHistoryForTests(directory: string | null): void { historyDirOverride = directory ? directory.trim() : null; } diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index aab65e29..708bb3e4 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -23,6 +23,8 @@ import { __resetSyncHistoryForTests, appendSyncHistoryEntry, configureSyncHistoryForTests, + pruneSyncHistory, + readLatestSyncHistorySync, readSyncHistory, } from "../lib/sync-history.js"; @@ -977,6 +979,68 @@ describe("codex-cli sync", () => { } }); + it("prunes sync history while keeping per-kind latest pointers aligned", async () => { + const summary = { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: { + outcome: "changed", + runAt: 1, + sourcePath: accountsPath, + targetPath: accountsPath, + summary, + trigger: "manual", + rollbackSnapshot: null, + }, + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: accountsPath, + snapshot: { + path: accountsPath, + running: true, + lastKnownMtimeMs: 2, + lastSyncAt: 2, + reloadCount: 1, + errorCount: 0, + }, + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: { + outcome: "changed", + runAt: 3, + sourcePath: accountsPath, + targetPath: accountsPath, + summary, + trigger: "automatic", + rollbackSnapshot: null, + }, + }); + + const result = await pruneSyncHistory({ maxEntries: 1 }); + expect(result.kept).toBe(2); + const history = await readSyncHistory(); + expect(history.map((entry) => entry.recordedAt)).toEqual([2, 3]); + const latest = readLatestSyncHistorySync(); + expect(latest?.kind).toBe("codex-cli-sync"); + expect(latest?.recordedAt).toBe(3); + }); + it("restores the latest Codex CLI sync rollback checkpoint", async () => { const storagePath = join(tempDir, "restore", "openai-codex-accounts.json"); const snapshotPath = join(tempDir, "logs", "rollback-snapshot.json"); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ed3f5cf6..dddd3c80 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -29,6 +29,7 @@ const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const previewCodexCliSyncMock = vi.fn(); const syncAccountStorageFromCodexCliMock = vi.fn(); +const getLatestCodexCliSyncRollbackPlanMock = vi.fn(); const getCodexCliAccountsPathMock = vi.fn(() => "/mock/codex/accounts.json"); const getCodexCliAuthPathMock = vi.fn(() => "/mock/codex/auth.json"); const getCodexCliConfigPathMock = vi.fn(() => "/mock/codex/config.toml"); @@ -127,6 +128,7 @@ vi.mock("../lib/codex-cli/writer.js", () => ({ vi.mock("../lib/codex-cli/sync.js", () => ({ previewCodexCliSync: previewCodexCliSyncMock, syncAccountStorageFromCodexCli: syncAccountStorageFromCodexCliMock, + getLatestCodexCliSyncRollbackPlan: getLatestCodexCliSyncRollbackPlanMock, })); vi.mock("../lib/codex-cli/state.js", () => ({ @@ -286,6 +288,7 @@ describe("codex manager cli commands", () => { savePluginConfigMock.mockReset(); previewCodexCliSyncMock.mockReset(); syncAccountStorageFromCodexCliMock.mockReset(); + getLatestCodexCliSyncRollbackPlanMock.mockReset(); getCodexCliAccountsPathMock.mockReset(); getCodexCliAuthPathMock.mockReset(); getCodexCliConfigPathMock.mockReset(); @@ -402,6 +405,12 @@ describe("codex manager cli commands", () => { getCodexCliConfigPathMock.mockReturnValue("/mock/codex/config.toml"); isCodexCliSyncEnabledMock.mockReturnValue(true); loadCodexCliStateMock.mockResolvedValue(null); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValue({ + status: "unavailable", + reason: + "No manual Codex CLI sync with a rollback checkpoint is available.", + snapshot: null, + }); getLastLiveAccountSyncSnapshotMock.mockReturnValue({ path: null, running: false, @@ -1908,6 +1917,48 @@ describe("codex manager cli commands", () => { ); }); + it("reports rollback checkpoint check in doctor json output", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "checkpoint@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + ], + }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValueOnce({ + status: "ready", + reason: "Rollback checkpoint ready (1 account).", + snapshot: { + name: "codex-checkpoint", + path: "/mock/backups/rollback.json", + }, + accountCount: 1, + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + checks: Array<{ key: string; severity: string; details?: string }>; + }; + const checkpoint = payload.checks.find( + (check) => check.key === "rollback-checkpoint", + ); + expect(checkpoint).toBeDefined(); + expect(checkpoint?.severity).toBe("ok"); + expect(checkpoint?.details).toBe("/mock/backups/rollback.json"); + }); + it("runs doctor --fix in dry-run mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/storage-recovery-paths.test.ts b/test/storage-recovery-paths.test.ts index 1d435934..323b4c69 100644 --- a/test/storage-recovery-paths.test.ts +++ b/test/storage-recovery-paths.test.ts @@ -7,11 +7,17 @@ import { assessNamedBackupRestore, getNamedBackupsDirectoryPath, loadAccounts, + pruneAutoGeneratedSnapshots, restoreNamedBackup, saveAccounts, setStorageBackupEnabled, setStoragePathDirect, } from "../lib/storage.js"; +import { + __resetSyncHistoryForTests, + appendSyncHistoryEntry, + configureSyncHistoryForTests, +} from "../lib/sync-history.js"; function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); @@ -35,6 +41,8 @@ describe("storage recovery paths", () => { afterEach(async () => { setStoragePathDirect(null); setStorageBackupEnabled(true); + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); await fs.rm(workDir, { recursive: true, force: true }); }); @@ -593,4 +601,78 @@ describe("storage recovery paths", () => { expect(assessment.nextActiveEmail).toBe("replace@example.com"); expect(assessment.currentActiveEmail).toBe("replace@example.com"); }); + + it("prunes older auto snapshots while keeping latest and manual backups", async () => { + const backupsDir = getNamedBackupsDirectoryPath(); + await fs.mkdir(backupsDir, { recursive: true }); + const payload = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-a" }], + }; + const olderName = "accounts-codex-cli-sync-snapshot-2026-03-10_00-00-00"; + const olderPath = join(backupsDir, `${olderName}.json`); + const newerName = "accounts-codex-cli-sync-snapshot-2026-03-12_00-00-00"; + const newerPath = join(backupsDir, `${newerName}.json`); + const manualPath = join(backupsDir, "manual-checkpoint.json"); + await fs.writeFile(olderPath, JSON.stringify(payload), "utf-8"); + await fs.writeFile(newerPath, JSON.stringify(payload), "utf-8"); + await fs.writeFile(manualPath, JSON.stringify(payload), "utf-8"); + + const result = await pruneAutoGeneratedSnapshots(); + expect(result.pruned.map((entry) => entry.name)).toContain(olderName); + expect(result.kept.map((entry) => entry.name)).toContain(newerName); + expect(existsSync(olderPath)).toBe(false); + expect(existsSync(newerPath)).toBe(true); + expect(existsSync(manualPath)).toBe(true); + }); + + it("retains rollback-referenced snapshots when pruning auto-generated backups", async () => { + const backupsDir = getNamedBackupsDirectoryPath(); + const logsDir = join(workDir, "logs"); + configureSyncHistoryForTests(logsDir); + await fs.mkdir(backupsDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + const payload = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "rt-b" }], + }; + const referencedName = + "accounts-codex-cli-sync-snapshot-2026-03-11_00-00-00"; + const referencedPath = join(backupsDir, `${referencedName}.json`); + const staleName = "accounts-codex-cli-sync-snapshot-2026-03-09_00-00-00"; + const stalePath = join(backupsDir, `${staleName}.json`); + await fs.writeFile(referencedPath, JSON.stringify(payload), "utf-8"); + await fs.writeFile(stalePath, JSON.stringify(payload), "utf-8"); + + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: Date.now(), + run: { + outcome: "changed", + runAt: Date.now(), + sourcePath: storagePath, + targetPath: storagePath, + summary: { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + trigger: "manual", + rollbackSnapshot: { name: referencedName, path: referencedPath }, + }, + }); + + const result = await pruneAutoGeneratedSnapshots(); + expect(result.kept.map((entry) => entry.name)).toContain(referencedName); + expect(result.pruned.map((entry) => entry.name)).toContain(staleName); + expect(existsSync(referencedPath)).toBe(true); + expect(existsSync(stalePath)).toBe(false); + }); }); diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts new file mode 100644 index 00000000..7d7561da --- /dev/null +++ b/test/sync-history.test.ts @@ -0,0 +1,187 @@ +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CodexCliSyncRun } from "../lib/codex-cli/sync.js"; +import type { LiveAccountSyncSnapshot } from "../lib/live-account-sync.js"; +import { + __resetSyncHistoryForTests, + appendSyncHistoryEntry, + configureSyncHistoryForTests, + pruneSyncHistory, + pruneSyncHistoryEntries, + readLatestSyncHistorySync, + readSyncHistory, +} from "../lib/sync-history.js"; + +describe("sync history pruning", () => { + let workDir = ""; + let logDir = ""; + + beforeEach(async () => { + workDir = join( + process.cwd(), + "tmp-sync-history", + `sync-history-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + logDir = join(workDir, "logs"); + configureSyncHistoryForTests(logDir); + await fs.mkdir(logDir, { recursive: true }); + await __resetSyncHistoryForTests(); + }); + + afterEach(async () => { + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); + await fs + .rm(workDir, { recursive: true, force: true }) + .catch(() => undefined); + }); + + function createCodexSummary(): CodexCliSyncRun["summary"] { + return { + sourceAccountCount: 0, + targetAccountCountBefore: 0, + targetAccountCountAfter: 0, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }; + } + + function createCodexRun(runAt: number, targetPath: string): CodexCliSyncRun { + return { + outcome: "noop", + runAt, + sourcePath: null, + targetPath, + summary: createCodexSummary(), + trigger: "manual", + rollbackSnapshot: null, + }; + } + + function createLiveSnapshot(now: number): LiveAccountSyncSnapshot { + return { + path: `/live-${now}`, + running: true, + lastKnownMtimeMs: now, + lastSyncAt: now, + reloadCount: 1, + errorCount: 0, + }; + } + + it("keeps the latest codex-cli-sync entry when trimming aggressively", () => { + const entries = [ + { + kind: "codex-cli-sync" as const, + recordedAt: 1, + run: createCodexRun(1, "/first"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 2, + reason: "watch" as const, + outcome: "success" as const, + path: "/live/first", + snapshot: createLiveSnapshot(2), + }, + { + kind: "codex-cli-sync" as const, + recordedAt: 3, + run: createCodexRun(3, "/second"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 4, + reason: "poll" as const, + outcome: "success" as const, + path: "/live/second", + snapshot: createLiveSnapshot(4), + }, + ]; + const result = pruneSyncHistoryEntries(entries, 1); + const latestCodex = result.entries.find( + (entry) => entry.kind === "codex-cli-sync", + ); + expect(latestCodex).toBeDefined(); + expect(latestCodex?.recordedAt).toBe(3); + expect(result.removed).toBe(2); + }); + + it("keeps the latest live-account-sync entry when trimming aggressively", () => { + const entries = [ + { + kind: "codex-cli-sync" as const, + recordedAt: 1, + run: createCodexRun(1, "/first"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 2, + reason: "watch" as const, + outcome: "success" as const, + path: "/live/first", + snapshot: createLiveSnapshot(2), + }, + { + kind: "codex-cli-sync" as const, + recordedAt: 3, + run: createCodexRun(3, "/second"), + }, + { + kind: "live-account-sync" as const, + recordedAt: 4, + reason: "poll" as const, + outcome: "error" as const, + path: "/live/second", + snapshot: createLiveSnapshot(4), + }, + ]; + const result = pruneSyncHistoryEntries(entries, 1); + const latestLive = result.entries.find( + (entry) => entry.kind === "live-account-sync", + ); + expect(latestLive).toBeDefined(); + expect(latestLive?.recordedAt).toBe(4); + expect(result.removed).toBe(2); + }); + + it("keeps latest entry on disk after pruning and mirrors latest file", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 1, + run: createCodexRun(1, "/source-1"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 2, + reason: "watch", + outcome: "success", + path: "/watch-1", + snapshot: createLiveSnapshot(2), + }); + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 3, + run: createCodexRun(3, "/source-2"), + }); + await appendSyncHistoryEntry({ + kind: "live-account-sync", + recordedAt: 4, + reason: "poll", + outcome: "error", + path: "/poll-2", + snapshot: createLiveSnapshot(4), + }); + + const result = await pruneSyncHistory({ maxEntries: 1 }); + const latestOnDisk = readLatestSyncHistorySync(); + expect(latestOnDisk).toEqual(result.latest); + const history = await readSyncHistory(); + expect(history.at(-1)).toEqual(result.latest); + expect(history).toHaveLength(2); + }); +});