diff --git a/lib/cli.ts b/lib/cli.ts index b0a81b35..dd728c9a 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -4,6 +4,7 @@ import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js"; import type { AccountIdSource } from "./types.js"; import { type AccountStatus, + type AuthMenuReadOnlyRow, isTTY, showAccountDetails, showAuthMenu, @@ -94,6 +95,7 @@ export interface ExistingAccountInfo { export interface LoginMenuOptions { flaggedCount?: number; + healthSummary?: AuthMenuReadOnlyRow; statusMessage?: string | (() => string | undefined); } @@ -270,6 +272,7 @@ export async function promptLoginMode( while (true) { const action = await showAuthMenu(existingAccounts, { flaggedCount: options.flaggedCount ?? 0, + healthSummary: options.healthSummary, statusMessage: options.statusMessage, }); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index c959acb8..8cc0c8c6 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { promises as fs, existsSync } from "node:fs"; @@ -90,6 +91,10 @@ import { getCodexCliConfigPath, loadCodexCliState, } from "./codex-cli/state.js"; +import { + getLastCodexCliSyncRun, + getLatestCodexCliSyncRollbackPlan, +} from "./codex-cli/sync.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { confirm } from "./ui/confirm.js"; @@ -3350,6 +3355,17 @@ interface DoctorFixAction { message: string; } +interface DashboardHealthSummary { + label: string; + hint?: string; +} + +interface DoctorReadOnlySummary { + severity: DoctorSeverity; + label: string; + hint: string; +} + function hasPlaceholderEmail(value: string | undefined): boolean { if (!value) return false; const email = value.trim().toLowerCase(); @@ -3392,6 +3408,14 @@ function getDoctorRefreshTokenKey( return trimmed || undefined; } +function getReadOnlyDoctorRefreshTokenFingerprint( + refreshToken: unknown, +): string | undefined { + const token = getDoctorRefreshTokenKey(refreshToken); + if (!token) return undefined; + return createHash("sha256").update(token).digest("hex").slice(0, 12); +} + function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { let changed = false; const actions: DoctorFixAction[] = []; @@ -3925,6 +3949,300 @@ async function runDoctor(args: string[]): Promise { return summary.error > 0 ? 1 : 0; } +function formatCountNoun( + count: number, + singular: string, + plural = `${singular}s`, +): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function summarizeLatestCodexCliSyncState(): { + label: string; + hint: string; +} { + const latestRun = getLastCodexCliSyncRun(); + if (!latestRun) { + return { + label: "none", + hint: "No Codex CLI sync result recorded yet.", + }; + } + + const outcomeLabel = + latestRun.outcome === "changed" + ? "changed" + : latestRun.outcome === "noop" + ? "noop" + : latestRun.outcome === "disabled" + ? "disabled" + : latestRun.outcome === "unavailable" + ? "unavailable" + : "error"; + const when = formatRelativeDateShort(latestRun.runAt); + const counts = [ + latestRun.summary.addedAccountCount > 0 + ? `add ${latestRun.summary.addedAccountCount}` + : null, + latestRun.summary.updatedAccountCount > 0 + ? `update ${latestRun.summary.updatedAccountCount}` + : null, + latestRun.summary.destinationOnlyPreservedCount > 0 + ? `preserve ${latestRun.summary.destinationOnlyPreservedCount}` + : null, + latestRun.summary.selectionChanged ? "selection changed" : null, + ].filter((value): value is string => value !== null); + const hintParts = [ + `Latest sync ${outcomeLabel}${when ? ` ${when}` : ""}`, + counts.length > 0 ? counts.join(" | ") : "no account deltas recorded", + latestRun.message?.trim() || undefined, + ].filter((value): value is string => Boolean(value)); + + return { + label: `${outcomeLabel}${when ? ` ${when}` : ""}`, + hint: hintParts.join(" | "), + }; +} + +function summarizeReadOnlyDoctorState( + storage: AccountStorageV3 | null, +): DoctorReadOnlySummary { + if (!storage || storage.accounts.length === 0) { + return { + severity: "warn", + label: "setup pending", + hint: "No accounts are configured yet.", + }; + } + + const warnings: string[] = []; + const errors: string[] = []; + const rawActiveCandidate = + storage.activeIndexByFamily?.codex ?? storage.activeIndex; + const normalizedActiveCandidate = Number.isFinite(rawActiveCandidate) + ? Math.trunc(rawActiveCandidate) + : Number.NaN; + const activeExists = + normalizedActiveCandidate >= 0 && + normalizedActiveCandidate < storage.accounts.length; + if (!activeExists) { + errors.push("active index is out of range"); + } + + const disabledCount = storage.accounts.filter( + (account) => account.enabled === false, + ).length; + if (disabledCount >= storage.accounts.length) { + errors.push("all accounts are disabled"); + } else if (disabledCount > 0) { + warnings.push(`${disabledCount} disabled`); + } + + const seenRefreshTokens = new Set(); + let duplicateTokenCount = 0; + let placeholderEmailCount = 0; + let likelyInvalidRefreshTokenCount = 0; + for (const account of storage.accounts) { + const token = getReadOnlyDoctorRefreshTokenFingerprint( + account.refreshToken, + ); + if (token) { + if (seenRefreshTokens.has(token)) { + duplicateTokenCount += 1; + } else { + seenRefreshTokens.add(token); + } + } + if (hasPlaceholderEmail(account.email)) { + placeholderEmailCount += 1; + } + if (hasLikelyInvalidRefreshToken(account.refreshToken)) { + likelyInvalidRefreshTokenCount += 1; + } + } + if (duplicateTokenCount > 0) { + warnings.push(formatCountNoun(duplicateTokenCount, "duplicate token")); + } + if (placeholderEmailCount > 0) { + warnings.push(formatCountNoun(placeholderEmailCount, "placeholder email")); + } + if (likelyInvalidRefreshTokenCount > 0) { + warnings.push( + formatCountNoun(likelyInvalidRefreshTokenCount, "invalid refresh token"), + ); + } + + if (errors.length > 0) { + return { + severity: "error", + label: formatCountNoun(errors.length, "error"), + hint: errors.concat(warnings).join(" | "), + }; + } + if (warnings.length > 0) { + return { + severity: "warn", + label: formatCountNoun(warnings.length, "warning"), + hint: warnings.join(" | "), + }; + } + return { + severity: "ok", + label: "ok", + hint: "No read-only doctor issues detected.", + }; +} + +function logLoginMenuHealthSummaryWarning( + context: string, + error: unknown, +): void { + const errorLabel = getRedactedFilesystemErrorLabel(error); + log.warn(`${context} [${errorLabel}]`); +} + +function formatLoginMenuRollbackHint( + rollbackPlan: Awaited>, + rollbackPlanLoadFailed = false, +): string { + if (rollbackPlanLoadFailed) { + return "rollback state unavailable"; + } + if (rollbackPlan.status === "ready") { + const accountCount = rollbackPlan.accountCount; + if ( + typeof accountCount === "number" && + Number.isFinite(accountCount) && + accountCount >= 0 + ) { + return `checkpoint ready for ${Math.trunc(accountCount)} account(s)`; + } + return "checkpoint ready"; + } + const reason = collapseWhitespace(rollbackPlan.reason); + if (reason.toLowerCase().includes("unavailable")) { + return reason; + } + return rollbackPlan.snapshot ? "checkpoint unavailable" : "no rollback checkpoint available"; +} + +async function buildLoginMenuHealthSummary( + storage: AccountStorageV3, +): Promise { + if (storage.accounts.length === 0) { + return null; + } + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; + const disabledCount = storage.accounts.length - enabledCount; + let actionableRestores: Awaited< + ReturnType + > = { + assessments: [], + allAssessments: [], + totalBackups: 0, + }; + let rollbackPlan: Awaited< + ReturnType + > = { + status: "unavailable", + reason: "rollback state unavailable", + snapshot: null, + }; + let rollbackPlanLoadFailed = false; + let restoreStateUnavailable = false; + let syncSummary = { + label: "unknown", + hint: "Sync state unavailable.", + }; + let doctorSummary: DoctorReadOnlySummary = { + severity: "warn", + label: "unknown", + hint: "Doctor state unavailable.", + }; + const [restoresResult, rollbackResult] = await Promise.allSettled([ + getActionableNamedBackupRestores({ + currentStorage: storage, + }), + getLatestCodexCliSyncRollbackPlan(), + ]); + if (restoresResult.status === "fulfilled") { + actionableRestores = restoresResult.value; + } else { + restoreStateUnavailable = true; + logLoginMenuHealthSummaryWarning( + "Failed to load login menu restore health summary state", + restoresResult.reason, + ); + } + if (rollbackResult.status === "fulfilled") { + rollbackPlan = rollbackResult.value; + } else { + rollbackPlanLoadFailed = true; + logLoginMenuHealthSummaryWarning( + "Failed to load login menu rollback health summary state", + rollbackResult.reason, + ); + } + try { + syncSummary = summarizeLatestCodexCliSyncState(); + } catch (error) { + logLoginMenuHealthSummaryWarning( + "Failed to summarize login menu sync state", + error, + ); + } + try { + doctorSummary = summarizeReadOnlyDoctorState(storage); + } catch (error) { + logLoginMenuHealthSummaryWarning( + "Failed to summarize login menu doctor state", + error, + ); + } + const rollbackHint = formatLoginMenuRollbackHint( + rollbackPlan, + rollbackPlanLoadFailed, + ); + const rollbackUnavailable = + rollbackPlanLoadFailed || + rollbackPlan.snapshot !== null || + collapseWhitespace(rollbackPlan.reason).toLowerCase().includes("unavailable"); + const restoreLabel = + restoreStateUnavailable + ? "unavailable" + : actionableRestores.totalBackups > 0 + ? `${actionableRestores.assessments.length}/${actionableRestores.totalBackups} ready` + : "none"; + const rollbackLabel = + rollbackPlan.status === "ready" + ? "ready" + : rollbackUnavailable + ? "unavailable" + : "none"; + const accountLabel = + disabledCount > 0 + ? `${enabledCount}/${storage.accounts.length} enabled` + : `${storage.accounts.length} active`; + const hintParts = [ + `Accounts: ${enabledCount} enabled / ${disabledCount} disabled / ${storage.accounts.length} total`, + `Sync: ${syncSummary.hint}`, + restoreStateUnavailable + ? "Restore backups: state unavailable" + : `Restore backups: ${actionableRestores.assessments.length} actionable of ${actionableRestores.totalBackups} total`, + `Rollback: ${rollbackHint}`, + `Doctor: ${doctorSummary.hint}`, + ]; + + return { + label: + `Pool ${accountLabel} | Sync ${syncSummary.label} | Restore ${restoreLabel} | ` + + `Rollback ${rollbackLabel} | Doctor ${doctorSummary.label}`, + hint: hintParts.join(" | "), + }; +} + async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, @@ -4061,12 +4379,16 @@ async function runAuthLogin(): Promise { }); } } - const flaggedStorage = await loadFlaggedAccounts(); + const [flaggedStorage, healthSummary] = await Promise.all([ + loadFlaggedAccounts(), + buildLoginMenuHealthSummary(currentStorage), + ]); const menuResult = await promptLoginMode( toExistingAccountInfo(currentStorage, quotaCache, displaySettings), { flaggedCount: flaggedStorage.accounts.length, + healthSummary: healthSummary ?? undefined, statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, }, ); diff --git a/lib/sync-history.ts b/lib/sync-history.ts index cc749171..49a9c3fd 100644 --- a/lib/sync-history.ts +++ b/lib/sync-history.ts @@ -11,6 +11,14 @@ const HISTORY_FILE_NAME = "sync-history.ndjson"; const LATEST_FILE_NAME = "sync-history-latest.json"; const MAX_HISTORY_ENTRIES = 200; const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); +const RETRYABLE_LATEST_READ_CODES = new Set(["EBUSY", "EPERM", "EACCES"]); + +function sleepSync(ms: number): void { + if (!Number.isFinite(ms) || ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} type SyncHistoryKind = "codex-cli-sync" | "live-account-sync"; @@ -257,19 +265,30 @@ export async function readSyncHistory( } export function readLatestSyncHistorySync(): SyncHistoryEntry | null { - try { - const content = readFileSync(getSyncHistoryPaths().latestPath, "utf8"); - const parsed = parseEntry(content); - return parsed ? cloneEntry(parsed) : null; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.debug("Failed to read latest sync history", { - error: error instanceof Error ? error.message : String(error), - }); + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + const content = readFileSync(getSyncHistoryPaths().latestPath, "utf8"); + const parsed = parseEntry(content); + return parsed ? cloneEntry(parsed) : null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + code && + RETRYABLE_LATEST_READ_CODES.has(code) && + attempt < 3 + ) { + sleepSync(10 * 2 ** attempt); + continue; + } + if (code !== "ENOENT") { + log.debug("Failed to read latest sync history", { + error: error instanceof Error ? error.message : String(error), + }); + } + return null; } - return null; } + return null; } export function cloneSyncHistoryEntry( diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 5f77ad62..b4cbd07e 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -49,8 +49,14 @@ export interface AccountInfo { statuslineFields?: string[]; } +export interface AuthMenuReadOnlyRow { + label: string; + hint?: string; +} + export interface AuthMenuOptions { flaggedCount?: number; + healthSummary?: AuthMenuReadOnlyRow; statusMessage?: string | (() => string | undefined); } @@ -603,12 +609,32 @@ export async function showAuthMenu( color: flaggedCount > 0 ? "red" : "yellow", }, { label: "", value: { type: "cancel" }, separator: true }, - { - label: UI_COPY.mainMenu.accounts, + ]; + + const healthSummaryLabel = sanitizeTerminalText( + options.healthSummary?.label, + ); + const healthSummaryHint = sanitizeTerminalText(options.healthSummary?.hint); + if (healthSummaryLabel) { + items.push({ + label: UI_COPY.mainMenu.healthSummary, value: { type: "cancel" }, kind: "heading", - }, - ]; + }); + items.push({ + label: healthSummaryLabel, + hint: healthSummaryHint, + value: { type: "cancel" }, + disabled: true, + }); + items.push({ label: "", value: { type: "cancel" }, separator: true }); + } + + items.push({ + label: UI_COPY.mainMenu.accounts, + value: { type: "cancel" }, + kind: "heading", + }); if (visibleAccounts.length === 0) { items.push({ diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 937a0b01..b4fcebad 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -9,6 +9,7 @@ export const UI_COPY = { fixIssues: "Auto-Repair Issues", settings: "Settings", moreChecks: "Advanced Checks", + healthSummary: "Health Summary", refreshChecks: "Refresh All Accounts", checkFlagged: "Check Problem Accounts", accounts: "Saved Accounts", diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ddfa757c..41e6de79 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -61,12 +61,13 @@ const detectOcChatgptMultiAuthTargetMock = vi.fn(); const normalizeAccountStorageMock = vi.fn((value) => value); const withAccountStorageTransactionMock = vi.fn(); const withAccountAndFlaggedStorageTransactionMock = vi.fn(); +const loggerWarnMock = vi.fn(); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ debug: vi.fn(), info: vi.fn(), - warn: vi.fn(), + warn: loggerWarnMock, error: vi.fn(), })), logWarn: vi.fn(), @@ -3625,6 +3626,9 @@ describe("codex manager cli commands", () => { expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(promptLoginModeMock).toHaveBeenCalledTimes(1); expect(promptLoginModeMock.mock.calls[0]?.[0]).toEqual([]); + expect(promptLoginModeMock.mock.calls[0]?.[1]).toMatchObject({ + healthSummary: undefined, + }); expect(confirmMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock.mock.calls[0]?.[1]).toMatchObject({ @@ -3970,7 +3974,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(confirmMock).not.toHaveBeenCalled(); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); expect(restoreNamedBackupMock).not.toHaveBeenCalled(); @@ -4264,7 +4268,7 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(action).toHaveBeenCalledTimes(1); - expect(getActionableNamedBackupRestoresMock).not.toHaveBeenCalled(); + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); }, ); @@ -4414,6 +4418,390 @@ describe("codex manager cli commands", () => { expect(promptLoginModeMock).toHaveBeenCalledTimes(2); }); + it("passes a read-only health summary into the login menu", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "enabled@example.com", + accountId: "acc_enabled", + refreshToken: "refresh-enabled", + accessToken: "access-enabled", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "disabled@example.com", + accountId: "acc_disabled", + refreshToken: "refresh-enabled", + accessToken: "access-disabled", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + getActionableNamedBackupRestoresMock.mockResolvedValue({ + assessments: [{}], + totalBackups: 2, + }); + getLastCodexCliSyncRunMock.mockReturnValue({ + outcome: "changed", + runAt: now, + sourcePath: "/mock/codex/accounts.json", + targetPath: "/mock/openai-codex-accounts.json", + summary: { + sourceAccountCount: 2, + targetAccountCountBefore: 2, + targetAccountCountAfter: 2, + addedAccountCount: 1, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: true, + }, + trigger: "manual", + rollbackSnapshot: { + name: "sync-snapshot", + path: "/mock/backups/sync-snapshot.json", + }, + }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValue({ + status: "ready", + reason: "Rollback checkpoint ready (2 accounts).", + snapshot: { + name: "sync-snapshot", + path: "/mock/backups/sync-snapshot.json", + }, + accountCount: 2, + storage: { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [], + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(1); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: expect.objectContaining({ + label: expect.stringMatching( + /Pool 1\/2 enabled[\s\S]*Sync changed[\s\S]*Restore 1\/2 ready[\s\S]*Rollback ready[\s\S]*Doctor 4 warnings/, + ), + hint: expect.stringMatching( + /Sync: Latest sync changed today \| add 1 \| selection changed[\s\S]*Restore backups: 1 actionable of 2 total[\s\S]*Rollback: checkpoint ready for 2 account\(s\)[\s\S]*Doctor: 1 disabled \| 1 duplicate token \| 2 placeholder emails \| 2 invalid refresh tokens/, + ), + }), + }), + ); + }); + + it("falls back to a safe health summary when restore or rollback state reads fail", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + getActionableNamedBackupRestoresMock.mockRejectedValue( + makeErrnoError("resource busy", "EBUSY"), + ); + const rollbackBusyError = makeErrnoError("resource busy", "EBUSY"); + rollbackBusyError.path = "C:\\sensitive\\rollback.json"; + getLatestCodexCliSyncRollbackPlanMock.mockRejectedValue(rollbackBusyError); + getLastCodexCliSyncRunMock.mockImplementation(() => { + const syncBusyError = makeErrnoError("resource busy", "EBUSY"); + syncBusyError.path = "C:\\sensitive\\sync-history.json"; + throw syncBusyError; + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + const warningOutput = loggerWarnMock.mock.calls.flat().join("\n"); + + expect(exitCode).toBe(0); + expect(loggerWarnMock).toHaveBeenCalled(); + expect(warningOutput).toContain( + "Failed to load login menu rollback health summary state", + ); + expect(warningOutput).toContain("EBUSY"); + expect(warningOutput).not.toContain("C:\\sensitive\\rollback.json"); + expect(warningOutput).not.toContain("C:\\sensitive\\sync-history.json"); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: expect.objectContaining({ + label: expect.stringMatching( + /Pool 1 active[\s\S]*Sync unknown[\s\S]*Restore unavailable[\s\S]*Rollback unavailable[\s\S]*Doctor 2 warnings/, + ), + hint: expect.stringMatching( + /Restore backups: state unavailable[\s\S]*Rollback: rollback state unavailable[\s\S]*Doctor: 1 placeholder email \| 1 invalid refresh token/, + ), + }), + }), + ); + }); + + it("surfaces rollback unavailable results instead of collapsing them into none", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "rollback-unavailable@example.com", + refreshToken: "refresh-rollback-unavailable", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + getLatestCodexCliSyncRollbackPlanMock.mockResolvedValue({ + status: "unavailable", + reason: " rollback state unavailable due to filesystem lock ", + snapshot: null, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: expect.objectContaining({ + label: expect.stringMatching(/Rollback unavailable/), + hint: expect.stringMatching( + /Rollback: rollback state unavailable due to filesystem lock/, + ), + }), + }), + ); + }); + + it("shows sync none when no Codex CLI sync result has been recorded yet", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync-none@example.com", + refreshToken: "refresh-none", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + getLastCodexCliSyncRunMock.mockReturnValue(null); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: expect.objectContaining({ + label: expect.stringContaining("Sync none"), + hint: expect.stringContaining( + "Sync: No Codex CLI sync result recorded yet.", + ), + }), + }), + ); + }); + + it("surfaces unavailable sync results with trimmed message details", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "sync-unavailable@example.com", + refreshToken: "refresh-unavailable", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + getLastCodexCliSyncRunMock.mockReturnValue({ + outcome: "unavailable", + runAt: Date.now(), + sourcePath: "source.json", + targetPath: "target.json", + message: " blocked by filesystem lock ", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 1, + unchangedAccountCount: 0, + destinationOnlyPreservedCount: 2, + selectionChanged: false, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: expect.objectContaining({ + label: expect.stringMatching(/Sync unavailable today/), + hint: expect.stringMatching( + /Sync: Latest sync unavailable today \| update 1 \| preserve 2 \| blocked by filesystem lock/, + ), + }), + }), + ); + }); + + it("omits the health summary and skips health-summary I/O when no accounts are saved", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + // The single restore scan comes from the startup recovery prompt guard, not the health summary path. + expect(getActionableNamedBackupRestoresMock).toHaveBeenCalledTimes(1); + expect(getLatestCodexCliSyncRollbackPlanMock).not.toHaveBeenCalled(); + expect(getLastCodexCliSyncRunMock).not.toHaveBeenCalled(); + expect(promptLoginModeMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + healthSummary: undefined, + }), + ); + }); + + it("renders health summary as a disabled dashboard row", async () => { + selectMock.mockResolvedValueOnce({ type: "cancel" }); + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + + await showAuthMenu( + [ + { + index: 0, + email: "a@example.com", + isCurrentAccount: true, + }, + ], + { + healthSummary: { + label: + "Pool 1 active | Sync none | Restore none | Rollback none | Doctor ok", + hint: "Accounts: 1 enabled / 0 disabled / 1 total", + }, + }, + ); + + const items = selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + disabled?: boolean; + hint?: string; + kind?: string; + }>; + const headingIndex = items.findIndex( + (item) => item.label === "Health Summary" && item.kind === "heading", + ); + + expect(headingIndex).toBeGreaterThan(-1); + expect(items[headingIndex + 1]).toEqual( + expect.objectContaining({ + label: + "Pool 1 active | Sync none | Restore none | Rollback none | Doctor ok", + disabled: true, + hint: "Accounts: 1 enabled / 0 disabled / 1 total", + }), + ); + }); + + it("sanitizes control characters in the health summary row", async () => { + selectMock.mockResolvedValueOnce({ type: "cancel" }); + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + + await showAuthMenu( + [ + { + index: 0, + email: "a@example.com", + isCurrentAccount: true, + }, + ], + { + healthSummary: { + label: + "Pool\x1b[31m 1 active\n| Sync none\x00 | Restore none | Rollback none | Doctor ok", + hint: + "Accounts:\x1b[32m 1 enabled / 0 disabled / 1 total\nRollback:\x00 checkpoint ready", + }, + }, + ); + + const items = selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + disabled?: boolean; + hint?: string; + kind?: string; + }>; + const row = items.find( + (item) => + item.kind !== "heading" && + item.label.includes("Pool 1 active") && + item.label.includes("Doctor ok"), + ); + + expect(row).toEqual( + expect.objectContaining({ + label: + "Pool 1 active| Sync none | Restore none | Rollback none | Doctor ok", + hint: "Accounts: 1 enabled / 0 disabled / 1 totalRollback: checkpoint ready", + disabled: true, + }), + ); + expect(row?.label).not.toContain("\x1b"); + expect(row?.label).not.toContain("\n"); + expect(row?.label).not.toContain("\x00"); + expect(row?.hint).not.toContain("\x1b"); + expect(row?.hint).not.toContain("\n"); + expect(row?.hint).not.toContain("\x00"); + }); + it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { const now = Date.now(); const storage = { diff --git a/test/sync-history.test.ts b/test/sync-history.test.ts index b03b65ae..71f02a5f 100644 --- a/test/sync-history.test.ts +++ b/test/sync-history.test.ts @@ -175,6 +175,64 @@ describe("sync history", () => { }); }); + it("retries transient EBUSY errors when reading the latest sync snapshot", async () => { + await appendSyncHistoryEntry({ + kind: "codex-cli-sync", + recordedAt: 21, + run: { + outcome: "disabled", + runAt: 21, + sourcePath: "source.json", + targetPath: "target.json", + message: " disabled by policy ", + summary: { + sourceAccountCount: 1, + targetAccountCountBefore: 1, + targetAccountCountAfter: 1, + addedAccountCount: 0, + updatedAccountCount: 0, + unchangedAccountCount: 1, + destinationOnlyPreservedCount: 0, + selectionChanged: false, + }, + }, + }); + + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + let firstRead = true; + return { + ...actual, + readFileSync: vi.fn((...args: Parameters) => { + if (firstRead) { + firstRead = false; + const busyError = new Error("busy") as NodeJS.ErrnoException; + busyError.code = "EBUSY"; + throw busyError; + } + return actual.readFileSync(...args); + }), + }; + }); + + try { + const isolatedSyncHistoryModule = await import("../lib/sync-history.js"); + isolatedSyncHistoryModule.configureSyncHistoryForTests(join(workDir, "logs")); + expect(isolatedSyncHistoryModule.readLatestSyncHistorySync()).toMatchObject({ + kind: "codex-cli-sync", + recordedAt: 21, + run: expect.objectContaining({ + outcome: "disabled", + message: " disabled by policy ", + }), + }); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + }); + it("waits for writes queued while history reads are draining", async () => { let releaseFirstAppend!: () => void; let releaseSecondAppend!: () => void;