diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index eef9ace..69bf1ba 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -6,6 +6,12 @@ import { getLastAccountsSaveTimestamp, getStoragePath, } from "../storage.js"; +import { + appendSyncHistoryEntry, + cloneSyncHistoryEntry, + readLatestSyncHistorySync, + readSyncHistory, +} from "../sync-history.js"; import { incrementCodexCliMetric, makeAccountFingerprint, @@ -100,6 +106,7 @@ interface ReconcileResult { } let lastCodexCliSyncRun: CodexCliSyncRun | null = null; +let lastHistoryLoadAttempted = false; function createEmptySyncSummary(): CodexCliSyncSummary { return { @@ -114,21 +121,56 @@ function createEmptySyncSummary(): CodexCliSyncSummary { }; } -function setLastCodexCliSyncRun(run: CodexCliSyncRun): void { +async function setLastCodexCliSyncRun(run: CodexCliSyncRun): Promise { lastCodexCliSyncRun = run; + try { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: run.runAt, + run, + }); + } catch (error) { + log.debug("Failed to record codex-cli sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } } export function getLastCodexCliSyncRun(): CodexCliSyncRun | null { - return lastCodexCliSyncRun - ? { - ...lastCodexCliSyncRun, - summary: { ...lastCodexCliSyncRun.summary }, - } - : null; + if (lastCodexCliSyncRun) { + return { + ...lastCodexCliSyncRun, + summary: { ...lastCodexCliSyncRun.summary }, + }; + } + + if (!lastHistoryLoadAttempted) { + lastHistoryLoadAttempted = true; + const latest = readLatestSyncHistorySync(); + const cloned = cloneSyncHistoryEntry(latest); + if (cloned?.kind === "codex-cli-sync") { + lastCodexCliSyncRun = cloned.run; + return { + ...cloned.run, + summary: { ...cloned.run.summary }, + }; + } + void readSyncHistory({ kind: "codex-cli-sync", limit: 1 }) + .then((entries) => { + const latestCodexEntry = entries.at(-1); + if (latestCodexEntry?.kind === "codex-cli-sync") { + lastCodexCliSyncRun = latestCodexEntry.run; + } + }) + .catch(() => undefined); + } + + return null; } export function __resetLastCodexCliSyncRunForTests(): void { lastCodexCliSyncRun = null; + lastHistoryLoadAttempted = false; } function buildIndexByAccountId( @@ -527,7 +569,7 @@ export async function syncAccountStorageFromCodexCli( const state = await loadCodexCliState(); if (!state) { incrementCodexCliMetric("reconcileNoops"); - setLastCodexCliSyncRun({ + await setLastCodexCliSyncRun({ outcome: (process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI ?? "").trim() === "0" ? "disabled" @@ -554,7 +596,7 @@ export async function syncAccountStorageFromCodexCli( if (next.accounts.length === 0) { incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - setLastCodexCliSyncRun({ + await setLastCodexCliSyncRun({ outcome: changed ? "changed" : "noop", runAt: Date.now(), sourcePath: state.path, @@ -574,7 +616,7 @@ export async function syncAccountStorageFromCodexCli( incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - setLastCodexCliSyncRun({ + await setLastCodexCliSyncRun({ outcome: changed ? "changed" : "noop", runAt: Date.now(), sourcePath: state.path, @@ -596,7 +638,7 @@ export async function syncAccountStorageFromCodexCli( }; } catch (error) { incrementCodexCliMetric("reconcileFailures"); - setLastCodexCliSyncRun({ + await setLastCodexCliSyncRun({ outcome: "error", runAt: Date.now(), sourcePath: null, diff --git a/lib/live-account-sync.ts b/lib/live-account-sync.ts index 24ab0da..bae04ee 100644 --- a/lib/live-account-sync.ts +++ b/lib/live-account-sync.ts @@ -1,6 +1,7 @@ import { type FSWatcher, promises as fs, watch as fsWatch } from "node:fs"; import { basename, dirname } from "node:path"; import { createLogger } from "./logger.js"; +import { appendSyncHistoryEntry } from "./sync-history.js"; const log = createLogger("live-account-sync"); @@ -99,7 +100,6 @@ export class LiveAccountSync { private reloadCount = 0; private errorCount = 0; private reloadInFlight: Promise | null = null; - constructor( reload: () => Promise, options: LiveAccountSyncOptions = {}, @@ -194,6 +194,24 @@ export class LiveAccountSync { lastLiveAccountSyncSnapshot = this.getSnapshot(); } + private async recordHistory( + reason: "watch" | "poll" | "manual", + outcome: "success" | "error", + message?: string, + ): Promise { + const snapshot = this.getSnapshot(); + const entry = { + kind: "live-account-sync" as const, + recordedAt: Date.now(), + reason, + outcome, + path: this.currentPath, + message, + snapshot, + }; + await appendSyncHistoryEntry(entry); + } + private scheduleReload(reason: "watch" | "poll"): void { if (!this.running) return; if (this.debounceTimer) { @@ -240,6 +258,7 @@ export class LiveAccountSync { reason, path: summarizeWatchPath(targetPath), }); + await this.recordHistory(reason, "success"); } catch (error) { this.errorCount += 1; log.warn("Live account sync reload failed", { @@ -247,6 +266,11 @@ export class LiveAccountSync { path: summarizeWatchPath(targetPath), error: error instanceof Error ? error.message : String(error), }); + await this.recordHistory( + reason, + "error", + error instanceof Error ? error.message : String(error), + ); } })(); diff --git a/lib/sync-history.ts b/lib/sync-history.ts new file mode 100644 index 0000000..1ac0bd4 --- /dev/null +++ b/lib/sync-history.ts @@ -0,0 +1,210 @@ +import { promises as fs, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CodexCliSyncRun } from "./codex-cli/sync.js"; +import type { LiveAccountSyncSnapshot } from "./live-account-sync.js"; +import { createLogger } from "./logger.js"; +import { getCodexLogDir } from "./runtime-paths.js"; + +const log = createLogger("sync-history"); + +type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; + +export interface CodexCliSyncHistoryEntry { + kind: "codex-cli-sync"; + recordedAt: number; + run: CodexCliSyncRun; +} + +export interface LiveAccountSyncHistoryEntry { + kind: "live-account-sync"; + recordedAt: number; + reason: "watch" | "poll" | "manual"; + outcome: "success" | "error"; + path: string | null; + message?: string; + snapshot: LiveAccountSyncSnapshot; +} + +export type SyncHistoryEntry = + | CodexCliSyncHistoryEntry + | LiveAccountSyncHistoryEntry; + +interface SyncHistoryPaths { + directory: string; + historyPath: string; + latestPath: string; +} + +const HISTORY_FILE_NAME = "sync-history.ndjson"; +const LATEST_FILE_NAME = "sync-history-latest.json"; + +let historyDirOverride: string | null = null; +let historyMutex: Promise = Promise.resolve(); +let lastAppendError: string | null = null; +let lastAppendPaths: SyncHistoryPaths | null = null; +const pendingHistoryWrites = new Set>(); + +async function waitForPendingHistoryWrites(): Promise { + if (pendingHistoryWrites.size === 0) return; + await Promise.allSettled(Array.from(pendingHistoryWrites)); +} + +function getHistoryDirectory(): string { + return historyDirOverride ?? getCodexLogDir(); +} + +export function getSyncHistoryPaths(): SyncHistoryPaths { + const directory = getHistoryDirectory(); + return { + directory, + historyPath: join(directory, HISTORY_FILE_NAME), + latestPath: join(directory, LATEST_FILE_NAME), + }; +} + +function withHistoryLock(fn: () => Promise): Promise { + const previous = historyMutex; + let release: () => void = () => {}; + historyMutex = new Promise((resolve) => { + release = resolve; + }); + return previous.then(fn).finally(() => release()); +} + +async function ensureHistoryDir(directory: string): Promise { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); +} + +function serializeEntry(entry: SyncHistoryEntry): string { + return JSON.stringify(entry); +} + +function cloneEntry(entry: T): T { + if (!entry) return entry; + return JSON.parse(JSON.stringify(entry)) as T; +} + +export async function appendSyncHistoryEntry( + entry: SyncHistoryEntry, +): Promise { + const writePromise = withHistoryLock(async () => { + const paths = getSyncHistoryPaths(); + lastAppendPaths = paths; + await ensureHistoryDir(paths.directory); + + const line = `${serializeEntry(entry)}\n`; + const latestContent = `${JSON.stringify(entry, null, 2)}\n`; + await fs.appendFile(paths.historyPath, line, { + encoding: "utf8", + mode: 0o600, + }); + await fs.writeFile(paths.latestPath, latestContent, { + encoding: "utf8", + mode: 0o600, + }); + lastAppendError = null; + }); + pendingHistoryWrites.add(writePromise); + await writePromise + .catch((error) => { + lastAppendError = error instanceof Error ? error.message : String(error); + log.debug("Failed to append sync history", { + error: lastAppendError, + }); + }) + .finally(() => { + pendingHistoryWrites.delete(writePromise); + }); +} + +export async function readSyncHistory( + options: { limit?: number; kind?: SyncHistoryKind } = {}, +): Promise { + const { limit, kind } = options; + await waitForPendingHistoryWrites(); + try { + const content = await fs.readFile( + getSyncHistoryPaths().historyPath, + "utf8", + ); + const lines = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const parsed = lines.map((line) => JSON.parse(line) as SyncHistoryEntry); + const filtered = kind + ? parsed.filter((entry) => entry.kind === kind) + : parsed; + return typeof limit === "number" && limit > 0 + ? filtered.slice(-limit) + : filtered; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + log.debug("Failed to read sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + return []; + } +} + +export function readLatestSyncHistorySync(): SyncHistoryEntry | null { + try { + const content = readFileSync(getSyncHistoryPaths().latestPath, "utf8"); + return cloneEntry(JSON.parse(content) as SyncHistoryEntry); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== "ENOENT") { + log.debug("Failed to read latest sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + return null; + } +} + +export function configureSyncHistoryForTests(directory: string | null): void { + historyDirOverride = directory ? directory.trim() : null; +} + +export async function __resetSyncHistoryForTests(): Promise { + const paths = getSyncHistoryPaths(); + await waitForPendingHistoryWrites(); + await withHistoryLock(async () => { + for (const target of [paths.historyPath, paths.latestPath]) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(target, { force: true }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, 25 * 2 ** attempt), + ); + } + } + } + }); + lastAppendError = null; + lastAppendPaths = null; +} + +export function cloneSyncHistoryEntry( + entry: SyncHistoryEntry | null, +): SyncHistoryEntry | null { + return cloneEntry(entry); +} + +export function __getLastSyncHistoryErrorForTests(): string | null { + return lastAppendError; +} + +export function __getLastSyncHistoryPathsForTests(): SyncHistoryPaths | null { + return lastAppendPaths ? { ...lastAppendPaths } : null; +} diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 1f7add2..5778d24 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -14,6 +14,11 @@ import { import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; import type { AccountStorageV3 } from "../lib/storage.js"; +import { + __resetSyncHistoryForTests, + configureSyncHistoryForTests, + readSyncHistory, +} from "../lib/sync-history.js"; describe("codex-cli sync", () => { let tempDir: string; @@ -37,6 +42,8 @@ describe("codex-cli sync", () => { accountsPath = join(tempDir, "accounts.json"); authPath = join(tempDir, "auth.json"); configPath = join(tempDir, "config.toml"); + configureSyncHistoryForTests(join(tempDir, "logs")); + await __resetSyncHistoryForTests(); process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; process.env.CODEX_CLI_AUTH_PATH = authPath; process.env.CODEX_CLI_CONFIG_PATH = configPath; @@ -65,6 +72,8 @@ describe("codex-cli sync", () => { process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = previousEnforceFileStore; } + await __resetSyncHistoryForTests(); + configureSyncHistoryForTests(null); await rm(tempDir, { recursive: true, force: true }); }); @@ -832,6 +841,14 @@ describe("codex-cli sync", () => { expect(lastRun?.summary.addedAccountCount).toBe(1); expect(lastRun?.summary.destinationOnlyPreservedCount).toBe(1); expect(lastRun?.summary.selectionChanged).toBe(true); + const history = await readSyncHistory({ kind: "codex-cli-sync" }); + const lastHistory = history.at(-1); + expect(lastHistory?.run.outcome).toBe("changed"); + expect(lastHistory?.run.summary.addedAccountCount).toBe(1); + __resetLastCodexCliSyncRunForTests(); + const persisted = getLastCodexCliSyncRun(); + expect(persisted?.outcome).toBe("changed"); + expect(persisted?.summary.addedAccountCount).toBe(1); }); it("returns current storage when state loading throws", async () => { diff --git a/test/live-account-sync.test.ts b/test/live-account-sync.test.ts index 883997f..375c7fc 100644 --- a/test/live-account-sync.test.ts +++ b/test/live-account-sync.test.ts @@ -1,5 +1,4 @@ import { promises as fs } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -7,6 +6,7 @@ import { getLastLiveAccountSyncSnapshot, LiveAccountSync, } from "../lib/live-account-sync.js"; +import * as syncHistory from "../lib/sync-history.js"; describe("live-account-sync", () => { let workDir = ""; @@ -17,9 +17,12 @@ describe("live-account-sync", () => { vi.setSystemTime(new Date("2026-02-26T12:00:00.000Z")); __resetLastLiveAccountSyncSnapshotForTests(); workDir = join( - tmpdir(), + process.cwd(), + "tmp-live-sync", `codex-live-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); + syncHistory.configureSyncHistoryForTests(join(workDir, "logs")); + await syncHistory.__resetSyncHistoryForTests(); storagePath = join(workDir, "openai-codex-accounts.json"); await fs.mkdir(workDir, { recursive: true }); await fs.writeFile( @@ -30,9 +33,30 @@ describe("live-account-sync", () => { }); afterEach(async () => { + const keepWorkDir = process.env.DEBUG_SYNC_HISTORY === "1"; vi.useRealTimers(); __resetLastLiveAccountSyncSnapshotForTests(); - await fs.rm(workDir, { recursive: true, force: true }); + await syncHistory.__resetSyncHistoryForTests(); + syncHistory.configureSyncHistoryForTests(null); + if (!keepWorkDir) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(workDir, { recursive: true, force: true }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") || + attempt === 4 + ) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, 25 * 2 ** attempt), + ); + } + } + } }); it("publishes watcher state for sync-center status surfaces", async () => { @@ -93,6 +117,7 @@ describe("live-account-sync", () => { const reload = vi.fn(async () => { throw new Error("reload failed"); }); + const appendSpy = vi.spyOn(syncHistory, "appendSyncHistoryEntry"); const sync = new LiveAccountSync(reload, { pollIntervalMs: 500, debounceMs: 50, @@ -116,7 +141,29 @@ describe("live-account-sync", () => { const snapshot = sync.getSnapshot(); expect(snapshot.errorCount).toBeGreaterThan(0); expect(snapshot.reloadCount).toBe(0); + expect(appendSpy).toHaveBeenCalled(); + expect(syncHistory.__getLastSyncHistoryErrorForTests()).toBeNull(); + const paths = syncHistory.getSyncHistoryPaths(); + expect(paths.directory).toBe(join(workDir, "logs")); + const lastAppendPaths = syncHistory.__getLastSyncHistoryPathsForTests(); + expect(lastAppendPaths?.directory).toBe(paths.directory); + const dirExists = await fs + .stat(paths.directory) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); + const content = await fs + .readFile(paths.historyPath, "utf-8") + .catch(() => ""); + expect(content.length).toBeGreaterThan(0); + const history = await syncHistory.readSyncHistory({ + kind: "live-account-sync", + }); + const last = history.at(-1); + expect(last?.outcome).toBe("error"); + expect(["poll", "watch"]).toContain(last?.reason); sync.stop(); + appendSpy.mockRestore(); }); it("stops watching cleanly and prevents further reloads", async () => { @@ -193,6 +240,10 @@ describe("live-account-sync", () => { await Promise.all([first, second]); expect(reload).toHaveBeenCalledTimes(1); + const history = await syncHistory.readSyncHistory({ + kind: "live-account-sync", + }); + expect(history[history.length - 1]?.outcome).toBe("success"); sync.stop(); }); });