Skip to content

fix(ui): harden dashboard health summary#101

Open
ndycode wants to merge 28 commits intodevfrom
fresh/17-health-summary-dashboard
Open

fix(ui): harden dashboard health summary#101
ndycode wants to merge 28 commits intodevfrom
fresh/17-health-summary-dashboard

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Stacked PR on fresh/16-retention-doctor
  • Reopens the preserved health-summary cleanup from archived PR #81

What Changed

  • Tightens the dashboard health summary so restore, rollback, sync, and doctor state stay accurate and readable without widening the surrounding retention and recovery scope.
  • Keeps the change focused to the login/dashboard summary path and its matching CLI coverage.

Validation

  • npm run lint
  • npm run typecheck
  • npm test
  • npm test -- test/documentation.test.ts
  • npm run build

Docs and Governance Checklist

  • README updated (if user-visible behavior changed)
  • docs/getting-started.md updated (if onboarding flow changed)
  • docs/features.md updated (if capability surface changed)
  • relevant docs/reference/* pages updated (if commands/settings/paths changed)
  • docs/upgrade.md updated (if migration behavior changed)
  • SECURITY.md and CONTRIBUTING.md reviewed for alignment

Risk and Rollback

  • Risk level: low
  • Rollback plan: revert 6decea7

Additional Notes

  • Validation above was rerun on the current stacked head fresh/19-first-run-wizard, which contains this branch unchanged plus later stacked commits.
  • This PR is intended to stay reviewable on top of fresh/16-retention-doctor, not main.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr re-opens the preserved health-summary cleanup from #81, scoped to the dashboard and its supporting rollback/snapshot infrastructure. the core additions are: a buildLoginMenuHealthSummary that assembles pool/sync/restore/rollback/doctor state into a single dashboard row, a rollback snapshot workflow tied to manual codex-cli syncs, automatic snapshot capture before destructive operations, and a refactored sync-history trim pipeline.

previously flagged issues that are now addressed:

  • buildLoginMenuHealthSummary now wraps each of the four state sources in its own independent try/catch — async I/O failures no longer suppress the in-memory summarizeReadOnlyDoctorState result
  • zero-account storage returns null immediately, skipping all async health-summary I/O on fresh installs
  • raw refresh tokens are no longer stored in seenRefreshTokens; getReadOnlyDoctorRefreshTokenFingerprint hashes with SHA-256 and keeps only the first 12 hex chars — reduces token-leakage surface on windows heap dumps
  • rollback hint assertion in tests now correctly matches formatLoginMenuRollbackHint's actual output format

remaining items worth tracking:

  • pruneAutoGeneratedSnapshots defaults keepLatestPerReason to 1, while ACCOUNT_SNAPSHOT_RETENTION_PER_REASON exports 3; rollback-referenced snapshots are protected by getLatestManualCodexCliRollbackSnapshotNames so there's no current data-loss path, but the inconsistency is a caller footgun — see comment in lib/storage.ts
  • getSyncCenterRollbackPlan and runSyncCenterRollback in settings-hub.ts guard statically-imported functions with typeof checks, producing unreachable fallback branches — see comment in lib/codex-manager/settings-hub.ts
  • rollbackLatestCodexCliSync ignores the current on-disk state passed by withAccountStorageTransaction; the settings-hub path is serialised via withQueuedRetry, but the cli rollbackLastCodexCliSync path carries the pre-existing toctou race on windows

Confidence Score: 3/5

  • safe to merge with low risk — no critical bugs introduced; previously flagged issues are addressed; remaining items are style-level inconsistencies and a pre-existing toctou on the cli rollback path
  • all four previously critical issues (try/catch isolation, token fingerprinting, zero-account io guard, rollback hint assertion) are resolved. the remaining open items are: inconsistent default in pruneAutoGeneratedSnapshots, dead code from typeof guards in settings-hub.ts, and the pre-existing cli toctou on windows. none block correctness of the dashboard health summary path, which is the stated scope. test coverage is thorough across all new code paths including transient io retries and concurrent write scenarios.
  • lib/storage.ts (retention default inconsistency) and lib/codex-manager/settings-hub.ts (dead fallback branches in rollback helpers)

Important Files Changed

Filename Overview
lib/codex-cli/sync.ts adds rollback snapshot capture on manual syncs, CodexCliSyncRollbackPlan / CodexCliSyncRollbackResult types, getLatestCodexCliSyncRollbackPlan, rollbackLatestCodexCliSync, and retry logic for transient windows I/O; _current is intentionally unused in the transaction handler (documented risk)
lib/codex-manager.ts adds buildLoginMenuHealthSummary with four independent try/catch blocks, SHA-256 token fingerprinting for read-only doctor deduplication, new checks for WAL, rotating backups, snapshot backups, recovery chain, rollback checkpoint, and named backup restores in runDoctor
lib/storage.ts adds snapshotAccountStorage, pruneAutoGeneratedSnapshots, enforceSnapshotRetention, and snapshot name utilities; pruneAutoGeneratedSnapshots default keepLatestPerReason is 1 but ACCOUNT_SNAPSHOT_RETENTION_PER_REASON exports 3, creating a caller-visible inconsistency
lib/sync-history.ts refactors trimHistoryFileIfNeeded to return PrunedSyncHistory, adds pruneSyncHistoryEntries (pure function keeping latest-per-kind even when cap is exceeded), loadHistoryEntriesFromDisk, rewriteLatestEntry, parseHistoryContent, and public pruneSyncHistory; pendingHistoryWrites type widened to Set<Promise<unknown>>
lib/codex-manager/settings-hub.ts adds rollback action to sync center with getSyncCenterRollbackPlan / runSyncCenterRollback helpers; typeof guards on statically imported functions produce dead code in both fallback branches

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[manual sync trigger\napplyCodexCliSyncToStorage] --> B{trigger === manual\nAND changed?}
    B -- no --> C[createSyncRun\nrollbackSnapshot: null]
    B -- yes --> D[captureRollbackSnapshot\nretryTransientRollbackIo]
    D -- EBUSY/EAGAIN retry --> D
    D -- success --> E[NamedBackupMetadata\nname + path]
    D -- all retries fail --> F[log.warn\nreturn null]
    E --> G[createSyncRun\nrollbackSnapshot: snapshot]
    F --> C

    G --> H[commitPendingCodexCliSyncRun\nappends to sync history]
    C --> H

    H --> I[getLatestCodexCliSyncRollbackPlan\nscans history for latest manual changed run]
    I --> J{snapshot in\nhistory?}
    J -- no --> K[status: unavailable\nno checkpoint]
    J -- yes --> L[loadRollbackSnapshot\nretryTransientRollbackIo readFile]
    L -- ENOENT --> M[status: unavailable\nmissing at path]
    L -- invalid JSON --> N[status: unavailable\ninvalid/empty]
    L -- success --> O[status: ready\naccountCount + storage]

    O --> P[rollbackLatestCodexCliSync\nwithAccountStorageTransaction]
    K --> Q[return unavailable\nto caller]
    M --> Q
    P --> R{persist\nsnapshot.storage}
    R -- success --> S[status: restored]
    R -- error --> T[status: error]

    style D fill:#f9f,stroke:#333
    style L fill:#f9f,stroke:#333
    style P fill:#bbf,stroke:#333
Loading

Comments Outside Diff (4)

  1. lib/codex-manager/settings-hub.ts, line 956 (link)

    P1 unguarded getSyncCenterRollbackPlan crashes sync center on EBUSY

    getSyncCenterRollbackPlan() calls getLatestCodexCliSyncRollbackPlan(), which reads the sync-history file from disk. on windows, antivirus or rotation holds can produce EBUSY / EPERM from that read. there is no try/catch around any of the six getSyncCenterRollbackPlan() call sites in promptSyncCenter — the initial one before the loop and all five refreshes inside it. a single EBUSY anywhere crashes the entire sync-center UI, leaving the user unable to apply or rollback.

    buildLoginMenuHealthSummary wraps every equivalent call in an individual try/catch. the sync-center path needs the same treatment. a minimal fix wraps each refresh in a guard that falls back to the last-known plan:

    // before the loop
    let rollbackPlan: CodexCliSyncRollbackPlan = {
      status: "unavailable",
      reason: "Rollback checkpoint is unavailable.",
      snapshot: null,
    };
    try {
      rollbackPlan = await getSyncCenterRollbackPlan();
    } catch {
      // EBUSY or similar — proceed with unavailable plan
    }

    the same pattern should be applied to every rollbackPlan = await getSyncCenterRollbackPlan() inside the loop (after refresh, apply, and rollback actions). missing vitest coverage for the throwing-getSyncCenterRollbackPlan path in the sync-center loop — worth adding a test that mocks the function to reject and asserts the loop continues rather than crashes.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager/settings-hub.ts
    Line: 956
    
    Comment:
    **unguarded `getSyncCenterRollbackPlan` crashes sync center on EBUSY**
    
    `getSyncCenterRollbackPlan()` calls `getLatestCodexCliSyncRollbackPlan()`, which reads the sync-history file from disk. on windows, antivirus or rotation holds can produce `EBUSY` / `EPERM` from that read. there is no try/catch around any of the six `getSyncCenterRollbackPlan()` call sites in `promptSyncCenter` — the initial one before the loop and all five refreshes inside it. a single `EBUSY` anywhere crashes the entire sync-center UI, leaving the user unable to apply or rollback.
    
    `buildLoginMenuHealthSummary` wraps every equivalent call in an individual try/catch. the sync-center path needs the same treatment. a minimal fix wraps each refresh in a guard that falls back to the last-known plan:
    
    ```typescript
    // before the loop
    let rollbackPlan: CodexCliSyncRollbackPlan = {
      status: "unavailable",
      reason: "Rollback checkpoint is unavailable.",
      snapshot: null,
    };
    try {
      rollbackPlan = await getSyncCenterRollbackPlan();
    } catch {
      // EBUSY or similar — proceed with unavailable plan
    }
    ```
    
    the same pattern should be applied to every `rollbackPlan = await getSyncCenterRollbackPlan()` inside the loop (after refresh, apply, and rollback actions). missing vitest coverage for the throwing-`getSyncCenterRollbackPlan` path in the sync-center loop — worth adding a test that mocks the function to reject and asserts the loop continues rather than crashes.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/storage.ts, line 1334-1337 (link)

    P1 loadAccounts() inside snapshotAccountStorage bypasses failurePolicy = "warn"

    snapshotAccountStorage calls loadAccounts() unconditionally before attempting the backup. failurePolicy only guards the createBackup step — if loadAccounts() throws (e.g., windows EBUSY from an antivirus hold on the storage file), the error propagates directly to the caller regardless of failurePolicy.

    callers in destructive-actions.ts pass no failurePolicy, so they get the default "warn". they expect a failed snapshot to log-and-continue, not to abort the destructive action entirely. an EBUSY on loadAccounts() would do the latter.

    deleteAccountAtIndex already holds storage in memory; deleteSavedAccounts / resetLocalState could accept it similarly. accepting an optional pre-loaded storage avoids the extra disk read and the unguarded throw:

    export async function snapshotAccountStorage(
      options: AccountSnapshotOptions & { currentStorage?: AccountStorageV3 | null },
    ): Promise<NamedBackupMetadata | null> {
      const { reason, now = Date.now(), force = true, failurePolicy = "warn", createBackup = createNamedBackup, currentStorage: injectedStorage } = options;
      let currentStorage: AccountStorageV3 | null;
      try {
        currentStorage = injectedStorage !== undefined ? injectedStorage : await loadAccounts();
      } catch (error) {
        if (failurePolicy === "error") throw error;
        log.warn("Failed to load storage for snapshot", { reason, error: formatSnapshotErrorForLog(error) });
        return null;
      }
      if (!currentStorage || currentStorage.accounts.length === 0) return null;
      ...
    }

    this also removes the redundant disk read in deleteAccountAtIndex, which matters on windows where every extra file open is an EBUSY candidate.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/storage.ts
    Line: 1334-1337
    
    Comment:
    **`loadAccounts()` inside `snapshotAccountStorage` bypasses `failurePolicy = "warn"`**
    
    `snapshotAccountStorage` calls `loadAccounts()` unconditionally before attempting the backup. `failurePolicy` only guards the `createBackup` step — if `loadAccounts()` throws (e.g., windows `EBUSY` from an antivirus hold on the storage file), the error propagates directly to the caller regardless of `failurePolicy`.
    
    callers in `destructive-actions.ts` pass no `failurePolicy`, so they get the default `"warn"`. they expect a failed snapshot to log-and-continue, not to abort the destructive action entirely. an `EBUSY` on `loadAccounts()` would do the latter.
    
    `deleteAccountAtIndex` already holds `storage` in memory; `deleteSavedAccounts` / `resetLocalState` could accept it similarly. accepting an optional pre-loaded storage avoids the extra disk read and the unguarded throw:
    
    ```typescript
    export async function snapshotAccountStorage(
      options: AccountSnapshotOptions & { currentStorage?: AccountStorageV3 | null },
    ): Promise<NamedBackupMetadata | null> {
      const { reason, now = Date.now(), force = true, failurePolicy = "warn", createBackup = createNamedBackup, currentStorage: injectedStorage } = options;
      let currentStorage: AccountStorageV3 | null;
      try {
        currentStorage = injectedStorage !== undefined ? injectedStorage : await loadAccounts();
      } catch (error) {
        if (failurePolicy === "error") throw error;
        log.warn("Failed to load storage for snapshot", { reason, error: formatSnapshotErrorForLog(error) });
        return null;
      }
      if (!currentStorage || currentStorage.accounts.length === 0) return null;
      ...
    }
    ```
    
    this also removes the redundant disk read in `deleteAccountAtIndex`, which matters on windows where every extra file open is an `EBUSY` candidate.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  3. lib/sync-history.ts, line 353-365 (link)

    pruneSyncHistory resets lastAppendError unconditionally

    lastAppendError = null on line 361 clears the module-level error flag that tracks append failures. pruneSyncHistory is a standalone maintenance operation — clearing an unrelated pending append error here masks failures from any caller that checks lastAppendError after a previous failed appendSyncHistoryEntry. the prune should not own that flag.

  4. lib/storage.ts, line 2254-2256 (link)

    break silently drops protection for the previous valid checkpoint

    when the latest manual+changed run has rollbackSnapshot: null (failed snapshot capture), this break stops the scan entirely. any snapshot from an earlier successful manual sync is no longer added to preserveNames, so once the codex-cli-sync per-reason bucket exceeds ACCOUNT_SNAPSHOT_RETENTION_PER_REASON (3), that older snapshot gets pruned. the user ends up with getLatestCodexCliSyncRollbackPlan() returning unavailable for the most recent run and the prior valid checkpoint deleted from disk.

    continue is the safer choice — skip the no-snapshot run and keep scanning backwards for the nearest older run that does have a snapshot name to protect:

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 1307-1310

Comment:
**`pruneAutoGeneratedSnapshots` default diverges from `ACCOUNT_SNAPSHOT_RETENTION_PER_REASON`**

the exported constant `ACCOUNT_SNAPSHOT_RETENTION_PER_REASON = 3` establishes the module's declared retention policy, but the default for `keepLatestPerReason` inside `pruneAutoGeneratedSnapshots` is hardcoded to `1`. `enforceSnapshotRetention` correctly passes the constant, so the production snapshot path keeps 3 per reason. but any direct caller — including tests and future callers — that invokes `pruneAutoGeneratedSnapshots()` without options will silently keep only 1 per reason, applying a more aggressive policy than declared.

rollback-referenced snapshots are protected by `getLatestManualCodexCliRollbackSnapshotNames`, so there is no current data-loss path — but the inconsistency is a footgun. aligning the default with the constant makes the api self-consistent:

```suggestion
	const keepLatestPerReason = Math.max(
		1,
		options.keepLatestPerReason ?? ACCOUNT_SNAPSHOT_RETENTION_PER_REASON,
	);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/codex-manager/settings-hub.ts
Line: 975-1007

Comment:
**unreachable fallback branches from `typeof` guards on statically imported functions**

`getSyncCenterRollbackPlan` and `runSyncCenterRollback` both guard their primary code path with `typeof codexCliSyncModule.getLatestCodexCliSyncRollbackPlan === "function"`. since `codexCliSyncModule` is a static `import * as` at the top of the file, these properties will always be functions when the module loads. the `else` / final `return` fallbacks are dead code that can never be reached.

the `import * as codexCliSyncModule` pattern was likely added to support vitest module spying, which is a valid goal. but the `typeof` guard adds noise and means the duplicate fallback object literals have to be kept in sync with the real return type. a simpler, equivalent form:

```typescript
async function getSyncCenterRollbackPlan(): Promise<CodexCliSyncRollbackPlan> {
    try {
        return await codexCliSyncModule.getLatestCodexCliSyncRollbackPlan();
    } catch {
        return {
            status: "unavailable",
            reason: "Rollback checkpoint is unavailable.",
            snapshot: null,
        };
    }
}

async function runSyncCenterRollback(
    plan: CodexCliSyncRollbackPlan,
): Promise<CodexCliSyncRollbackResult> {
    return codexCliSyncModule.rollbackLatestCodexCliSync(plan);
}
```

this keeps the spy-friendly `codexCliSyncModule` indirection while removing the unreachable branches.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/codex-cli/sync.ts
Line: 313-346

Comment:
**`rollbackLatestCodexCliSync` ignores `_current` storage from transaction**

`withAccountStorageTransaction` passes the current on-disk storage as the first argument, but the handler signature is `async (_current, persist)` — the current state is intentionally discarded. the rollback unconditionally persists the snapshot's storage, silently overwriting any changes made to disk between when the plan was resolved and when `persist()` fires.

in the `settings-hub.ts` path this is serialized via `withQueuedRetry`, so the window is narrow. the cli `rollbackLastCodexCliSync` path has no such wrapper (as noted in a prior thread), so the race is live there. consider at minimum adding a comment that `_current` is intentionally unused and explaining the windows concurrent-write risk, so future readers understand why the pattern looks like it ignores fresh state.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix(sync): keep sync..."

Context used:

  • Context used - speak in lowercase, concise sentences. act like th... (source)

Copilot AI review requested due to automatic review settings March 16, 2026 08:47
@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

health summary is assembled from storage and sync/doctor state and passed into the multi-account login menu as an optional read-only row. changes thread healthSummary through lib/cli.ts:line, lib/codex-manager.ts:line, and lib/ui/auth-menu.ts:line, and add tests in test/codex-manager-cli.test.ts:line.

Changes

Cohort / File(s) Summary
auth UI & copy
lib/ui/auth-menu.ts, lib/ui/copy.ts
added AuthMenuReadOnlyRow and healthSummary?: AuthMenuReadOnlyRow to auth menu options; sanitize and render a disabled health-summary row before accounts; added UI_COPY.mainMenu.healthSummary (lib/ui/auth-menu.ts:line, lib/ui/copy.ts:line).
cli option threading
lib/cli.ts
added optional healthSummary to LoginMenuOptions and threaded it into promptLoginMode (lib/cli.ts:line).
health aggregation & helpers
lib/codex-manager.ts
introduced DashboardHealthSummary/doctor summary types, fingerprint helper, sync/doctor/rollback summarizers, guarded buildLoginMenuHealthSummary, and logging helpers; integrated health-summary assembly into login/startup flows with try/catch resilience (lib/codex-manager.ts:line).
codex-cli sync (rollback)
lib/codex-cli/sync.ts
added rollback modeling, snapshot capture, plan/load/execute helpers; extended CodexCliSyncRun with trigger and rollbackSnapshot; exported getLatestCodexCliSyncRollbackPlan, rollbackLatestCodexCliSync, rollbackLastCodexCliSync; propagate trigger and capture snapshot on manual applies (lib/codex-cli/sync.ts:line).
storage snapshots & pruning
lib/storage.ts
added account snapshot naming, snapshotAccountStorage, listAccountSnapshots, pruning (pruneAutoGeneratedSnapshots), retention constants and helpers; snapshot call added pre-import (lib/storage.ts:line).
sync history pruning
lib/sync-history.ts
added pruneSyncHistory, PrunedSyncHistory, consolidated pruning and latest-entry maintenance, refactored append/read flows (lib/sync-history.ts:line).
settings hub rollback UI
lib/codex-manager/settings-hub.ts
added sync center rollback action, plan retrieval, runRollback plumbing, UI integration (option, hint, shortcut), and plan refresh after relevant actions (lib/codex-manager/settings-hub.ts:line).
tests & test wiring
test/codex-manager-cli.test.ts, test/codex-cli-sync.test.ts, test/storage.test.ts, test/sync-history.test.ts
added/expanded tests and mocks for healthSummary plumbing, rollback planning/execution, snapshot behavior, and pruning; exported new test shims and storage APIs used by tests (test/codex-manager-cli.test.ts:line, test/codex-cli-sync.test.ts:line, test/storage.test.ts:line, test/sync-history.test.ts:line).

Sequence Diagram

sequenceDiagram
    participant client as client
    participant cli as lib/cli.ts
    participant codex as lib/codex-manager.ts
    participant storage as storage
    participant ui as lib/ui/auth-menu.ts

    client->>cli: start login flow
    cli->>codex: runAuthLogin()
    codex->>storage: read sync / doctor / backups / rollback state
    storage-->>codex: return states
    note over codex: buildLoginMenuHealthSummary() aggregates states
    codex->>cli: promptLoginMode(options + healthSummary)
    cli->>ui: showAuthMenu(healthSummary)
    ui-->>client: render health summary row + accounts
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

suggested labels

feature, test, rollback

notes and review flags:

  • missing regression tests: add an explicit regression test that verifies normal login proceeds unchanged when buildLoginMenuHealthSummary throws (lib/codex-manager.ts:line, test/codex-manager-cli.test.ts:line).
  • windows edge cases: review path handling and fingerprinting logic for refresh-token fingerprinting and snapshot path redaction (lib/codex-manager.ts:line, lib/storage.ts:line) for windows path normalization and path-separator behavior.
  • concurrency risks: inspect concurrent reads/writes during snapshot capture, rollback planning, and health-summary assembly (lib/codex-cli/sync.ts:line, lib/storage.ts:line, lib/codex-manager.ts:line) to ensure no race conditions when storage mutates during capture or pruning.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed title accurately summarizes the main change: hardening the dashboard health summary UI component, which is the unifying theme across all file modifications in this PR.
Description check ✅ Passed PR description covers summary, what changed, validation status (all checks marked passed), and risk/rollback plan. However, Docs and Governance Checklist items are unchecked with no justification provided.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fresh/17-health-summary-dashboard
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/17-health-summary-dashboard
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens the interactive login/dashboard “health summary” so sync/restore/rollback/doctor state can be surfaced in the auth menu without expanding retention/recovery logic.

Changes:

  • Adds a read-only “Health Summary” row to the auth menu UI and copy.
  • Builds a consolidated health-summary string during auth login from sync, restore, rollback, and read-only doctor signals.
  • Updates/extends CLI tests to cover the new health-summary behavior and updated restore checks.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/codex-manager-cli.test.ts Updates expectations and adds coverage for the new login menu health summary and UI rendering.
lib/ui/copy.ts Adds menu copy for the new “Health Summary” section.
lib/ui/auth-menu.ts Extends auth menu options and renders the read-only health summary row (sanitized, disabled).
lib/codex-manager.ts Implements health summary aggregation for login menu, including sync/restore/rollback/doctor summaries.
lib/cli.ts Plumbs the new optional health summary option from CLI prompt into the auth menu.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +4005 to +4008
const activeIndex = resolveActiveIndex(storage, "codex");
const activeExists =
activeIndex >= 0 && activeIndex < storage.accounts.length;
if (!activeExists) {
Comment on lines +4025 to +4038
for (const account of storage.accounts) {
const token = account.refreshToken.trim();
if (seenRefreshTokens.has(token)) {
duplicateTokenCount += 1;
} else {
seenRefreshTokens.add(token);
}
if (hasPlaceholderEmail(account.email)) {
placeholderEmailCount += 1;
}
if (hasLikelyInvalidRefreshToken(account.refreshToken)) {
likelyInvalidRefreshTokenCount += 1;
}
}
Comment on lines +4094 to +4097
[actionableRestores, rollbackPlan] = await Promise.all([
getActionableNamedBackupRestores({ currentStorage: storage }),
getLatestCodexCliSyncRollbackPlan(),
]);
getLatestCodexCliSyncRollbackPlan(),
]);
} catch (error) {
console.warn("Failed to build login menu health summary", error);
fixIssues: "Auto-Repair Issues",
settings: "Settings",
moreChecks: "Advanced Checks",
healthSummary: "Health Summary",
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 4025-4031: The loop over storage.accounts that calls
account.refreshToken.trim() can throw when refreshToken is missing; replace the
direct trim usage in the duplicate-token detection with the existing safe helper
getDoctorRefreshTokenKey(account) (or otherwise null-check/typeof-check
account.refreshToken before trimming) so seenRefreshTokens and
duplicateTokenCount logic uses the normalized key returned by
getDoctorRefreshTokenKey; ensure the change is applied where the for-loop,
seenRefreshTokens, duplicateTokenCount, and the call sites
buildLoginMenuHealthSummary / summarizeReadOnlyDoctorState interact so a
malformed account cannot throw and crash the login flow.
- Around line 4093-4102: The try/catch currently only wraps Promise.all for
getActionableNamedBackupRestores and getLatestCodexCliSyncRollbackPlan, but
summarizeLatestCodexCliSyncState() and summarizeReadOnlyDoctorState(storage) are
called after the catch and can throw; expand the try block to include the calls
to summarizeLatestCodexCliSyncState and summarizeReadOnlyDoctorState (or wrap
them in the same try) so any error thrown by those helpers is caught, and in the
catch log the error with context and return/set safe default summaries (e.g.,
empty/placeholder syncSummary and doctorSummary) to avoid blocking login menu
rendering; locate this change around the getActionableNamedBackupRestores,
getLatestCodexCliSyncRollbackPlan, summarizeLatestCodexCliSyncState, and
summarizeReadOnlyDoctorState symbols.

In `@test/codex-manager-cli.test.ts`:
- Line 2323: The test currently asserts getActionableNamedBackupRestoresMock is
called twice which forces duplicate filesystem scans; change the test to expect
a single call and update the code paths so the same
getActionableNamedBackupRestores result is reused between the health-summary and
startup-recovery flows (e.g., cache or share the promise returned by
getActionableNamedBackupRestores in the service that both health-summary and
startupRecovery handlers call). Locate usages of
getActionableNamedBackupRestores/getActionableNamedBackupRestoresMock and the
startup-recovery and health-summary handlers, ensure they reference the shared
result instead of invoking a second scan, and update the test expectation to
toHaveBeenCalledTimes(1). Ensure behavior still covers error handling for
EBUSY/429 by queuing/retrying in the shared implementation.
- Around line 3233-3273: The healthSummary label/hint may contain ANSI/control
characters but showAuthMenu currently passes them through; update showAuthMenu
to sanitize healthSummary.label and healthSummary.hint before pushing the item
(the code that builds the "Health Summary" heading and the next disabled item
inside showAuthMenu), stripping ANSI escape sequences and control characters
(e.g. use a utility like stripAnsi or a regex to remove /\x1B\[[0-9;]*[A-Za-z]/g
and other non-printables) and ensure the item created for healthSummary sets the
sanitized strings as label and hint so the test can assert plain text; also
add/adjust the test to inject ANSI/control chars into healthSummary and assert
they are removed.
- Around line 3199-3231: The test's simulated failure uses new Error("EBUSY
backups") which doesn't emulate Windows errno semantics; update the mocked
rejection for getActionableNamedBackupRestoresMock to throw an errno-shaped
error (e.g., const err = Object.assign(new Error("EBUSY backups"), { code:
"EBUSY" }); getActionableNamedBackupRestoresMock.mockRejectedValue(err)) so the
code path that checks error.code === "EBUSY" is exercised; change only the mock
rejection creation in the test surrounding getActionableNamedBackupRestoresMock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 75641a8a-8ebf-475c-981b-4fd7838b7ea4

📥 Commits

Reviewing files that changed from the base of the PR and between 46efc1e and 6decea7.

📒 Files selected for processing (5)
  • lib/cli.ts
  • lib/codex-manager.ts
  • lib/ui/auth-menu.ts
  • lib/ui/copy.ts
  • test/codex-manager-cli.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/ui/copy.ts
  • lib/cli.ts
  • lib/ui/auth-menu.ts
  • lib/codex-manager.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-cli.test.ts
🔇 Additional comments (13)
lib/ui/copy.ts (1)

12-12: good ui copy addition.

lib/ui/copy.ts:12 adds the heading key cleanly and matches the menu rendering path.

lib/ui/auth-menu.ts (2)

52-60: type plumbing looks correct.

lib/ui/auth-menu.ts:52-60 introduces a tight read-only shape and keeps healthSummary optional in menu options.


614-637: health summary rendering is safely integrated.

lib/ui/auth-menu.ts:614-637 sanitizes both fields and renders the summary as a disabled row, which keeps the dashboard section non-interactive as intended.

lib/cli.ts (1)

7-7: health summary passthrough is clean.

lib/cli.ts:7, lib/cli.ts:98, and lib/cli.ts:275 wire the read-only summary through without altering auth control flow.

Also applies to: 98-98, 275-275

test/codex-manager-cli.test.ts (4)

779-779: no comment needed here.

test/codex-manager-cli.test.ts:779 is harmless setup churn.


2668-2668: same duplicate-scan concern as above.

this expectation in test/codex-manager-cli.test.ts:2668 tracks the same startup-scan behavior already flagged at line 2323.


2962-2962: same duplicate-scan concern as above.

this expectation in test/codex-manager-cli.test.ts:2962 is another downstream instance of the line 2323 issue.


3112-3197: good regression coverage for health summary wiring.

test/codex-manager-cli.test.ts:3112-3197 validates that login options carry a read-only health summary with expected label and hint content.

lib/codex-manager.ts (5)

93-96: imports look correct.

getLastCodexCliSyncRun() returns CodexCliSyncRun | null and getLatestCodexCliSyncRollbackPlan() returns Promise<CodexCliSyncRollbackPlan> per lib/codex-cli/sync.ts:289-309 and lib/codex-cli/sync.ts:448-458. both are used appropriately in the new health summary functions.


3219-3228: interfaces are well-defined.

DashboardHealthSummary and DoctorReadOnlySummary are internal types with appropriate fields for read-only display. the optional hint on DashboardHealthSummary vs required on DoctorReadOnlySummary matches their usage patterns in buildLoginMenuHealthSummary and summarizeReadOnlyDoctorState.


3937-3943: simple utility, looks good.


3945-3990: sync state summarization handles null correctly.

the null check at line 3950 covers the case where getLastCodexCliSyncRun() returns null (per lib/codex-cli/sync.ts:309). the summary field access at lines 3969-3978 is safe since CodexCliSyncSummary fields are non-optional per lib/codex-cli/sync.ts:68-80.


4265-4273: health summary integration into login menu looks correct.

the healthSummary is built before rendering and passed to promptLoginMode. per the ai summary, test/codex-manager-cli.test.ts covers health summary integration.

one note: since buildLoginMenuHealthSummary is awaited without a surrounding try/catch here, an unhandled throw (if the fixes above aren't applied) would exit the login flow. the resilience fix suggested for lib/codex-manager.ts:4093-4102 would address this.

@ndycode
Copy link
Owner Author

ndycode commented Mar 16, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ndycode
Copy link
Owner Author

ndycode commented Mar 16, 2026

@coderabbitai resolve

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

✅ Actions performed

Comments resolved and changes approved.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
test/codex-manager-cli.test.ts (2)

3216-3218: ⚠️ Potential issue | 🟠 Major

use errno-shaped failure in the fallback test.

test/codex-manager-cli.test.ts:3216-3218 still uses new Error("EBUSY backups"), which does not exercise windows errno handling paths. use makeErrnoError(..., "EBUSY") so the regression matches real fs failures.

proposed fix
-		getActionableNamedBackupRestoresMock.mockRejectedValue(
-			new Error("EBUSY backups"),
-		);
+		getActionableNamedBackupRestoresMock.mockRejectedValue(
+			makeErrnoError("resource busy", "EBUSY"),
+		);
As per coding guidelines, "test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3216 - 3218, Replace the generic
Error used in the fallback test with an errno-shaped error so Windows-specific
code paths are exercised: change the rejection of
getActionableNamedBackupRestoresMock (the mock currently using new Error("EBUSY
backups")) to use makeErrnoError(..., "EBUSY") (providing the same
message/subject like "backups") so the test reproduces real fs failures and
triggers errno-based handling in the code under test.

3268-3308: ⚠️ Potential issue | 🟡 Minor

add ansi/control-char sanitization coverage for health summary row rendering.

test/codex-manager-cli.test.ts:3268-3308 verifies disabled-row rendering, but it still does not assert stripping ansi/control chars from label/hint before display. add a regression case with escaped input and assert sanitized output.

example extension
 		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",
+					label:
+						"\u001b[31mPool 1 active\u001b[0m | Sync none | Restore none | Rollback none | Doctor ok\u0007",
+					hint: "\u001b[2mAccounts: 1 enabled / 0 disabled / 1 total\u001b[0m\u0000",
 				},
 			},
 		);
@@
 		expect(items[headingIndex + 1]).toEqual(
 			expect.objectContaining({
-				label:
-					"Pool 1 active | Sync none | Restore none | Rollback none | Doctor ok",
+				label:
+					"Pool 1 active | Sync none | Restore none | Rollback none | Doctor ok",
 				disabled: true,
 				hint: "Accounts: 1 enabled / 0 disabled / 1 total",
 			}),
 		);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3268 - 3308, Update the test to
cover ANSI/control-character sanitization for the health summary row by
providing escaped/ANSI-laden strings for healthSummary.label and
healthSummary.hint when calling showAuthMenu (or add a new similar test), then
assert that the items produced via selectMock include the sanitized label and
hint (no ANSI/control chars) and still have disabled: true and kind: "heading"
placement preserved; reference the existing selectMock usage and the items
extraction logic to locate where to assert sanitized output for showAuthMenu's
healthSummary rendering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-manager.ts`:
- Around line 4131-4141: The current Promise.all call that awaits
getActionableNamedBackupRestores({ currentStorage: storage }) and
getLatestCodexCliSyncRollbackPlan() exits immediately on any error; implement a
bounded retry loop around the combined attempt in the codex-manager code path
(the block using actionableRestores and rollbackPlan) to retry transient Windows
file-lock/EBUSY/EPERM and HTTP-429 style errors with exponential backoff (e.g.,
initial delay ~50–200ms, max attempts 3–5, small jitter) and abort immediately
on non-transient errors; ensure the retry predicate checks error codes/messages
for EBUSY/EPERM/ERR_FS/429, log each retry via logLoginMenuHealthSummaryWarning
without printing tokens/emails, keep final behavior unchanged on hard failures,
and add/modify vitest unit tests to cover transient EBUSY and 429 retries and
final failure cases.
- Around line 4167-4173: The menu hint currently injects raw rollbackPlan.reason
into hintParts (see the hintParts array and the use of rollbackPlan.reason);
replace that direct insertion with a sanitized/ mapped status string (e.g., via
a new helper like getRollbackHint(reason) or sanitizeRollbackReason(reason))
that returns a bounded label or status code (no filesystem paths, max length,
and mapped common reasons to human-friendly phrases). Update places referencing
rollbackPlan.reason in lib/codex-manager.ts to call the sanitizer, add/adjust
vitest unit tests to assert sanitized outputs, and ensure logs still avoid
leaking paths/tokens/emails while handling EBUSY/429 retry logic where relevant.

In `@test/codex-manager-cli.test.ts`:
- Around line 3202-3241: The test "falls back to a safe health summary when
restore or rollback state reads fail" (uses runCodexMultiAuthCli,
promptLoginModeMock, getActionableNamedBackupRestoresMock,
getLastCodexCliSyncRunMock) must add a console.warn spy assertion to ensure the
fallback warning is redacted: create a spy for console.warn before invoking
runCodexMultiAuthCli, assert it was called, and assert the captured warning
string does NOT contain raw filesystem paths, raw Error/Object dumps, or
unredacted tokens (e.g., assert the joined console.warn calls do not match
regexes for path separators like / or \\, "Error(", "Object{", or known error
text like "EBUSY"). Ensure the spy is restored after the test so other tests
remain deterministic.
- Line 779: Remove the duplicate default mock setup
getLastCodexCliSyncRunMock.mockReturnValue(null) that is repeated in the test;
keep only the first assignment (the earlier setup at the other occurrence) and
delete the later duplicate to reduce setup noise and avoid redundant state
initialization in the test file.

---

Duplicate comments:
In `@test/codex-manager-cli.test.ts`:
- Around line 3216-3218: Replace the generic Error used in the fallback test
with an errno-shaped error so Windows-specific code paths are exercised: change
the rejection of getActionableNamedBackupRestoresMock (the mock currently using
new Error("EBUSY backups")) to use makeErrnoError(..., "EBUSY") (providing the
same message/subject like "backups") so the test reproduces real fs failures and
triggers errno-based handling in the code under test.
- Around line 3268-3308: Update the test to cover ANSI/control-character
sanitization for the health summary row by providing escaped/ANSI-laden strings
for healthSummary.label and healthSummary.hint when calling showAuthMenu (or add
a new similar test), then assert that the items produced via selectMock include
the sanitized label and hint (no ANSI/control chars) and still have disabled:
true and kind: "heading" placement preserved; reference the existing selectMock
usage and the items extraction logic to locate where to assert sanitized output
for showAuthMenu's healthSummary rendering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 598fcf11-8eb8-4438-83cc-b7c77ea8a2b6

📥 Commits

Reviewing files that changed from the base of the PR and between 6decea7 and c73e5ec.

📒 Files selected for processing (2)
  • lib/codex-manager.ts
  • test/codex-manager-cli.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/codex-manager.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-cli.test.ts
🔇 Additional comments (3)
test/codex-manager-cli.test.ts (1)

2326-2328: nice tightening on startup scan expectations.

the assertions in test/codex-manager-cli.test.ts:2326, test/codex-manager-cli.test.ts:2671, and test/codex-manager-cli.test.ts:2965 now lock in a single startup scan path while still verifying no restore prompt side effects. this keeps the login flow contract clearer.

Also applies to: 2671-2671, 2965-2965

lib/codex-manager.ts (2)

4039-4048: good hardening on duplicate-token detection.

lib/codex-manager.ts:4039-4048 now avoids raw token handling in this read-only summary path and safely skips unusable refresh tokens. this is a solid safety improvement, and i don’t see a new concurrency risk in this loop.


4098-4181: missing regression test coverage for two error fallback paths in lib/codex-manager.ts:4143-4156.

test at test/codex-manager-cli.test.ts:3202 ("falls back to a safe health summary...") only covers the promise.all failure path and doesn't mock summarizeLatestCodexCliSyncState() or summarizeReadOnlyDoctorState(). need explicit tests for:

  • sync-summary error fallback (lib/codex-manager.ts:4143-4148)
  • doctor-summary error fallback (lib/codex-manager.ts:4151-4156)
  • logLoginMenuHealthSummaryWarning() calls with error details
  • windows-style filesystem error redaction (EBUSY/resource busy handling)

no mocks exist for these two functions in the test suite, so their error paths are untested. verify windows error details don't leak in logs per coding guidelines.

Comment on lines +4131 to +4141
try {
[actionableRestores, rollbackPlan] = await Promise.all([
getActionableNamedBackupRestores({ currentStorage: storage }),
getLatestCodexCliSyncRollbackPlan(),
]);
} catch (error) {
logLoginMenuHealthSummaryWarning(
"Failed to load async login menu health summary state",
error,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

add a bounded retry for transient windows file-lock failures.

lib/codex-manager.ts:4131-4141 falls back immediately after one failure. for windows EBUSY/EPERM-style contention, a short retry would reduce false “unknown” health summaries without changing behavior on hard failures. this also lowers noise from transient concurrency between background io and menu rendering.

As per coding guidelines "lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 4131 - 4141, The current Promise.all call
that awaits getActionableNamedBackupRestores({ currentStorage: storage }) and
getLatestCodexCliSyncRollbackPlan() exits immediately on any error; implement a
bounded retry loop around the combined attempt in the codex-manager code path
(the block using actionableRestores and rollbackPlan) to retry transient Windows
file-lock/EBUSY/EPERM and HTTP-429 style errors with exponential backoff (e.g.,
initial delay ~50–200ms, max attempts 3–5, small jitter) and abort immediately
on non-transient errors; ensure the retry predicate checks error codes/messages
for EBUSY/EPERM/ERR_FS/429, log each retry via logLoginMenuHealthSummaryWarning
without printing tokens/emails, keep final behavior unchanged on hard failures,
and add/modify vitest unit tests to cover transient EBUSY and 429 retries and
final failure cases.

reason: "No manual Codex CLI apply with a rollback checkpoint is available.",
snapshot: null,
});
getLastCodexCliSyncRunMock.mockReturnValue(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

remove duplicate default mock setup for sync run state.

test/codex-manager-cli.test.ts:779 repeats the same default already set at test/codex-manager-cli.test.ts:737. keep one assignment to reduce setup noise.

proposed cleanup
-		getLastCodexCliSyncRunMock.mockReturnValue(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getLastCodexCliSyncRunMock.mockReturnValue(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` at line 779, Remove the duplicate default
mock setup getLastCodexCliSyncRunMock.mockReturnValue(null) that is repeated in
the test; keep only the first assignment (the earlier setup at the other
occurrence) and delete the later duplicate to reduce setup noise and avoid
redundant state initialization in the test file.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
lib/codex-manager.ts (1)

4151-4168: 🧹 Nitpick | 🔵 Trivial

consider bounded retry for transient windows file-lock failures.

the try/catch blocks at lib/codex-manager.ts:4151-4168 fall back immediately on any error. on windows, EBUSY/EPERM contention from concurrent file access is common. a short retry with exponential backoff (e.g., 2-3 attempts, 50-100ms initial delay) would reduce false "unknown" health summaries without changing behavior on hard failures.

this was flagged in a past review and remains unaddressed. per coding guidelines: "verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 4151 - 4168, The two try/catch blocks that
call getActionableNamedBackupRestores and getLatestCodexCliSyncRollbackPlan
should implement a bounded retry with exponential backoff (2–3 attempts, initial
delay 50–100ms doubling each retry) and only retry on transient filesystem
contention errors (e.g., EBUSY, EPERM); on other errors immediately call
logLoginMenuHealthSummaryWarning with the error as today. Modify the calls
around getActionableNamedBackupRestores and getLatestCodexCliSyncRollbackPlan to
wrap their awaits in a small retry helper or inline loop that inspects
error.code/name for EBUSY/EPERM, applies delays, and falls back to the existing
logLoginMenuHealthSummaryWarning behavior on final failure. Add/adjust vitest
unit tests to simulate EBUSY/EPERM transient failures and assert the retry
behavior and that hard errors still log immediately, and ensure any new retry
queue logic accounts for EBUSY/429 scenarios per guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-manager.ts`:
- Line 4347: The call to buildLoginMenuHealthSummary(currentStorage) inside the
inner menu loop triggers repeated disk I/O (via getActionableNamedBackupRestores
and getLatestCodexCliSyncRollbackPlan) on every iteration; change this by
introducing a short-lived cache (e.g., memoized result with a 30–60s TTL) keyed
by currentStorage (or a last-modified token) and use that cached summary in the
menu loop, and/or only rebuild the summary after storage-modifying actions
(invalidate the cache on functions that change storage/backup state). Update the
code paths that call buildLoginMenuHealthSummary to read from the cache (or call
an async getCachedLoginMenuHealthSummary wrapper) and add Vitest unit tests for
buildLoginMenuHealthSummary to cover caching behavior and invalidation when
storage changes.

In `@test/codex-manager-cli.test.ts`:
- Around line 3144-3147: The test uses
getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [{}],
totalBackups: 2 }) with an empty assessment object which may hide null/undefined
property access in production; update the mock to provide a minimal valid
assessment shape (e.g., include the specific properties your code reads such as
backup, name, status, or whatever health-summary logic in codex-manager.ts
expects) or alternatively adjust the code under test (functions that process
assessments in codex-manager.ts / health summary builder) to defensively check
for missing nested fields before access; target the
getActionableNamedBackupRestoresMock setup in the test and ensure the mocked
assessments mirror the real assessment interface used by the
health-summary/building logic.

---

Duplicate comments:
In `@lib/codex-manager.ts`:
- Around line 4151-4168: The two try/catch blocks that call
getActionableNamedBackupRestores and getLatestCodexCliSyncRollbackPlan should
implement a bounded retry with exponential backoff (2–3 attempts, initial delay
50–100ms doubling each retry) and only retry on transient filesystem contention
errors (e.g., EBUSY, EPERM); on other errors immediately call
logLoginMenuHealthSummaryWarning with the error as today. Modify the calls
around getActionableNamedBackupRestores and getLatestCodexCliSyncRollbackPlan to
wrap their awaits in a small retry helper or inline loop that inspects
error.code/name for EBUSY/EPERM, applies delays, and falls back to the existing
logLoginMenuHealthSummaryWarning behavior on final failure. Add/adjust vitest
unit tests to simulate EBUSY/EPERM transient failures and assert the retry
behavior and that hard errors still log immediately, and ensure any new retry
queue logic accounts for EBUSY/429 scenarios per guidelines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: abed6bb3-0e20-48e4-8053-fada14584b8a

📥 Commits

Reviewing files that changed from the base of the PR and between c73e5ec and 0302042.

📒 Files selected for processing (2)
  • lib/codex-manager.ts
  • test/codex-manager-cli.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/codex-manager.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-cli.test.ts
🔇 Additional comments (16)
lib/codex-manager.ts (9)

1-1: lgtm on crypto import for fingerprinting.

importing createHash from node:crypto for the sha256 fingerprint is appropriate and avoids external dependencies.


3273-3279: good: token fingerprinting reduces leakage surface.

getReadOnlyDoctorRefreshTokenFingerprint at lib/codex-manager.ts:3273-3279 hashes the refresh token and truncates to 12 chars, which is a solid approach to avoid logging or exposing raw tokens while still enabling duplicate detection in summarizeReadOnlyDoctorState.


4039-4049: past review addressed: safe token handling in doctor summary.

at lib/codex-manager.ts:4039-4049, the loop now uses getReadOnlyDoctorRefreshTokenFingerprint(account.refreshToken) which internally calls getDoctorRefreshTokenKey and handles undefined/non-string values safely. no more risk of .trim() throwing on malformed account entries.


4090-4096: good: redacted error logging avoids path/token leakage.

logLoginMenuHealthSummaryWarning at lib/codex-manager.ts:4090-4096 uses getRedactedFilesystemErrorLabel(error) which should strip sensitive filesystem paths and error details. this aligns with the coding guideline to avoid leaking tokens or emails in logs.


4098-4116: past review addressed: rollback hint is now sanitized.

formatLoginMenuRollbackHint at lib/codex-manager.ts:4098-4116 maps rollbackPlan to bounded, user-friendly strings instead of injecting raw rollbackPlan.reason which could contain noisy filesystem paths. the logic at lines 4101-4115 returns one of four predefined labels, preventing uncontrolled text from reaching the ui.


4118-4123: good: early return on zero-account storage.

buildLoginMenuHealthSummary at lib/codex-manager.ts:4121-4123 correctly returns null for fresh installs with no accounts, avoiding unnecessary i/o and ui clutter.


4194-4200: hint construction looks correct.

the hintParts array at lib/codex-manager.ts:4194-4200 uses sanitized/mapped values for all sections: syncSummary.hint, formatLoginMenuRollbackHint(rollbackPlan), and doctorSummary.hint. no raw filesystem paths or tokens are interpolated.


3954-3999: sync state summarizer is safe and degrades gracefully.

summarizeLatestCodexCliSyncState at lib/codex-manager.ts:3954-3999 pulls from the in-memory cache first via getLastCodexCliSyncRun() and returns a safe default when no run exists. no exceptions can propagate here.


4001-4088: doctor state summary correctly uses fingerprint for duplicate detection.

summarizeReadOnlyDoctorState at lib/codex-manager.ts:4001-4088 iterates accounts using getReadOnlyDoctorRefreshTokenFingerprint instead of raw tokens, ensuring duplicate detection doesn't expose sensitive data. the function returns safe defaults for empty or null storage.

test/codex-manager-cli.test.ts (7)

779-779: duplicate default mock setup for sync run state.

test/codex-manager-cli.test.ts:779 repeats the same default already set at test/codex-manager-cli.test.ts:737. keep one assignment to reduce setup noise.


3202-3241: add a redaction assertion for the fallback warning path.

test/codex-manager-cli.test.ts:3202-3241 validates fallback summary text, but it does not assert that warning output is sanitized (no raw filesystem paths/error objects). add a console.warn spy assertion so this path has a privacy-safe regression guard.

as per coding guidelines, "test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions."


3268-3308: add a sanitization regression for summary text.

test/codex-manager-cli.test.ts:3268-3308 checks disabled-row rendering, but it does not assert ansi/control-char stripping from healthSummary.label and healthSummary.hint in lib/ui/auth-menu.ts:614-617.


3115-3200: lgtm — happy path health summary propagation test is solid.

the test correctly verifies:

  • pool summary line includes enabled/disabled account counts
  • rollback hint reflects checkpoint availability
  • promptLoginModeMock receives the healthSummary option

good coverage for the primary flow.


3243-3266: lgtm — zero-account early-return guard is well tested.

verifies that health-summary i/o (rollback plan, sync run) is skipped when accounts array is empty, and that the single getActionableNamedBackupRestoresMock call comes from the startup recovery path rather than the health summary path (as the inline comment explains).


2326-2328: assertion updates align with health-summary i/o changes.

the expectation changes from not.toHaveBeenCalled() to toHaveBeenCalledTimes(1) at test/codex-manager-cli.test.ts:2671 and test/codex-manager-cli.test.ts:2965 correctly reflect that health-summary i/o now fires after oauth completes. the new healthSummary: undefined assertion at test/codex-manager-cli.test.ts:2327 confirms fresh-install behavior.

Also applies to: 2671-2671, 2965-2965


3115-3200: code already uses isolated error handling, no Promise.all pattern found.

the health summary function at lib/codex-manager.ts:4100-4180 implements four separate try/catch blocks with graceful fallbacks, not Promise.all(). each call to getActionableNamedBackupRestores(), getLatestCodexCliSyncRollbackPlan(), summarizeLatestCodexCliSyncState(), and summarizeReadOnlyDoctorState() is awaited independently with its own error boundary. there's no partial failure risk from concurrent execution since the functions aren't combined in a Promise.all. the test at test/codex-manager-cli.test.ts:3115-3200 correctly validates that the health summary passes read-only data to the login menu, and the mocked functions already demonstrate successful handling.

			> Likely an incorrect or invalid review comment.

}
}
const flaggedStorage = await loadFlaggedAccounts();
const healthSummary = await buildLoginMenuHealthSummary(currentStorage);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# search for caching or debounce patterns around buildLoginMenuHealthSummary
rg -n -C5 'buildLoginMenuHealthSummary' lib/

Repository: ndycode/codex-multi-auth

Length of output: 1324


🏁 Script executed:

#!/bin/bash
# examine the loop structure around line 4347
sed -n '4300,4360p' lib/codex-manager.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 2827


🏁 Script executed:

#!/bin/bash
# read the buildLoginMenuHealthSummary function implementation
sed -n '4118,4160p' lib/codex-manager.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 1485


🏁 Script executed:

#!/bin/bash
# search for caching patterns like memoize, cache, ttl around the function
rg -i 'memoiz|cache|ttl|debounce' lib/codex-manager.ts | head -20

Repository: ndycode/codex-multi-auth

Length of output: 856


🏁 Script executed:

#!/bin/bash
# read more of the function to see all async calls
sed -n '4118,4180p' lib/codex-manager.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 2126


🏁 Script executed:

#!/bin/bash
# find where the loop ends (around line 4495)
sed -n '4490,4500p' lib/codex-manager.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 367


🏁 Script executed:

#!/bin/bash
# search for tests related to buildLoginMenuHealthSummary
find test -type f -name "*.ts" -o -name "*.js" | xargs rg 'buildLoginMenuHealthSummary' 2>/dev/null

Repository: ndycode/codex-multi-auth

Length of output: 52


🏁 Script executed:

#!/bin/bash
# count actual async/await patterns in the function more carefully
sed -n '4118,4175p' lib/codex-manager.ts | rg 'await|async' -n

Repository: ndycode/codex-multi-auth

Length of output: 243


🏁 Script executed:

#!/bin/bash
# search for test files
find test -type f \( -name "*.ts" -o -name "*.test.ts" -o -name "*.spec.ts" \) | head -10

Repository: ndycode/codex-multi-auth

Length of output: 327


🏁 Script executed:

#!/bin/bash
# search for memoization or caching at module level
rg -n 'const.*=.*new.*Map|const.*cache|memoize' lib/codex-manager.ts | head -20

Repository: ndycode/codex-multi-auth

Length of output: 683


🏁 Script executed:

#!/bin/bash
# check the implementations of the two main async calls
rg -A10 'async function getActionableNamedBackupRestores' lib/

Repository: ndycode/codex-multi-auth

Length of output: 636


🏁 Script executed:

#!/bin/bash
# find scanNamedBackups implementation
rg -A15 'async function scanNamedBackups' lib/

Repository: ndycode/codex-multi-auth

Length of output: 923


🏁 Script executed:

#!/bin/bash
# find getLatestCodexCliSyncRollbackPlan implementation
rg -A10 'async function getLatestCodexCliSyncRollbackPlan' lib/

Repository: ndycode/codex-multi-auth

Length of output: 696


🏁 Script executed:

#!/bin/bash
# search for any tests related to health summary or login menu
find test -type f -name "*.ts" | xargs rg -l 'health|menu' 2>/dev/null | head -5

Repository: ndycode/codex-multi-auth

Length of output: 214


rebuild health summary on every menu iteration causes redundant disk i/o without caching.

buildLoginMenuHealthSummary(currentStorage) at lib/codex-manager.ts:4347 is called inside the inner menu loop and performs two async disk operations on every iteration: getActionableNamedBackupRestores() calls fs.readdir() on the backup directory, and getLatestCodexCliSyncRollbackPlan() performs rollback snapshot I/O. this creates latency spikes on slower disks or under contention, especially with concurrent filesystem operations.

missing regression tests for buildLoginMenuHealthSummary itself; add vitest coverage when implementing caching. consider a short ttl (30-60 seconds) or rebuild only after storage-modifying actions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` at line 4347, The call to
buildLoginMenuHealthSummary(currentStorage) inside the inner menu loop triggers
repeated disk I/O (via getActionableNamedBackupRestores and
getLatestCodexCliSyncRollbackPlan) on every iteration; change this by
introducing a short-lived cache (e.g., memoized result with a 30–60s TTL) keyed
by currentStorage (or a last-modified token) and use that cached summary in the
menu loop, and/or only rebuild the summary after storage-modifying actions
(invalidate the cache on functions that change storage/backup state). Update the
code paths that call buildLoginMenuHealthSummary to read from the cache (or call
an async getCachedLoginMenuHealthSummary wrapper) and add Vitest unit tests for
buildLoginMenuHealthSummary to cover caching behavior and invalidation when
storage changes.

Comment on lines +3144 to +3147
getActionableNamedBackupRestoresMock.mockResolvedValue({
assessments: [{}],
totalBackups: 2,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

assessment mock uses empty object — verify property access won't break.

test/codex-manager-cli.test.ts:3145 passes assessments: [{}] which is a bare object with no properties. if lib/codex-manager.ts accesses assessment.backup.name or similar during health summary building, the production code could throw whereas this test silently passes due to mock isolation. consider providing a minimal valid assessment shape or explicitly documenting that the code only checks assessments.length.

suggested minimal shape
 		getActionableNamedBackupRestoresMock.mockResolvedValue({
-			assessments: [{}],
+			assessments: [{ backup: { name: "mock-backup" }, eligibleForRestore: true }],
 			totalBackups: 2,
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3144 - 3147, The test uses
getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [{}],
totalBackups: 2 }) with an empty assessment object which may hide null/undefined
property access in production; update the mock to provide a minimal valid
assessment shape (e.g., include the specific properties your code reads such as
backup, name, status, or whatever health-summary logic in codex-manager.ts
expects) or alternatively adjust the code under test (functions that process
assessments in codex-manager.ts / health summary builder) to defensively check
for missing nested fields before access; target the
getActionableNamedBackupRestoresMock setup in the test and ensure the mocked
assessments mirror the real assessment interface used by the
health-summary/building logic.

@ndycode ndycode force-pushed the fresh/16-retention-doctor branch from be0837b to 60b3541 Compare March 17, 2026 22:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
test/codex-manager-cli.test.ts (3)

779-779: ⚠️ Potential issue | 🟡 Minor

remove the duplicate sync-run default mock.

test/codex-manager-cli.test.ts:737 already sets the same default. keeping test/codex-manager-cli.test.ts:779 adds setup noise and can mask per-test intent.

proposed cleanup
-		getLastCodexCliSyncRunMock.mockReturnValue(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` at line 779, Remove the duplicate default
mock call getLastCodexCliSyncRunMock.mockReturnValue(null) that was added
redundantly; it duplicates the setup already established earlier and should be
deleted to reduce setup noise and avoid masking per-test intent—locate the
second occurrence of getLastCodexCliSyncRunMock.mockReturnValue(null) in the
test file and remove that line so only the original default mock remains.

3281-3321: ⚠️ Potential issue | 🟡 Minor

add a sanitization regression assertion for the read-only row.

test/codex-manager-cli.test.ts:3281-3321 validates rendering, but it still does not assert ansi/control-char stripping for healthSummary.label and healthSummary.hint passed into lib/ui/auth-menu.ts:614-617.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3281 - 3321, Update the test
that calls showAuthMenu to include a healthSummary.label and healthSummary.hint
containing ANSI/control characters and add assertions that the produced items
(from selectMock calls) contain the sanitized versions (control chars stripped)
for both label and hint and remain disabled; locate the call to showAuthMenu and
the items/headingIndex checks in the test (using selectMock, showAuthMenu,
healthSummary.label/hint and the items array) and assert that items[headingIndex
+ 1].label equals the cleaned label string and items[headingIndex + 1].hint
equals the cleaned hint string while still expecting disabled: true to ensure
sanitization in lib/ui/auth-menu.ts (the code around the showAuthMenu handling
at the referenced lines).

3144-3147: ⚠️ Potential issue | 🟡 Minor

use a minimally valid assessment shape in this health-summary test.

test/codex-manager-cli.test.ts:3144-3147 still uses assessments: [{}]. that can hide nested access regressions and weakens this regression case.

proposed test-shape hardening
 		getActionableNamedBackupRestoresMock.mockResolvedValue({
-			assessments: [{}],
+			assessments: [
+				{
+					backup: { name: "mock-backup" },
+					eligibleForRestore: true,
+				},
+			],
 			totalBackups: 2,
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3144 - 3147, The mock for
getActionableNamedBackupRestoresMock currently returns assessments: [{}];
replace that with a minimally valid assessment object shape (in the assessments
array returned by getActionableNamedBackupRestoresMock.mockResolvedValue) that
includes the real fields the health-summary paths read — e.g., an
id/assessmentId, a status/severity field, a target name (targetName or name), a
timestamp (lastBackupAt or checkedAt) and any nested details/backups array or
counts that the health-summary logic accesses — so the test exercises nested
access and prevents regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-cli/sync.ts`:
- Around line 359-369: captureRollbackSnapshot currently calls
snapshotAccountStorage with failurePolicy: "warn" which can silently return null
on transient Windows EBUSY/EPERM; update captureRollbackSnapshot to retry
snapshotAccountStorage on EBUSY/EPERM with a bounded exponential backoff (e.g.,
3-5 attempts with increasing delays) and only fall back to returning null after
retries fail, using the snapshotAccountStorage call-site in
captureRollbackSnapshot to detect the specific error codes; update or add vitest
coverage to validate retry behavior and that any new queue or retry helpers
handle EBUSY and HTTP 429 semantics consistently.
- Around line 390-446: loadRollbackSnapshot currently fails immediately when
fs.readFile throws transient Windows file-lock errors (EBUSY/EPERM); add a
bounded retry loop around the fs.readFile call in loadRollbackSnapshot with 2–3
attempts and a short exponential/backoff delay (e.g., 50–200ms) only for
retryable error codes ("EBUSY", "EPERM", maybe "EACCES"), then proceed to the
existing error handling if all retries fail. Keep the normalized parsing and
existing return shapes unchanged, ensure the retry only wraps the readFile
operation (refer to loadRollbackSnapshot and the fs.readFile usage), and
update/add vitest cases to simulate EBUSY/EPRM transient failures to validate
the retry behavior.
- Around line 359-507: Add two unit tests: (1) exercise
getLatestCodexCliSyncRollbackPlan when there are no manual "changed" runs by
stubbing readSyncHistory so findLatestManualRollbackRun returns null and assert
the returned plan has status "unavailable" and reason "No manual Codex CLI
apply..."; (2) exercise loadRollbackSnapshot handling of corrupted JSON by
creating a CodexCliSyncRollbackSnapshot with a real temp file containing invalid
JSON (or stub fs.readFile to return malformed text), call loadRollbackSnapshot
and assert it returns status "unavailable" with reason "Rollback checkpoint is
invalid or empty." (use
normalizeAccountStorage/readSyncHistory/loadRollbackSnapshot/getLatestCodexCliSyncRollbackPlan
to locate behavior).

In `@test/codex-cli-sync.test.ts`:
- Around line 1578-1686: Add a deterministic regression test that mirrors the
existing TOCTOU test but exercises rollbackLatestCodexCliSync(plan): preload a
rollback plan (read snapshot + CodexCliSyncRun with rollbackSnapshot) into a
variable, then mutate storage/history concurrently before executing the rollback
by calling rollbackLatestCodexCliSync(preloadedPlan); use the same
withAccountStorageTransaction spy pattern (mocking
storageModule.withAccountStorageTransaction to run a concurrent
storageModule.saveAccounts and await persist) to force the race, then assert
rollback result.status is "restored" (or expected), txSpy was called once, and
the final storage reflects the concurrent save (e.g., account refreshToken
equals "refresh-concurrent"); reference rollbackLatestCodexCliSync(plan),
rollbackLastCodexCliSync(), storageModule.withAccountStorageTransaction,
storageModule.saveAccounts, and appendSyncHistoryEntry to locate where to add
the test.

---

Duplicate comments:
In `@test/codex-manager-cli.test.ts`:
- Line 779: Remove the duplicate default mock call
getLastCodexCliSyncRunMock.mockReturnValue(null) that was added redundantly; it
duplicates the setup already established earlier and should be deleted to reduce
setup noise and avoid masking per-test intent—locate the second occurrence of
getLastCodexCliSyncRunMock.mockReturnValue(null) in the test file and remove
that line so only the original default mock remains.
- Around line 3281-3321: Update the test that calls showAuthMenu to include a
healthSummary.label and healthSummary.hint containing ANSI/control characters
and add assertions that the produced items (from selectMock calls) contain the
sanitized versions (control chars stripped) for both label and hint and remain
disabled; locate the call to showAuthMenu and the items/headingIndex checks in
the test (using selectMock, showAuthMenu, healthSummary.label/hint and the items
array) and assert that items[headingIndex + 1].label equals the cleaned label
string and items[headingIndex + 1].hint equals the cleaned hint string while
still expecting disabled: true to ensure sanitization in lib/ui/auth-menu.ts
(the code around the showAuthMenu handling at the referenced lines).
- Around line 3144-3147: The mock for getActionableNamedBackupRestoresMock
currently returns assessments: [{}]; replace that with a minimally valid
assessment object shape (in the assessments array returned by
getActionableNamedBackupRestoresMock.mockResolvedValue) that includes the real
fields the health-summary paths read — e.g., an id/assessmentId, a
status/severity field, a target name (targetName or name), a timestamp
(lastBackupAt or checkedAt) and any nested details/backups array or counts that
the health-summary logic accesses — so the test exercises nested access and
prevents regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1a05c78c-fe69-444c-bb2d-0c2a6a5d8e0f

📥 Commits

Reviewing files that changed from the base of the PR and between 0302042 and 01ba63d.

📒 Files selected for processing (4)
  • lib/codex-cli/sync.ts
  • lib/codex-manager.ts
  • test/codex-cli-sync.test.ts
  • test/codex-manager-cli.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/codex-manager.ts
  • lib/codex-cli/sync.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/codex-manager-cli.test.ts
  • test/codex-cli-sync.test.ts
🔇 Additional comments (30)
lib/codex-manager.ts (15)

1-1: lgtm: crypto import for token fingerprinting.

importing createHash from node:crypto to support the sha-256 fingerprint approach is appropriate for reducing token-leakage surface.


70-70: lgtm: new storage and sync imports for health summary features.

the new imports for listAccountSnapshots, getLastCodexCliSyncRun, and getLatestCodexCliSyncRollbackPlan are correctly scoped for the health summary and rollback plan features.

Also applies to: 94-97


3220-3229: lgtm: new interfaces for health summary types.

DashboardHealthSummary and DoctorReadOnlySummary are minimal and match the AuthMenuReadOnlyRow interface from lib/ui/auth-menu.ts:52-61 for the fields actually used (label, hint).


3273-3279: lgtm: token fingerprinting reduces leakage surface.

getReadOnlyDoctorRefreshTokenFingerprint properly uses sha-256 and slices to 12 chars. this is a safe replacement for raw token handling in read-only doctor state summaries.


3376-3419: lgtm: wal and rotating backup checks in doctor.

the new doctor checks for storage-journal (wal), rotating-backups, and snapshot-backups are well-structured with appropriate severity levels.


3469-3486: lgtm: recovery chain check aggregates all backup sources.

the recovery-chain check at lib/codex-manager.ts:3469-3486 correctly aggregates storage, wal, rotating, and snapshot artifacts to determine recovery readiness.


3592-3641: lgtm: rollback checkpoint and named-backup restore checks.

the doctor now checks for rollback checkpoint readiness and actionable named backup restores, providing actionable guidance in the details field.


3946-3952: lgtm: formatCountNoun helper.

simple and correct pluralization helper for count labels.


3954-3999: lgtm: summarizeLatestCodexCliSyncState.

correctly formats sync run outcome with relative date and delta counts. no token/email leakage since it only uses outcome, counts, and timestamps.


4001-4088: lgtm: summarizeReadOnlyDoctorState uses fingerprinting.

at lib/codex-manager.ts:4040-4042, the loop uses getReadOnlyDoctorRefreshTokenFingerprint instead of raw tokens for duplicate detection. this aligns with the pr objective to replace raw refresh-token storage with fingerprints.


4090-4096: lgtm: logLoginMenuHealthSummaryWarning uses redacted error labels.

getRedactedFilesystemErrorLabel is used to avoid leaking filesystem paths in warnings, per the pr objective.


4098-4117: lgtm: formatLoginMenuRollbackHint avoids raw reason injection.

this addresses the past review comment at lib/codex-manager.ts:4197-4203 by mapping rollback status to bounded hint strings rather than injecting raw rollbackPlan.reason.


4119-4124: lgtm: early return on empty storage.

early return at line 4122-4124 avoids unnecessary i/o on fresh installs, per pr objectives.


4129-4171: each health-summary sub-call is now isolated in its own try/catch.

the refactored structure with separate try/catch blocks for getActionableNamedBackupRestores, getLatestCodexCliSyncRollbackPlan, summarizeLatestCodexCliSyncState, and summarizeReadOnlyDoctorState addresses the past review comment about try/catch not covering sync helper calls.


4350-4356: buildLoginMenuHealthSummary still called per menu iteration without caching.

the past review comment at lib/codex-manager.ts:4350 flagged that buildLoginMenuHealthSummary is called inside the inner menu loop, causing redundant disk i/o. this concern remains unaddressed. consider a short ttl cache (30-60 seconds) or only rebuild after storage-modifying actions.

as per coding guidelines "lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios."

lib/codex-cli/sync.ts (15)

10-14: lgtm: new storage imports for rollback capability.

importing NamedBackupMetadata, normalizeAccountStorage, snapshotAccountStorage, and withAccountStorageTransaction correctly supports the new rollback snapshot and restore features.


79-99: lgtm: new rollback types are well-structured.

CodexCliSyncTrigger, CodexCliSyncRollbackSnapshot, CodexCliSyncRollbackPlan, and CodexCliSyncRollbackResult provide clear type safety for the rollback feature.


125-126: lgtm: CodexCliSyncRun extended with trigger and rollbackSnapshot.

extending the run type to track trigger source and rollback snapshot enables rollback plan resolution from sync history.


166-179: lgtm: normalizeRollbackSnapshot guards against malformed snapshots.

defensive validation ensures both name and path are strings before returning a snapshot object.


181-195: lgtm: normalizeCodexCliSyncRun and cloneCodexCliSyncRun updated for rollback.

correctly normalizes trigger to "manual" or "automatic" and applies normalizeRollbackSnapshot for the rollbackSnapshot field.


247-256: lgtm: buildSyncRunError sets rollbackSnapshot to null.

on error, the rollback snapshot is explicitly nulled out to prevent stale snapshot references.


259-269: lgtm: createSyncRun accepts partial trigger/rollbackSnapshot with defaults.

clean signature extension that defaults trigger to "automatic" and rollbackSnapshot to null.


312-329: lgtm: commitPendingCodexCliSyncRun normalizes rollbackSnapshot.

ensures the snapshot is normalized before publishing to sync history.


371-373: lgtm: isManualChangedSyncRun predicate.

correctly identifies manual trigger syncs that resulted in changes.


375-388: lgtm: findLatestManualRollbackRun iterates history in reverse.

correctly finds the most recent manual changed sync run by iterating from newest to oldest.


448-458: lgtm: getLatestCodexCliSyncRollbackPlan exported for health summary.

correctly delegates to findLatestManualRollbackRun and loadRollbackSnapshot.


460-493: rollbackLatestCodexCliSync uses withAccountStorageTransaction for serialization.

the use of withAccountStorageTransaction at lib/codex-cli/sync.ts:463 addresses the greptile-noted TOCTOU concern by ensuring plan resolution and persist happen atomically within the transaction. if a pre-resolved plan is passed with status "ready", it's used directly; otherwise, the plan is re-resolved within the transaction at line 467.

however, if an external caller passes a "ready" plan that was resolved before entering the transaction, there's still a small TOCTOU window. the current implementation mitigates this since resolvedPlan.storage is what gets persisted, not a re-read. this is acceptable for the current use case.


495-507: rollbackLastCodexCliSync throws on non-restored status.

this throwing variant is suitable for test helpers but production callers should use rollbackLatestCodexCliSync which returns typed results. per context snippet 4 (lib/codex-manager/settings-hub.ts:337-345), production code correctly uses the non-throwing variant.


973-979: lgtm: applyCodexCliSyncToStorage extended with trigger parameter.

the optional trigger parameter defaults to "automatic" and is correctly propagated through the sync run creation.


1040-1048: rollback snapshot only captured for manual trigger with changes.

the conditional at lib/codex-cli/sync.ts:1040-1041 correctly limits snapshot capture to manual triggers that result in changes, avoiding unnecessary i/o for automatic syncs or no-op manual syncs.

Comment on lines +359 to +507
async function captureRollbackSnapshot(): Promise<CodexCliSyncRollbackSnapshot | null> {
const snapshot: NamedBackupMetadata | null = await snapshotAccountStorage({
reason: "codex-cli-sync",
failurePolicy: "warn",
});
if (!snapshot) return null;
return {
name: snapshot.name,
path: snapshot.path,
};
}

function isManualChangedSyncRun(run: CodexCliSyncRun | null): run is CodexCliSyncRun {
return Boolean(run && run.outcome === "changed" && run.trigger === "manual");
}

async function findLatestManualRollbackRun(): Promise<
CodexCliSyncRun | null
> {
const history = await readSyncHistory({ kind: "codex-cli-sync" });
for (let index = history.length - 1; index >= 0; index -= 1) {
const entry = history[index];
if (!entry || entry.kind !== "codex-cli-sync") continue;
const run = normalizeCodexCliSyncRun(entry.run);
if (isManualChangedSyncRun(run)) {
return run;
}
}
return null;
}

async function loadRollbackSnapshot(
snapshot: CodexCliSyncRollbackSnapshot | null,
): Promise<CodexCliSyncRollbackPlan> {
if (!snapshot) {
return {
status: "unavailable",
reason: "No rollback checkpoint is available for the last manual apply.",
snapshot: null,
};
}
if (!snapshot.name.trim()) {
return {
status: "unavailable",
reason: "Rollback checkpoint is missing its snapshot name.",
snapshot: null,
};
}
if (!snapshot.path.trim()) {
return {
status: "unavailable",
reason: "Rollback checkpoint is missing its snapshot path.",
snapshot: null,
};
}

try {
const raw = await fs.readFile(snapshot.path, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeAccountStorage(parsed);
if (!normalized) {
return {
status: "unavailable",
reason: "Rollback checkpoint is invalid or empty.",
snapshot,
};
}
return {
status: "ready",
reason: `Rollback checkpoint ready (${normalized.accounts.length} account(s)).`,
snapshot,
accountCount: normalized.accounts.length,
storage: normalized,
};
} catch (error) {
const reason =
(error as NodeJS.ErrnoException).code === "ENOENT"
? `Rollback checkpoint is missing at ${snapshot.path}.`
: `Failed to read rollback checkpoint: ${
error instanceof Error ? error.message : String(error)
}`;
return {
status: "unavailable",
reason,
snapshot,
};
}
}

export async function getLatestCodexCliSyncRollbackPlan(): Promise<CodexCliSyncRollbackPlan> {
const lastManualRun = await findLatestManualRollbackRun();
if (!lastManualRun) {
return {
status: "unavailable",
reason: "No manual Codex CLI apply with a rollback checkpoint is available.",
snapshot: null,
};
}
return loadRollbackSnapshot(lastManualRun.rollbackSnapshot);
}

export async function rollbackLatestCodexCliSync(
plan?: CodexCliSyncRollbackPlan,
): Promise<CodexCliSyncRollbackResult> {
return withAccountStorageTransaction(async (_current, persist) => {
const resolvedPlan =
plan && plan.status === "ready"
? plan
: await getLatestCodexCliSyncRollbackPlan();
if (resolvedPlan.status !== "ready" || !resolvedPlan.storage) {
return {
status: "unavailable",
reason: resolvedPlan.reason,
snapshot: resolvedPlan.snapshot,
};
}

try {
await persist(resolvedPlan.storage);
return {
status: "restored",
reason: resolvedPlan.reason,
snapshot: resolvedPlan.snapshot,
accountCount:
resolvedPlan.accountCount ?? resolvedPlan.storage.accounts.length,
};
} catch (error) {
return {
status: "error",
reason: error instanceof Error ? error.message : String(error),
snapshot: resolvedPlan.snapshot,
};
}
});
}

export async function rollbackLastCodexCliSync(): Promise<
CodexCliSyncRollbackResult & { status: "restored"; snapshot: CodexCliSyncRollbackSnapshot }
> {
const result = await rollbackLatestCodexCliSync();
if (result.status !== "restored" || !result.snapshot) {
throw new Error(result.reason);
}
return {
...result,
status: "restored",
snapshot: result.snapshot,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# search for test coverage of rollback functions
rg -n 'rollback|captureRollback|loadRollback' test/ --type ts -C2 | head -80

Repository: ndycode/codex-multi-auth

Length of output: 4269


🏁 Script executed:

#!/bin/bash
# Find test files related to sync.ts
fd -e test.ts -e spec.ts | xargs grep -l "captureRollbackSnapshot\|loadRollbackSnapshot\|getLatestCodexCliSyncRollbackPlan\|rollbackLatestCodexCliSync\|rollbackLastCodexCliSync"

Repository: ndycode/codex-multi-auth

Length of output: 126


🏁 Script executed:

#!/bin/bash
# Also search for any test file that imports from sync.ts
rg "from.*sync.ts|import.*sync.ts" test/ --type ts

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check if there's a test file for codex-cli sync specifically
fd "sync" test/ --type ts

Repository: ndycode/codex-multi-auth

Length of output: 237


🏁 Script executed:

#!/bin/bash
# Read the codex-cli-sync.test.ts file to check what tests exist
wc -l test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 100


🏁 Script executed:

#!/bin/bash
# Get the file size first, then read it
head -200 test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 6286


🏁 Script executed:

#!/bin/bash
# Search for tests that specifically test rollback functions
rg -n "getLatestCodexCliSyncRollbackPlan|rollbackLatestCodexCliSync|rollbackLastCodexCliSync|captureRollbackSnapshot|loadRollbackSnapshot" test/codex-cli-sync.test.ts -A5 | head -150

Repository: ndycode/codex-multi-auth

Length of output: 2763


🏁 Script executed:

#!/bin/bash
# Search for test descriptions that describe the rollback behavior
rg -n "it\(" test/codex-cli-sync.test.ts | grep -i "rollback\|snapshot"

Repository: ndycode/codex-multi-auth

Length of output: 641


🏁 Script executed:

#!/bin/bash
# Look for tests around line 1565-1845 to understand the complete test suite for rollback
sed -n '1540,1850p' test/codex-cli-sync.test.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 10712


🏁 Script executed:

#!/bin/bash
# Search for tests related to captureRollbackSnapshot and snapshot creation
rg -n "captureRollbackSnapshot" test/codex-cli-sync.test.ts -B3 -A3

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Look for the "records rollback snapshot metadata" test which creates the snapshot
sed -n '1349,1430p' test/codex-cli-sync.test.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 3036


🏁 Script executed:

#!/bin/bash
# Check which functions are exported from sync.ts
rg "^export" lib/codex-cli/sync.ts

Repository: ndycode/codex-multi-auth

Length of output: 1197


🏁 Script executed:

#!/bin/bash
# Check if captureRollbackSnapshot and loadRollbackSnapshot are defined and exported
sed -n '359,507p' lib/codex-cli/sync.ts | rg "export|async function"

Repository: ndycode/codex-multi-auth

Length of output: 452


🏁 Script executed:

#!/bin/bash
# Search for tests that check invalid JSON or JSON parse errors
rg -n "JSON\|parse\|invalid" test/codex-cli-sync.test.ts | grep -i "json\|invalid" | head -20

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check the full test file around the missing file test to see all error scenarios
sed -n '1688,1850p' test/codex-cli-sync.test.ts | grep -n "it\|expect"

Repository: ndycode/codex-multi-auth

Length of output: 1556


🏁 Script executed:

#!/bin/bash
# Search for any tests that test JSON parse errors or invalid JSON scenarios
rg -n "invalid\|corrupted\|malformed" test/codex-cli-sync.test.ts -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check if there are tests for scenarios without manual runs
rg -n "no.*manual\|unavailable" test/codex-cli-sync.test.ts | head -20

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Look at the actual function implementations to understand what errors are possible
sed -n '359,430p' lib/codex-cli/sync.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 2565


🏁 Script executed:

#!/bin/bash
# Check if there's a test for when no manual runs exist
sed -n '1500,1600p' test/codex-cli-sync.test.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 3546


🏁 Script executed:

#!/bin/bash
# Look at the complete test descriptions to see what scenarios are covered
rg "it\(" test/codex-cli-sync.test.ts -A1 | grep -A1 "rollback\|snapshot" | head -40

Repository: ndycode/codex-multi-auth

Length of output: 1050


🏁 Script executed:

#!/bin/bash
# Search for test that checks when there are NO manual runs in history
rg -n "no.*manual\|empty.*history\|getLatestCodexCliSyncRollbackPlan\(\)" test/codex-cli-sync.test.ts -B3 -A5 | head -60

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check what happens when getLatestCodexCliSyncRollbackPlan is called with empty history
sed -n '1545,1577p' test/codex-cli-sync.test.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 1259


🏁 Script executed:

#!/bin/bash
# Search if there's any test that doesn't set up any manual runs
rg -n "const plan = await getLatestCodexCliSyncRollbackPlan" test/codex-cli-sync.test.ts -B10 | grep -E "it\(|const plan"

Repository: ndycode/codex-multi-auth

Length of output: 320


🏁 Script executed:

#!/bin/bash
# Look at lines around the first rollback test to see what the setup is
sed -n '1530,1580p' test/codex-cli-sync.test.ts | head -60

Repository: ndycode/codex-multi-auth

Length of output: 1742


🏁 Script executed:

#!/bin/bash
# Let's check if there's a test that runs without setting up any history
sed -n '1, 200p' test/codex-cli-sync.test.ts | tail -50

Repository: ndycode/codex-multi-auth

Length of output: 1048


🏁 Script executed:

#!/antml:
# Look for all tests that start fresh with no pre-existing sync history
rg "it\(" test/codex-cli-sync.test.ts | head -30

Repository: ndycode/codex-multi-auth

Length of output: 2882


🏁 Script executed:

#!/bin/bash
# Find the test "restores the latest manual apply"
rg -n "restores the latest manual apply" test/codex-cli-sync.test.ts -A30

Repository: ndycode/codex-multi-auth

Length of output: 1080


🏁 Script executed:

#!/antml bash
# Check if there's a test where history is completely empty or no manual runs exist
sed -n '1700,1730p' test/codex-cli-sync.test.ts | cat -n

Repository: ndycode/codex-multi-auth

Length of output: 1163


🏁 Script executed:

#!/antml:
# Check if there's a test for when no manual runs exist (empty scenario)
rg -n "No manual Codex CLI" test/codex-cli-sync.test.ts -B5 -A5

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/antml/bash
# Search for tests of invalid/corrupted JSON in rollback snapshot
rg -n "JSON\|parse\|corrupted\|invalid or empty" test/codex-cli-sync.test.ts -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/an/bash
# Search for any test that explicitly tests scenarios without pre-populated history
rg -n "\.test\.ts.*it\(" test/codex-cli-sync.test.ts | wc -l

Repository: ndycode/codex-multi-auth

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Look at the function that returns "No manual Codex CLI apply" - check if it's tested
rg -n "No manual Codex CLI apply with a rollback checkpoint" lib/codex-cli/sync.ts -B5 -A5

Repository: ndycode/codex-multi-auth

Length of output: 498


🏁 Script executed:

#!/bin/bash
# Check if there's test coverage for the normalizeAccountStorage path 
rg -n "invalid or empty\|Rollback checkpoint is invalid" test/codex-cli-sync.test.ts -B3 -A3

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Get a full list of test descriptions to verify coverage
rg "it\(" test/codex-cli-sync.test.ts | grep -i "rollback\|snapshot"

Repository: ndycode/codex-multi-auth

Length of output: 612


🏁 Script executed:

#!/bin/bash
# Check what all the rollback-related test descriptions say
sed -n '1349,1360p' test/codex-cli-sync.test.ts
sed -n '1428,1438p' test/codex-cli-sync.test.ts  
sed -n '1471,1478p' test/codex-cli-sync.test.ts
sed -n '1545,1555p' test/codex-cli-sync.test.ts
sed -n '1578,1585p' test/codex-cli-sync.test.ts
sed -n '1688,1695p' test/codex-cli-sync.test.ts
sed -n '1726,1732p' test/codex-cli-sync.test.ts
sed -n '1773,1778p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 2441


test coverage exists for rollback functions but has gaps in edge cases.

the three exported rollback functions (getLatestCodexCliSyncRollbackPlan, rollbackLatestCodexCliSync, rollbackLastCodexCliSync) are tested in test/codex-cli-sync.test.ts with coverage for success paths and several error scenarios:

✓ test/codex-cli-sync.test.ts:1349 - snapshot capture on manual apply
✓ test/codex-cli-sync.test.ts:1471 - successful rollback restore
✓ test/codex-cli-sync.test.ts:1578 - rollback serialization against concurrent writes
✓ test/codex-cli-sync.test.ts:1688 - missing file (ENOENT) handling
✓ test/codex-cli-sync.test.ts:1726 - blank name/path validation
✓ test/codex-cli-sync.test.ts:1773 - persist failure handling

however, these edge cases are missing test coverage:

  • no manual runs in sync history (the "No manual Codex CLI apply..." path at lib/codex-cli/sync.ts:453)
  • corrupted/invalid json in snapshot file (would hit the normalizeAccountStorage null case at lib/codex-cli/sync.ts:61-66)

add tests for these two scenarios to close the gaps.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-cli/sync.ts` around lines 359 - 507, Add two unit tests: (1)
exercise getLatestCodexCliSyncRollbackPlan when there are no manual "changed"
runs by stubbing readSyncHistory so findLatestManualRollbackRun returns null and
assert the returned plan has status "unavailable" and reason "No manual Codex
CLI apply..."; (2) exercise loadRollbackSnapshot handling of corrupted JSON by
creating a CodexCliSyncRollbackSnapshot with a real temp file containing invalid
JSON (or stub fs.readFile to return malformed text), call loadRollbackSnapshot
and assert it returns status "unavailable" with reason "Rollback checkpoint is
invalid or empty." (use
normalizeAccountStorage/readSyncHistory/loadRollbackSnapshot/getLatestCodexCliSyncRollbackPlan
to locate behavior).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/codex-manager/settings-hub.ts (1)

2815-2963: ⚠️ Potential issue | 🟠 Major

add integration tests for sync-center rollback action execution and hotkey gating.

the rollback flow at lib/codex-manager/settings-hub.ts:2879 gates the l hotkey correctly and lib/codex-manager/settings-hub.ts:2896-2915 wraps execution with withQueuedRetry to handle retries, but test coverage is incomplete. test/codex-manager-cli.test.ts tests rollback plan loading failure (line 4988) and underlying sync functions in test/codex-cli-sync.test.ts, but there are no regression tests for:

  • l hotkey triggering rollback action when plan ready
  • rollback action failure updating preview to "error" status and keeping sync-center interactive
  • rollback action retries for EBUSY/429 during execution (not just preview loading)

add tests that mock rollbackLatestCodexCliSyncMock with both success and failure outcomes, verify the UI loop behavior, and confirm retry logic applies to the rollback execution path via withQueuedRetry(preview.targetPath, ...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager/settings-hub.ts` around lines 2815 - 2963, Add integration
tests exercising the sync-center rollback action: simulate a ready rollback plan
from getSyncCenterRollbackPlan and assert that pressing the "l" hotkey triggers
the rollback action (the menu's onInput mapping) by executing
runSyncCenterRollback via withQueuedRetry(preview.targetPath, ...). Create cases
where the mocked rollback implementation (rollbackLatestCodexCliSyncMock /
runSyncCenterRollback) returns success and where it throws/fails; for the
failure case assert preview is updated to status "error" with statusDetail and
the UI loop remains interactive (does not exit), and for retry behavior mock
transient EBUSY/429 errors and assert withQueuedRetry retries the rollback
execution path rather than only the preview load. Reference the interactive loop
in settings-hub (the select onInput handler and the result.type === "rollback"
branch) and ensure tests hook or spy on withQueuedRetry to confirm it wraps the
rollback call.
♻️ Duplicate comments (5)
test/codex-manager-cli.test.ts (4)

3281-3321: ⚠️ Potential issue | 🟡 Minor

add ansi/control-char sanitization assertions for health summary row.

test/codex-manager-cli.test.ts:3281-3321 validates disabled-row rendering, but it still does not prove sanitization of healthSummary.label and healthSummary.hint before display in lib/ui/auth-menu.ts:line.

proposed regression assertion update
 		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",
+					label:
+						"\u001b[31mPool 1 active | Sync none | Restore none | Rollback none | Doctor ok\u001b[0m",
+					hint: "Accounts: 1 enabled / 0 disabled / 1 total\u0007",
 				},
 			},
 		);
@@
 		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",
 			}),
 		);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3281 - 3321, Update the test for
showAuthMenu to assert that healthSummary.label and healthSummary.hint are
sanitized of ANSI/control characters before rendering: in the existing test use
selectMock to capture items (the items array from selectMock.mock.calls[0][0]),
locate the health summary row via headingIndex (finding item.label === "Health
Summary" && item.kind === "heading"), then add assertions on items[headingIndex
+ 1] that verify its label and hint do not contain control/ANSI escape sequences
(e.g. no \u001b or other control chars) to prove lib/ui/auth-menu.ts sanitizes
healthSummary.label and healthSummary.hint.

3144-3147: ⚠️ Potential issue | 🟡 Minor

use a real assessment shape in this health-summary fixture.

Line 3145 in test/codex-manager-cli.test.ts uses assessments: [{}]. this can hide nested access regressions when lib/codex-manager.ts:line consumes assessment fields.

proposed test-fixture fix
 		getActionableNamedBackupRestoresMock.mockResolvedValue({
-			assessments: [{}],
+			assessments: [
+				{
+					backup: {
+						name: "mock-backup",
+						path: "/mock/backups/mock-backup.json",
+						createdAt: null,
+						updatedAt: now,
+						sizeBytes: 128,
+						version: 3,
+						accountCount: 1,
+						schemaErrors: [],
+						valid: true,
+						loadError: undefined,
+					},
+					currentAccountCount: 2,
+					mergedAccountCount: 2,
+					imported: 0,
+					skipped: 0,
+					wouldExceedLimit: false,
+					eligibleForRestore: true,
+					error: undefined,
+				},
+			],
+			allAssessments: [],
 			totalBackups: 2,
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3144 - 3147, The fixture uses an
empty assessment object in
getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [{}], ...
}), which can hide regressions when lib/codex-manager.ts reads assessment
properties; replace the empty object with a realistic assessment shape
containing the exact fields the code consumes (e.g., id/backupId, status,
createdAt, health/metrics, actionable flags or actions array, and any nested
objects accessed) so tests exercise the same nested accesses used by functions
in lib/codex-manager.ts (reference getActionableNamedBackupRestoresMock and the
health-summary test fixture) and update any other assertions in
test/codex-manager-cli.test.ts that expect specific values.

779-779: 🧹 Nitpick | 🔵 Trivial

remove duplicate default sync-run mock setup.

Line 779 repeats the same default already set at Line 737 in test/codex-manager-cli.test.ts, which adds setup noise without value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` at line 779, Remove the duplicated mock setup
call getLastCodexCliSyncRunMock.mockReturnValue(null) at the later occurrence
(the one around line 779) since the same default is already established earlier
in the test file (around the earlier setup). Keep the initial setup where
getLastCodexCliSyncRunMock is configured and delete the redundant call to avoid
noisy duplicate test setup.

4988-5052: ⚠️ Potential issue | 🟠 Major

do not lock in 4 rollback-plan reads; add explicit win32 eperm regression.

Line 5050 in test/codex-manager-cli.test.ts hard-codes toHaveBeenCalledTimes(4) for getLatestCodexCliSyncRollbackPlanMock, which bakes duplicate rollback-plan io into behavior exercised by lib/codex-manager.ts:line. this increases fs contention risk under windows load. this new failure-path test also only exercises EBUSY (Line 5019-5021), leaving EPERM behavior unguarded.

proposed direction
-		expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalledTimes(4);
+		// one rollback-plan load per sync-center render cycle (open + refresh)
+		expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalledTimes(2);
it("keeps sync-center recoverable when rollback plan loading fails with eperm", async () => {
  // mirror the existing ebusy test setup, but use:
  // getLatestCodexCliSyncRollbackPlanMock.mockRejectedValue(
  //   Object.assign(new Error("access denied"), { code: "EPERM" }),
  // );
  // assert degraded rollback text remains safe and flow continues.
});

As per coding guidelines, "test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 4988 - 5052, The test currently
hard-codes
expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalledTimes(4) which
locks in the exact number of rollback-plan reads; change that assertion to a
non-exact check (e.g.,
expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalled() or
.toHaveBeenCalledTimesGreaterThanOrEqual(1)) so the test does not enforce
duplicate IO, and add a new sibling test named like "keeps sync-center
recoverable when rollback plan loading fails with eperm" that mirrors this EBUSY
setup but uses
getLatestCodexCliSyncRollbackPlanMock.mockRejectedValue(Object.assign(new
Error("access denied"), { code: "EPERM" })) and asserts the CLI still shows
degraded rollback text and exits 0 (reusing runCodexMultiAuthCli,
previewCodexCliSyncMock, selectMock flows and asserting
applyCodexCliSyncToStorageMock not called).
test/codex-cli-sync.test.ts (1)

1638-1746: ⚠️ Potential issue | 🟠 Major

still missing the stale-plan race for rollbackLatestCodexCliSync(plan).

test/codex-cli-sync.test.ts:1638-1746 only exercises rollbackLastCodexCliSync() while it resolves the plan internally. rollbackLatestCodexCliSync(plan) is touched in test/codex-cli-sync.test.ts:1976-1982, but only on a forced save failure. there is still no regression that preloads a ready plan, mutates storage/history, and then executes the explicit-plan rollback, so the toctou path remains uncovered.

As per coding guidelines, test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Also applies to: 1976-1982

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-cli-sync.test.ts` around lines 1638 - 1746, Add a deterministic
test that exercises the TOCTOU (stale-plan) path by preparing a ready rollback
plan (call rollbackLatestCodexCliSync(plan) with an explicitly preloaded plan
object that you obtain from the same plan-builder used in tests), then before
invoking rollbackLatestCodexCliSync(plan) mutate storage/history concurrently
(use storageModule.withAccountStorageTransaction or spy on it like the existing
test does to start a concurrent storageModule.saveAccounts() and capture its
Promise) so the explicit-plan rollback sees stale storage; assert the rollback
result and final stored accounts reflect the concurrent mutation (await the
concurrent save Promise and verify refreshToken/account list), and ensure the
test uses vitest constructs (vi.spyOn) and temp fixtures to remain
deterministic; reference rollbackLatestCodexCliSync(plan),
rollbackLastCodexCliSync, storageModule.withAccountStorageTransaction, and
storageModule.saveAccounts to locate where to add the new case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/codex-cli/sync.ts`:
- Around line 493-502: Add a new vitest in test/codex-cli-sync.test.ts that
covers the path where findLatestManualRollbackRun() returns null: call
getLatestCodexCliSyncRollbackPlan() and assert the returned object has status
=== "unavailable", reason === "No manual Codex CLI apply with a rollback
checkpoint is available.", and snapshot === null; set up the test scenario so
sync history has only automatic runs or is empty (mock or stub
findLatestManualRollbackRun to return null) and reference this new test in the
lib/** change note per coding guidelines.
- Around line 42-49: Add a parametrized vitest that covers the Windows EPERM
retry path: mock process.platform to "win32" and have the stubbed
retryTransientRollbackIo (or the function that throws within the rollback loop)
throw an error with code "EPERM" so that isRetryableRollbackIoErrorCode returns
true for the retry loop and the loop completes successfully; specifically,
update the existing codex-cli sync tests to include a case where
retryTransientRollbackIo throws { code: "EPERM" } and assert the retry logic
retries and resolves (or completes) rather than failing, ensuring the test
toggles process.platform back after the case to avoid global test pollution.

In `@lib/codex-manager/settings-hub.ts`:
- Around line 2895-2914: The rollback branch calls
withQueuedRetry(preview.targetPath, () => runSyncCenterRollback(rollbackPlan))
without a local try/catch, so any thrown error will escape the sync-center loop;
wrap that call in a try/catch around the withQueuedRetry invocation, and on
catch set preview = { ...preview, status: "error", lastSync:
getLastCodexCliSyncRun(), statusDetail: (err && err.message) || String(err) },
then refresh rollbackPlan = await getSyncCenterRollbackPlan() and continue the
loop; reference the functions/vars withQueuedRetry, runSyncCenterRollback,
preview, rollbackPlan, getLastCodexCliSyncRun, getSyncCenterRollbackPlan, and
preserve existing flow when rollbackResult is returned successfully.

In `@lib/storage.ts`:
- Around line 2279-2286: The function pruneAutoGeneratedSnapshots silently
clamps the option keepLatestPerReason to a minimum of 1 (via Math.max) which can
surprise callers who pass 0; update the AutoSnapshotPruneOptions JSDoc (and/or
the pruneAutoGeneratedSnapshots docblock) to explicitly state that
keepLatestPerReason is clamped to a minimum of 1 and describe the behavior when
callers pass 0 or negative values, referencing the pruneAutoGeneratedSnapshots
function and the keepLatestPerReason variable so readers know the enforced
minimum.

In `@test/codex-cli-sync.test.ts`:
- Around line 1428-1486: The test only exercises the EBUSY retry path; add a
sibling vitest case that mocks process.platform to "win32" and simulates a
transient NodeJS.ErrnoException with code "EPERM" on
storageModule.snapshotAccountStorage (use the same mock pattern:
mockRejectedValueOnce(transientError) then mockResolvedValueOnce(...)) to drive
the Win32-specific retry branch in applyCodexCliSyncToStorage; ensure the test
asserts snapshotAccountStorage was called twice and that
result.pendingRun?.run.rollbackSnapshot matches the resolved snapshot, and
restore process.platform after the test to keep tests deterministic.

In `@test/storage.test.ts`:
- Around line 2301-2342: The test currently only checks that a snapshot file was
created by importAccounts; update the test so it reads and parses the created
snapshot to assert it was taken before the merge (i.e., contains only the
original "existing" account). After calling importAccounts(importPath) use
getNamedBackupsDirectoryPath() to find the backup file whose name
startsWith("accounts-import-accounts-snapshot-"), read and JSON.parse that
snapshot file, and assert snapshot.accounts has length 1 and
snapshot.accounts[0].accountId === "existing" (keep the existing expect that
final loadAccounts() has length 2). This ensures importAccounts() snapshotting
occurs pre-merge and keeps the test deterministic under vitest.

---

Outside diff comments:
In `@lib/codex-manager/settings-hub.ts`:
- Around line 2815-2963: Add integration tests exercising the sync-center
rollback action: simulate a ready rollback plan from getSyncCenterRollbackPlan
and assert that pressing the "l" hotkey triggers the rollback action (the menu's
onInput mapping) by executing runSyncCenterRollback via
withQueuedRetry(preview.targetPath, ...). Create cases where the mocked rollback
implementation (rollbackLatestCodexCliSyncMock / runSyncCenterRollback) returns
success and where it throws/fails; for the failure case assert preview is
updated to status "error" with statusDetail and the UI loop remains interactive
(does not exit), and for retry behavior mock transient EBUSY/429 errors and
assert withQueuedRetry retries the rollback execution path rather than only the
preview load. Reference the interactive loop in settings-hub (the select onInput
handler and the result.type === "rollback" branch) and ensure tests hook or spy
on withQueuedRetry to confirm it wraps the rollback call.

---

Duplicate comments:
In `@test/codex-cli-sync.test.ts`:
- Around line 1638-1746: Add a deterministic test that exercises the TOCTOU
(stale-plan) path by preparing a ready rollback plan (call
rollbackLatestCodexCliSync(plan) with an explicitly preloaded plan object that
you obtain from the same plan-builder used in tests), then before invoking
rollbackLatestCodexCliSync(plan) mutate storage/history concurrently (use
storageModule.withAccountStorageTransaction or spy on it like the existing test
does to start a concurrent storageModule.saveAccounts() and capture its Promise)
so the explicit-plan rollback sees stale storage; assert the rollback result and
final stored accounts reflect the concurrent mutation (await the concurrent save
Promise and verify refreshToken/account list), and ensure the test uses vitest
constructs (vi.spyOn) and temp fixtures to remain deterministic; reference
rollbackLatestCodexCliSync(plan), rollbackLastCodexCliSync,
storageModule.withAccountStorageTransaction, and storageModule.saveAccounts to
locate where to add the new case.

In `@test/codex-manager-cli.test.ts`:
- Around line 3281-3321: Update the test for showAuthMenu to assert that
healthSummary.label and healthSummary.hint are sanitized of ANSI/control
characters before rendering: in the existing test use selectMock to capture
items (the items array from selectMock.mock.calls[0][0]), locate the health
summary row via headingIndex (finding item.label === "Health Summary" &&
item.kind === "heading"), then add assertions on items[headingIndex + 1] that
verify its label and hint do not contain control/ANSI escape sequences (e.g. no
\u001b or other control chars) to prove lib/ui/auth-menu.ts sanitizes
healthSummary.label and healthSummary.hint.
- Around line 3144-3147: The fixture uses an empty assessment object in
getActionableNamedBackupRestoresMock.mockResolvedValue({ assessments: [{}], ...
}), which can hide regressions when lib/codex-manager.ts reads assessment
properties; replace the empty object with a realistic assessment shape
containing the exact fields the code consumes (e.g., id/backupId, status,
createdAt, health/metrics, actionable flags or actions array, and any nested
objects accessed) so tests exercise the same nested accesses used by functions
in lib/codex-manager.ts (reference getActionableNamedBackupRestoresMock and the
health-summary test fixture) and update any other assertions in
test/codex-manager-cli.test.ts that expect specific values.
- Line 779: Remove the duplicated mock setup call
getLastCodexCliSyncRunMock.mockReturnValue(null) at the later occurrence (the
one around line 779) since the same default is already established earlier in
the test file (around the earlier setup). Keep the initial setup where
getLastCodexCliSyncRunMock is configured and delete the redundant call to avoid
noisy duplicate test setup.
- Around line 4988-5052: The test currently hard-codes
expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalledTimes(4) which
locks in the exact number of rollback-plan reads; change that assertion to a
non-exact check (e.g.,
expect(getLatestCodexCliSyncRollbackPlanMock).toHaveBeenCalled() or
.toHaveBeenCalledTimesGreaterThanOrEqual(1)) so the test does not enforce
duplicate IO, and add a new sibling test named like "keeps sync-center
recoverable when rollback plan loading fails with eperm" that mirrors this EBUSY
setup but uses
getLatestCodexCliSyncRollbackPlanMock.mockRejectedValue(Object.assign(new
Error("access denied"), { code: "EPERM" })) and asserts the CLI still shows
degraded rollback text and exits 0 (reusing runCodexMultiAuthCli,
previewCodexCliSyncMock, selectMock flows and asserting
applyCodexCliSyncToStorageMock not called).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 09336b4c-b9f9-4a7f-8506-96736d872ecf

📥 Commits

Reviewing files that changed from the base of the PR and between 01ba63d and 893bfbf.

📒 Files selected for processing (8)
  • lib/codex-cli/sync.ts
  • lib/codex-manager/settings-hub.ts
  • lib/storage.ts
  • lib/sync-history.ts
  • test/codex-cli-sync.test.ts
  • test/codex-manager-cli.test.ts
  • test/storage.test.ts
  • test/sync-history.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/storage.test.ts
  • test/codex-cli-sync.test.ts
  • test/codex-manager-cli.test.ts
  • test/sync-history.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/sync-history.ts
  • lib/storage.ts
  • lib/codex-manager/settings-hub.ts
  • lib/codex-cli/sync.ts
🔇 Additional comments (23)
lib/codex-manager/settings-hub.ts (1)

2845-2851: rollback gating is implemented correctly.

at lib/codex-manager/settings-hub.ts:2845 (line 2845) and lib/codex-manager/settings-hub.ts:2879 (line 2879), both menu selection and hotkey path are gated on rollbackPlan.status === "ready". this is the right safety check for interactive rollback.

Also applies to: 2879-2881

test/codex-manager-cli.test.ts (1)

3204-3254: good fallback redaction regression coverage.

this is a solid guardrail in test/codex-manager-cli.test.ts:3204-3254: you assert warning emission while blocking raw ebusy text and raw path leakage in the output path exercised by lib/codex-manager.ts:line.

lib/storage.ts (10)

51-53: looks good.

constants are well-defined. the regex pattern correctly captures the reason group for snapshot classification.


211-226: types are clean.

the AccountSnapshotFailurePolicy gives callers explicit control over error handling behavior—nice for making rollback-critical paths fail loudly while allowing doctor flows to proceed.


2183-2210: timestamp and naming helpers are solid.

using UTC ensures consistent snapshot ordering across timezones. the millisecond precision in timestamps prevents collisions during rapid successive operations.


2212-2232: parsing logic is clean.

the fallback chain updatedAt ?? createdAt ?? 0 handles edge cases where filesystem metadata may be missing.


2234-2266: rollback snapshot name lookup is well-guarded.

dynamic import of ./sync-history.js avoids circular dependency issues. the function correctly finds the most recent manual "changed" run and extracts its snapshot name for retention.


2340-2344: retention enforcement is straightforward.

delegates to pruneAutoGeneratedSnapshots with the module constant. clean.


2346-2370: path redaction helpers are well-designed.

the regex at lib/storage.ts:2352-2355 handles both unix and windows paths, including drive letters. this prevents leaking usernames embedded in filesystem paths to logs.


2372-2426: snapshotAccountStorage implementation is solid.

the failurePolicy parameter gives callers control over error propagation. error messages use formatSnapshotErrorForLog at lib/storage.ts:2391,2410,2421 to redact paths. the retention enforcement at line 2416 ensures stale snapshots are cleaned up even if the current snapshot isn't used.


1995-1998: listAccountSnapshots is clean.

simple filter over listNamedBackups using the isAccountSnapshotName predicate.


3811-3813: pre-import snapshot placement is correct.

capturing the snapshot before the limit check at lib/storage.ts:3813 ensures a rollback checkpoint exists even if the import fails validation. using the default "warn" policy lets imports proceed when snapshot creation fails transiently.

lib/codex-cli/sync.ts (11)

112-132: rollback types are well-structured.

the CodexCliSyncRollbackPlan includes the loaded storage for ready plans, avoiding redundant reads during rollback execution.


199-228: normalization helpers are defensive.

normalizeRollbackSnapshot at lib/codex-cli/sync.ts:199-212 validates both name and path are strings before returning, preventing downstream crashes from malformed history entries.


292-302: createSyncRun signature extension is clean.

defaults ensure backward compatibility for existing callers not passing trigger or rollbackSnapshot.


356-361: rollback snapshot normalization on commit is correct.

normalizing at publish time at lib/codex-cli/sync.ts:356-358 ensures history entries have consistent structure regardless of how the pending run was constructed.


392-412: captureRollbackSnapshot retry handling is solid.

using failurePolicy: "error" at lib/codex-cli/sync.ts:398 ensures errors propagate to the retry wrapper. after retries exhaust, the function degrades gracefully by logging and returning null.


414-431: findLatestManualRollbackRun is efficient.

backwards iteration at lib/codex-cli/sync.ts:422 finds the most recent matching entry without scanning the entire history.


433-491: loadRollbackSnapshot validation and retry are correct.

input validation at lib/codex-cli/sync.ts:443-456 prevents unnecessary I/O. the retry wrapper at line 459 handles Windows file locks. error messages at lines 479-488 are descriptive without leaking sensitive data.


505-538: rollbackLatestCodexCliSync uses transaction for atomicity.

wrapping the persist in withAccountStorageTransaction at lib/codex-cli/sync.ts:508 ensures the rollback is atomic. accepting a pre-resolved plan avoids redundant reads when the caller has already validated availability.


540-552: rollbackLastCodexCliSync provides throwing semantics.

callers who need a guaranteed rollback can use this instead of checking the result object. the runtime check at lib/codex-cli/sync.ts:544 validates the type assertion.


1085-1093: trigger-conditional rollback snapshot is efficient.

only capturing snapshots for manual triggers at lib/codex-cli/sync.ts:1086 avoids disk overhead during frequent automatic syncs. the snapshot is attached to the run for history tracking.


280-290: error runs correctly clear rollback snapshot.

setting rollbackSnapshot: null at lib/codex-cli/sync.ts:288 ensures failed sync runs don't reference snapshots that may be in an inconsistent state.

Comment on lines +42 to +49
function isRetryableRollbackIoErrorCode(
code: string | undefined,
): code is "EBUSY" | "EAGAIN" | "EPERM" {
if (code && RETRYABLE_ROLLBACK_IO_CODES.has(code)) {
return true;
}
return code === "EPERM" && process.platform === "win32";
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check for existing tests covering EPERM retry on windows
rg -n "EPERM|win32" test/codex-cli-sync.test.ts -B2 -A2 | head -40

Repository: ndycode/codex-multi-auth

Length of output: 833


🏁 Script executed:

# search for specific tests of the rollback retry functions
rg -n "isRetryableRollbackIoErrorCode|retryTransientRollbackIo" test/ -B2 -A5

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# check if any tests mock process.platform
rg -n "process\.platform" test/codex-cli-sync.test.ts -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# get the full context of the EPERM tests to understand what they're testing
rg -n "it\.each.*EPERM" test/codex-cli-sync.test.ts -A10 | head -50

Repository: ndycode/codex-multi-auth

Length of output: 1266


🏁 Script executed:

# get the exact location and context of isRetryableRollbackIoErrorCode and retryTransientRollbackIo
rg -n "function isRetryableRollbackIoErrorCode|function retryTransientRollbackIo" lib/codex-cli/sync.ts -A8

Repository: ndycode/codex-multi-auth

Length of output: 600


🏁 Script executed:

# also check where retryTransientRollbackIo is used in the codebase
rg -n "retryTransientRollbackIo" lib/codex-cli/sync.ts

Repository: ndycode/codex-multi-auth

Length of output: 213


🏁 Script executed:

# see the full test names to understand what they're testing
rg -n "it\.each.*EPERM" test/codex-cli-sync.test.ts -B2 | grep -E "^\s*(it|describe)" | head -5

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# check if captureRollbackSnapshot or other rollback functions are tested
rg -n "captureRollbackSnapshot|rollbackLatestCodexCliSync|rollbackLastCodexCliSync" test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 690


🏁 Script executed:

# check what specific errors the rollback tests are throwing to see if they test the EPERM branch
rg -n "rollbackLastCodexCliSync|rollbackLatestCodexCliSync" test/codex-cli-sync.test.ts -A15 | grep -E "(throw|Error|EBUSY|EPERM|code)" | head -20

Repository: ndycode/codex-multi-auth

Length of output: 404


🏁 Script executed:

# also check the test around line 1638 to see what it's testing
sed -n '1638,1700p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1618


🏁 Script executed:

# search for test setup that throws EBUSY or EPERM during rollback operations
rg -n "rollbackLastCodexCliSync\|rollbackLatestCodexCliSync" test/codex-cli-sync.test.ts -B5 -A20 | grep -E "(throw|Error|EBUSY|EPERM|code:)" | head -20

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# check if there's any vitest mocking of file operations in rollback tests
rg -n "vi\.spyOn.*readFile|vi\.spyOn.*writeFile|vi\.spyOn.*unlink" test/codex-cli-sync.test.ts -A3 | head -30

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# let's see the full test suite for rollbackLastCodexCliSync to understand what's being tested
rg -n "it\(.*rollback" test/codex-cli-sync.test.ts -A2

Repository: ndycode/codex-multi-auth

Length of output: 1276


🏁 Script executed:

# check if the functions captureRollbackSnapshot and related rollback functions are tested at all
rg -n "captureRollbackSnapshot|loadRollbackSnapshot|getLatestCodexCliSyncRollbackPlan" test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 423


🏁 Script executed:

# check the EBUSY retry test at line 1428 to see what error codes it covers
sed -n '1428,1480p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1392


🏁 Script executed:

# check the rollback checkpoint read retry test at line 1786
sed -n '1786,1850p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1812


🏁 Script executed:

# verify what error codes are tested in the rollback retry tests - check if EPERM is covered
sed -n '1786,1900p' test/codex-cli-sync.test.ts | grep -E "(code|EBUSY|EAGAIN|EPERM)"

Repository: ndycode/codex-multi-auth

Length of output: 276


🏁 Script executed:

# check if any tests mock process.platform in the entire test file
rg -n "process\.platform|vi\.mock.*process" test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 50


add test for windows EPERM retry path in rollback i/o.

lib/codex-cli/sync.ts:48 returns true for EPERM when process.platform === "win32", but no test covers this branch. existing rollback tests at test/codex-cli-sync.test.ts:1428 and test/codex-cli-sync.test.ts:1786 throw EBUSY only. need a parametrized test that mocks process.platform to "win32" and throws EPERM from retryTransientRollbackIo to verify the retry loop completes successfully.

per coding guidelines: "lib/**: verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios." windows EPERM edge case is untested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-cli/sync.ts` around lines 42 - 49, Add a parametrized vitest that
covers the Windows EPERM retry path: mock process.platform to "win32" and have
the stubbed retryTransientRollbackIo (or the function that throws within the
rollback loop) throw an error with code "EPERM" so that
isRetryableRollbackIoErrorCode returns true for the retry loop and the loop
completes successfully; specifically, update the existing codex-cli sync tests
to include a case where retryTransientRollbackIo throws { code: "EPERM" } and
assert the retry logic retries and resolves (or completes) rather than failing,
ensuring the test toggles process.platform back after the case to avoid global
test pollution.

Comment on lines +462 to +470
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeAccountStorage(parsed);
if (!normalized) {
return {
status: "unavailable",
reason: "Rollback checkpoint is invalid or empty.",
snapshot,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check if there's a test for corrupted/invalid snapshot content
rg -n "invalid or empty\|corrupted\|malformed" test/codex-cli-sync.test.ts -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# first, verify the source file and code exists
head -n 475 lib/codex-cli/sync.ts | tail -n 20

Repository: ndycode/codex-multi-auth

Length of output: 586


🏁 Script executed:

# check if test file exists and get its size
wc -l test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 100


🏁 Script executed:

# search for loadRollbackSnapshot tests
rg -n "loadRollbackSnapshot\|normalizeAccountStorage" test/codex-cli-sync.test.ts -A5

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# check all test names in the test file to understand test structure
rg -n "describe\|it\(" test/codex-cli-sync.test.ts | head -30

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# search for snapshot or rollback tests
rg -n "snapshot\|rollback" test/codex-cli-sync.test.ts -i

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# get describe/it structure from test file
rg -n "describe\(|it\(" test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 3360


🏁 Script executed:

# check what normalizeAccountStorage does
rg -n "normalizeAccountStorage" lib/codex-cli/sync.ts -B2 -A10 | head -40

Repository: ndycode/codex-multi-auth

Length of output: 825


🏁 Script executed:

# look at the test that marks rollback plan unavailable (line 1748)
sed -n '1748,1785p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1177


🏁 Script executed:

# check what normalizeAccountStorage does in storage.ts
rg -n "normalizeAccountStorage" lib/ -A20 | head -60

Repository: ndycode/codex-multi-auth

Length of output: 2833


add regression test for corrupted snapshot content.

test covers missing checkpoint file at test/codex-cli-sync.test.ts:1748 but misses the normalizeAccountStorage null path at lib/codex-cli/sync.ts:464. create snapshot with valid json but invalid schema (missing accounts array, wrong version, etc.) and verify getLatestCodexCliSyncRollbackPlan returns status: "unavailable" with reason containing "invalid or empty". normalizeAccountStorage at lib/storage.ts:1673 validates against multiple schema rules, so the rollback flow needs coverage for that failure mode.

Comment on lines +493 to +502
export async function getLatestCodexCliSyncRollbackPlan(): Promise<CodexCliSyncRollbackPlan> {
const lastManualRun = await findLatestManualRollbackRun();
if (!lastManualRun) {
return {
status: "unavailable",
reason: "No manual Codex CLI apply with a rollback checkpoint is available.",
snapshot: null,
};
}
return loadRollbackSnapshot(lastManualRun.rollbackSnapshot);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# check if there's a test for the "no manual runs" scenario
rg -n "No manual Codex CLI" test/codex-cli-sync.test.ts -B3 -A5

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# Check if test file exists and search for related tests
fd "sync.test.ts" --type f

Repository: ndycode/codex-multi-auth

Length of output: 153


🏁 Script executed:

# Search for tests of getLatestCodexCliSyncRollbackPlan
rg -n "getLatestCodexCliSyncRollbackPlan" --type ts

Repository: ndycode/codex-multi-auth

Length of output: 2420


🏁 Script executed:

# Search for findLatestManualRollbackRun mocks or tests
rg -n "findLatestManualRollbackRun" test/ -A3 -B3

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# Read test cases for getLatestCodexCliSyncRollbackPlan around the lines found
sed -n '1620,1635p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 680


🏁 Script executed:

sed -n '1770,1790p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 728


🏁 Script executed:

sed -n '1850,1870p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 598


🏁 Script executed:

# Also check around the function definition to understand test structure
sed -n '1,50p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1875


🏁 Script executed:

# Search for tests that might set up automatic-only runs or mock findLatestManualRollbackRun returning null
rg -n "automatic" test/codex-cli-sync.test.ts -i -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 565


🏁 Script executed:

# Look for test descriptions around the unavailable scenarios
rg -n "unavailable|no manual|automatic" test/codex-cli-sync.test.ts -i -B3

Repository: ndycode/codex-multi-auth

Length of output: 1148


🏁 Script executed:

# Check if there's any test setup with only automatic runs in sync history
rg -n 'kind.*automatic|automatic.*run' test/codex-cli-sync.test.ts -i -B2 -A2

Repository: ndycode/codex-multi-auth

Length of output: 368


🏁 Script executed:

# Check the full context of the test that uses automaticRun - what happens after it's added?
sed -n '1580,1640p' test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1981


🏁 Script executed:

# Search for any test that checks for the specific message "No manual Codex CLI apply with a rollback checkpoint"
rg -n "No manual" test/codex-cli-sync.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 50


🏁 Script executed:

# Look at the function implementation to understand the exact condition
sed -n '493,510p' lib/codex-cli/sync.ts

Repository: ndycode/codex-multi-auth

Length of output: 707


add test for the "no manual runs" unavailable path at lib/codex-cli/sync.ts:495-500.

getLatestCodexCliSyncRollbackPlan() needs explicit coverage for when sync history contains only automatic runs or is empty. current tests at test/codex-cli-sync.test.ts:1580+ cover manual run with checkpoint (ready status) and missing checkpoint file (unavailable status), but don't exercise the findLatestManualRollbackRun() null case. add a test that verifies status: "unavailable" and reason: "No manual Codex CLI apply..." when no manual runs with rollback checkpoints exist.

per coding guidelines: lib/** changes must cite affected tests (vitest).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-cli/sync.ts` around lines 493 - 502, Add a new vitest in
test/codex-cli-sync.test.ts that covers the path where
findLatestManualRollbackRun() returns null: call
getLatestCodexCliSyncRollbackPlan() and assert the returned object has status
=== "unavailable", reason === "No manual Codex CLI apply with a rollback
checkpoint is available.", and snapshot === null; set up the test scenario so
sync history has only automatic runs or is empty (mock or stub
findLatestManualRollbackRun to return null) and reference this new test in the
lib/** change note per coding guidelines.

Comment on lines +2279 to +2286
export async function pruneAutoGeneratedSnapshots(
options: AutoSnapshotPruneOptions = {},
): Promise<AutoSnapshotPruneResult> {
const backups = options.backups ?? (await listNamedBackups());
const keepLatestPerReason = Math.max(
1,
options.keepLatestPerReason ?? 1,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

undocumented minimum clamp on keepLatestPerReason.

the Math.max(1, ...) at lib/storage.ts:2283-2285 silently enforces a floor of 1, but AutoSnapshotPruneOptions doesn't document this. callers passing keepLatestPerReason: 0 expecting full pruning will be surprised.

📝 add jsdoc clarifying the clamping
 export interface AutoSnapshotPruneOptions {
 	backups?: NamedBackupMetadata[];
 	preserveNames?: Iterable<string>;
+	/**
+	 * Number of snapshots to keep per reason. Clamped to a minimum of 1.
+	 * `@default` 1
+	 */
 	keepLatestPerReason?: number;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 2279 - 2286, The function
pruneAutoGeneratedSnapshots silently clamps the option keepLatestPerReason to a
minimum of 1 (via Math.max) which can surprise callers who pass 0; update the
AutoSnapshotPruneOptions JSDoc (and/or the pruneAutoGeneratedSnapshots docblock)
to explicitly state that keepLatestPerReason is clamped to a minimum of 1 and
describe the behavior when callers pass 0 or negative values, referencing the
pruneAutoGeneratedSnapshots function and the keepLatestPerReason variable so
readers know the enforced minimum.

Comment on lines +1428 to +1486
it("retries transient rollback snapshot capture for manual applies", async () => {
const transientError = new Error("snapshot busy") as NodeJS.ErrnoException;
transientError.code = "EBUSY";
const snapshotSpy = vi
.spyOn(storageModule, "snapshotAccountStorage")
.mockRejectedValueOnce(transientError)
.mockResolvedValueOnce({
name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_002",
path: join(tempDir, "rollback-snapshot-retry.json"),
createdAt: 1,
updatedAt: 1,
sizeBytes: 128,
version: 3,
accountCount: 1,
schemaErrors: [],
valid: true,
});

const current: AccountStorageV3 = {
version: 3,
accounts: [
{
accountId: "acc_a",
accountIdSource: "token",
email: "a@example.com",
refreshToken: "refresh-a",
accessToken: "access-a",
addedAt: 1,
lastUsed: 1,
},
],
activeIndex: 0,
activeIndexByFamily: { codex: 0 },
};

const result = await applyCodexCliSyncToStorage(current, {
trigger: "manual",
sourceState: {
path: accountsPath,
activeAccountId: "acc_b",
accounts: [
{
accountId: "acc_b",
email: "b@example.com",
accessToken: "access-b",
refreshToken: "refresh-b",
isActive: true,
},
],
},
});

expect(result.changed).toBe(true);
expect(snapshotSpy).toHaveBeenCalledTimes(2);
expect(result.pendingRun?.run.rollbackSnapshot).toEqual({
name: "accounts-codex-cli-sync-snapshot-2026-03-16_00-00-00_002",
path: join(tempDir, "rollback-snapshot-retry.json"),
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

cover the win32 EPERM rollback-io path.

test/codex-cli-sync.test.ts:1428-1486 and test/codex-cli-sync.test.ts:1786-1864 only drive EBUSY. the retry hardening here still never exercises the win32 EPERM branch called out in the PR notes, so the platform-specific rollback path can regress without a deterministic vitest signal. add a case that mocks process.platform to "win32" and forces EPERM through the same helper.

As per coding guidelines, test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Also applies to: 1786-1864

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-cli-sync.test.ts` around lines 1428 - 1486, The test only
exercises the EBUSY retry path; add a sibling vitest case that mocks
process.platform to "win32" and simulates a transient NodeJS.ErrnoException with
code "EPERM" on storageModule.snapshotAccountStorage (use the same mock pattern:
mockRejectedValueOnce(transientError) then mockResolvedValueOnce(...)) to drive
the Win32-specific retry branch in applyCodexCliSyncToStorage; ensure the test
asserts snapshotAccountStorage was called twice and that
result.pendingRun?.run.rollbackSnapshot matches the resolved snapshot, and
restore process.platform after the test to keep tests deterministic.

Comment on lines +2301 to +2342
it("creates a snapshot before importing into an existing account pool", async () => {
const importPath = join(testWorkDir, "import.json");

await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "existing",
refreshToken: "ref-existing",
addedAt: 1,
lastUsed: 1,
},
],
});
await fs.writeFile(
importPath,
JSON.stringify({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "imported",
refreshToken: "ref-imported",
addedAt: 2,
lastUsed: 2,
},
],
}),
"utf-8",
);

await importAccounts(importPath);

const entries = await fs.readdir(getNamedBackupsDirectoryPath());
expect(
entries.some((name) =>
name.startsWith("accounts-import-accounts-snapshot-"),
),
).toBe(true);
expect((await loadAccounts())?.accounts).toHaveLength(2);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

prove the import snapshot is pre-merge.

test/storage.test.ts:2301-2342 only proves that importAccounts() created some snapshot file. if the implementation starts snapshotting after the merge, this still passes because the backup directory is populated either way. parse the created snapshot back out, or restore it, and assert it contains only the original "existing" account.

As per coding guidelines, test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage.test.ts` around lines 2301 - 2342, The test currently only
checks that a snapshot file was created by importAccounts; update the test so it
reads and parses the created snapshot to assert it was taken before the merge
(i.e., contains only the original "existing" account). After calling
importAccounts(importPath) use getNamedBackupsDirectoryPath() to find the backup
file whose name startsWith("accounts-import-accounts-snapshot-"), read and
JSON.parse that snapshot file, and assert snapshot.accounts has length 1 and
snapshot.accounts[0].accountId === "existing" (keep the existing expect that
final loadAccounts() has length 2). This ensures importAccounts() snapshotting
occurs pre-merge and keeps the test deterministic under vitest.

@ndycode ndycode changed the base branch from fresh/16-retention-doctor to dev March 18, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants