Skip to content

feat(auth): add restore preview#112

Closed
ndycode wants to merge 17 commits intodevfrom
fresh/13-restore-preview
Closed

feat(auth): add restore preview#112
ndycode wants to merge 17 commits intodevfrom
fresh/13-restore-preview

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 16, 2026

Summary

  • Reopens the preserved restore-preview slice from archived PR #86
  • Stacked PR on fresh/12-backup-browser

What Changed

  • Adds restore assessment preview before apply, including refreshed pre-confirm assessment and replace-only restore support.
  • Keeps the branch focused to restore-preview behavior and the related CLI/storage 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: medium
  • Rollback plan: revert 17cec7e and 5ddee4d

Additional Notes

  • Preserved branch validation from the fresh-restack run passed lint, typecheck, build, and full tests.

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 lands the restore-preview slice from archived #86 on top of fresh/12-backup-browser, adding a full named-backup assessment and browser UI flow, a replace-only restore path, a new sync-history NDJSON module, and a destructive-actions extraction with proper index-rebasing and rollback. the storage layer gains atomic-delete retries (unlinkWithRetry), transaction path-anchoring (storagePath in AsyncLocalStorage context), and a boolean return from clearAccounts/clearFlaggedAccounts. coverage is broad (17k line diff, 43 files, all checklist gates passed).

key findings:

  • double file-read in loadBackupBrowserEntries — when the backup browser opens without startup assessments, listNamedBackups() via scanNamedBackups() reads and parses every .json file, then assessNamedBackupRestore() reads each file again; on windows this doubles the EBUSY/antivirus lock window and silently produces unrestorable entries with no user-visible error
  • trimHistoryFileIfNeeded is non-atomic — uses fs.writeFile (truncate-then-write) instead of temp+rename; a windows process kill between truncation and write completion leaves sync-history.ndjson empty with no recovery path
  • stale startup assessments on repeated backup browser visitsrecoveryState.allAssessments (the startup snapshot) is re-passed as startupAssessments on every retry after a cancelled restore, so users see stale data if they added or modified accounts between visits
  • missing vitest coverage for partial scanNamedBackups failure — no test simulates one file being EBUSY mid-chunk, so the mismatch between totalBackups and allAssessments.length on windows is not regression-tested
  • unused readFileSync import in sync-history.ts — dead code, drop it

Confidence Score: 3/5

  • safe to merge with the double-read and non-atomic trim issues addressed first — both have narrow but real failure windows on windows filesystems
  • the core assessment/restore pipeline is well-structured and covered by new tests including replace-only and rollback paths. the storagePath anchor in transactions is a solid correctness fix. the two P1 issues (double-read EBUSY window in loadBackupBrowserEntries, non-atomic trim in sync-history.ts) are real but narrow — they only manifest under windows antivirus/vss contention and process termination respectively, not under normal operation. the stale-assessment UX issue is low-severity. overall the PR is solid foundational work with two targeted fixes needed before it's fully windows-safe.
  • lib/codex-manager.ts (double-read in loadBackupBrowserEntries) and lib/sync-history.ts (non-atomic trimHistoryFileIfNeeded) need the most attention before merge

Important Files Changed

Filename Overview
lib/storage.ts largest change set — adds BackupRestoreAssessment, NamedBackupMetadata, and the full assessNamedBackupRestoreCandidate pipeline; introduces importNormalizedAccounts, scanNamedBackups, listNamedBackupsWithoutLoading, and restoreNamedBackup; also refactors clearAccounts/clearFlaggedAccounts to return boolean and adds unlinkWithRetry with windows EBUSY/EPERM backoff. the storagePath anchor added to both transaction contexts closes a subtle TOCTOU for path changes mid-transaction. the eligibleForRestore broadening (now includes replace-only) is intentional and covered by new tests.
lib/codex-manager.ts adds runBackupBrowserManager, runBackupRestorePreview, loadBackupBrowserEntries, and the restore-backup CLI command; integrates destructiveActionInFlight guard for fresh and reset modes; refactors handleManageAction to delegate to deleteAccountAtIndex for proper index rebasing and flagged-account cleanup. the double-read issue in loadBackupBrowserEntries (listNamedBackups + per-backup assessNamedBackupRestore) is a new windows EBUSY risk. startup recovery flow with allowEmptyStorageMenu and pendingRecoveryState is complex but the logic appears correct.
lib/sync-history.ts new module implementing NDJSON append-only sync history with mutex, chunk-based tail reader, and MAX_HISTORY_ENTRIES trim; trimHistoryFileIfNeeded uses a destructive writeFile pattern instead of temp+rename, creating a window for history corruption on windows if the process is killed mid-write; also contains a stale readFileSync import that is never used.
lib/destructive-actions.ts new module extracting deleteSavedAccounts, resetLocalState, deleteAccountAtIndex, and clampActiveIndices from codex-manager; the deleteAccountAtIndex rollback path (revert saveAccounts if saveFlaggedAccounts fails) and AggregateError propagation on double-failure are well-structured; rebaseActiveIndicesAfterDelete correctly shifts all family indices above the removed slot.
lib/cli.ts adds restore-backup and reset login modes, isInteractiveLoginMenuAvailable helper, and promptResetTypedConfirm with RESET-typed confirmation guard; the fallback path now correctly gates fresh behind promptDeleteAllTypedConfirm (previously missing); changes are low-risk formatting/refactor with two meaningful new mode branches.
test/storage.test.ts extends storage tests with replace-only restore coverage, transaction path-anchor tests, listNamedBackups/restoreNamedBackup end-to-end flows, and clearFlaggedAccounts boolean return assertions; good coverage of the new APIs though no test simulates a partial scan failure (one file EBUSY during scanNamedBackups).
test/destructive-actions.test.ts 301 lines of new tests covering deleteAccountAtIndex rollback, clampActiveIndices, rebaseActiveIndicesAfterDelete, and resetLocalState; rollback behavior tested via injected save errors; looks thorough.
test/codex-manager-cli.test.ts 5000-line expansion adding backup browser CLI path tests, resolveStartupRecoveryAction unit tests, and reset/fresh mode confirmation gate coverage; the scale of the test file makes navigation difficult but content appears thorough.
test/recovery.test.ts large expansion of recovery scenarios including the new getActionableNamedBackupRestores eligibleForRestore broadening; covers both eligible and ineligible cases and startup recovery action resolution; the concurrency/limit-race tests added for the guard check are valuable.
test/sync-history.test.ts new tests for appendSyncHistoryEntry, readSyncHistory, and getSyncHistoryPaths; covers trim behavior at >200 entries and kind filtering; does not test partial-write corruption scenarios or mutex contention on windows.

Sequence Diagram

sequenceDiagram
    participant CLI as runCodexMultiAuthCli
    participant Login as runAuthLogin
    participant Browser as runBackupBrowserManager
    participant Preview as runBackupRestorePreview
    participant Storage as lib/storage.ts

    CLI->>Login: restore-backup command
    Login->>Storage: getActionableNamedBackupRestores()<br/>(startup recovery path)
    Storage-->>Login: ActionableNamedBackupRecoveries

    Login->>Browser: runBackupBrowserManager(displaySettings, allAssessments?)
    Browser->>Storage: listNamedBackups() → scanNamedBackups()<br/>⚠️ reads all .json files (read #1)
    Storage-->>Browser: NamedBackupMetadata[]

    loop per backup (chunked by NAMED_BACKUP_LIST_CONCURRENCY)
        Browser->>Storage: assessNamedBackupRestore(name)<br/>⚠️ reads same .json file again (read #2)
        Storage-->>Browser: BackupRestoreAssessment
    end

    Browser->>Preview: runBackupRestorePreview(entry)
    Preview->>Storage: assessNamedBackupRestore(name)<br/>(read #3 — pre-confirm re-assess)
    Storage-->>Preview: BackupRestoreAssessment

    Preview->>Storage: restoreNamedBackup(name, {assessment})<br/>⚠️ re-assesses internally (ignores passed assessment)
    Storage->>Storage: withAccountStorageTransaction → importNormalizedAccounts
    Storage-->>Preview: {imported, total, skipped}
    Preview-->>Browser: "restored" | "dismissed"
    Browser-->>Login: BackupRestoreManagerResult
Loading

Comments Outside Diff (4)

  1. test/storage-recovery-paths.test.ts, line 1182 (link)

    Weakened assertion no longer verifies the expected winner

    the assertion was narrowed from toBe(manual-meta-checkpoint) to not.toBe(.cache). this only confirms the wrong file isn't picked; it doesn't confirm the right file is. if the path resolution logic regresses and picks some third unexpected file, this test will silently pass.

    the setTimeout(resolve, 20) delay addresses the mtime race — that's fine — but the assertion should still pin the correct winner:

    if this is still flaky after the mtime nudge, consider bumping the delay or using a deterministic mtime offset rather than weakening the contract.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: test/storage-recovery-paths.test.ts
    Line: 1182
    
    Comment:
    **Weakened assertion no longer verifies the expected winner**
    
    the assertion was narrowed from `toBe(manual-meta-checkpoint)` to `not.toBe(.cache)`. this only confirms the _wrong_ file isn't picked; it doesn't confirm the _right_ file is. if the path resolution logic regresses and picks some third unexpected file, this test will silently pass.
    
    the `setTimeout(resolve, 20)` delay addresses the mtime race — that's fine — but the assertion should still pin the correct winner:
    
    
    
    if this is still flaky after the mtime nudge, consider bumping the delay or using a deterministic mtime offset rather than weakening the contract.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  2. lib/codex-manager.ts, line 752-756 (link)

    P1 result.skipped === replacedCount condition never fires for standard replace-only restores

    for the typical single-account replace-only scenario (backup = [A'(new_token)], existing = [A(old_token)]), importNormalizedAccounts computes:

    skipped = max(0, backup_count - imported - options.replacedExistingCount)
            = max(0, 1 - 0 - 1) = 0
    

    so result.skipped === replacedCount resolves to 0 === 1false, and the fallback message "Imported 0 accounts. Skipped 0. Total accounts: 1." is shown instead of the intended "Replaced 1 current account.". the condition only becomes truthy when backup_count === 2 × replacedCount (i.e. exactly half the backup entries are within-backup duplicates), which is an obscure edge case.

    the test at line 680–682 masks this by mocking restoreNamedBackup to return { imported: 0, skipped: 1, total: 1 } — a value the real function never produces for this scenario.

    the condition should just be:

    also needs a companion vitest that calls the real importNormalizedAccounts (not mocked) and passes the output to formatNamedBackupRestoreResult to guard against a recurrence.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 752-756
    
    Comment:
    **`result.skipped === replacedCount` condition never fires for standard replace-only restores**
    
    for the typical single-account replace-only scenario (`backup = [A'(new_token)]`, `existing = [A(old_token)]`), `importNormalizedAccounts` computes:
    
    ```
    skipped = max(0, backup_count - imported - options.replacedExistingCount)
            = max(0, 1 - 0 - 1) = 0
    ```
    
    so `result.skipped === replacedCount` resolves to `0 === 1``false`, and the fallback message `"Imported 0 accounts. Skipped 0. Total accounts: 1."` is shown instead of the intended `"Replaced 1 current account."`. the condition only becomes truthy when `backup_count === 2 × replacedCount` (i.e. exactly half the backup entries are within-backup duplicates), which is an obscure edge case.
    
    the test at line 680–682 masks this by mocking `restoreNamedBackup` to return `{ imported: 0, skipped: 1, total: 1 }` — a value the real function never produces for this scenario.
    
    the condition should just be:
    
    
    
    also needs a companion vitest that calls the real `importNormalizedAccounts` (not mocked) and passes the output to `formatNamedBackupRestoreResult` to guard against a recurrence.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  3. lib/codex-manager.ts, line 1002-1035 (link)

    P1 loadBackupBrowserEntries double-reads every backup file on the non-fast path — widens EBUSY window on windows

    when startupAssessments is not provided, the function first calls listNamedBackups()scanNamedBackups()loadBackupCandidate(path) for every backup, then immediately calls assessNamedBackupRestore(backup.name, ...) which calls loadBackupCandidate(path) again for every backup. each backup JSON is therefore read twice from disk in rapid succession. on windows, an antivirus or shadow-copy lock between the two reads converts a valid scan into an EBUSY failure at assessment time, returning assessmentError entries in the browser UI that were fine a millisecond earlier.

    getActionableNamedBackupRestores avoids this by reusing the NamedBackupScanEntry data from the scan. loadBackupBrowserEntries should do the same — call scanNamedBackups() directly and pass the already-loaded candidates to assessNamedBackupRestoreCandidate instead of fetching them twice:

    const scanResult = await scanNamedBackups();
    namedEntries = await Promise.all(
        scanResult.backups.map(async ({ backup, candidate }) => {
            try {
                const assessment = assessNamedBackupRestoreCandidate(backup, candidate, currentStorage);
                return { kind: "named" as const, label: backup.name, backup, assessment };
            } catch (error) { ... }
        }),
    );

    no additional vitest for the double-read race exists in the new tests; worth adding a test that stubs fs.readFile to fail on the second call per path and verifies graceful degradation.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 1002-1035
    
    Comment:
    **`loadBackupBrowserEntries` double-reads every backup file on the non-fast path — widens EBUSY window on windows**
    
    when `startupAssessments` is not provided, the function first calls `listNamedBackups()``scanNamedBackups()``loadBackupCandidate(path)` for every backup, then immediately calls `assessNamedBackupRestore(backup.name, ...)` which calls `loadBackupCandidate(path)` again for every backup. each backup JSON is therefore read twice from disk in rapid succession. on windows, an antivirus or shadow-copy lock between the two reads converts a valid scan into an EBUSY failure at assessment time, returning `assessmentError` entries in the browser UI that were fine a millisecond earlier.
    
    `getActionableNamedBackupRestores` avoids this by reusing the `NamedBackupScanEntry` data from the scan. `loadBackupBrowserEntries` should do the same — call `scanNamedBackups()` directly and pass the already-loaded candidates to `assessNamedBackupRestoreCandidate` instead of fetching them twice:
    
    ```ts
    const scanResult = await scanNamedBackups();
    namedEntries = await Promise.all(
        scanResult.backups.map(async ({ backup, candidate }) => {
            try {
                const assessment = assessNamedBackupRestoreCandidate(backup, candidate, currentStorage);
                return { kind: "named" as const, label: backup.name, backup, assessment };
            } catch (error) { ... }
        }),
    );
    ```
    
    no additional vitest for the double-read race exists in the new tests; worth adding a test that stubs `fs.readFile` to fail on the second call per path and verifies graceful degradation.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

  4. lib/codex-manager.ts, line 928-960 (link)

    P1 double file-read on the backup browser's non-startup path

    when startupAssessments is not provided, loadBackupBrowserEntries calls listNamedBackups() which internally calls scanNamedBackups() — that already reads and fully parses every .json backup file to build the metadata + LoadedBackupCandidate. the parsed candidate is then discarded. immediately after, assessNamedBackupRestore(backup.name, { currentStorage }) reads every same file a second time.

    on windows, antivirus scanners or vss shadow-copy can hold an exclusive handle between those two reads, causing the second read to fail with EBUSY. the error is caught and the entry gets an assessmentError label making it unrestorable from the browser — no user-visible explanation is shown.

    the fast path already solves this: getActionableNamedBackupRestores() uses scanNamedBackups() internally and calls assessNamedBackupRestoreCandidate directly from the already-loaded candidate (single read per file). consider replacing the listNamedBackups + per-backup assessNamedBackupRestore loop with a single getActionableNamedBackupRestores() call:

    // instead of:
    const namedBackups = await listNamedBackups();
    // ...then per-backup assessNamedBackupRestore() calls
    
    // use the fast path:
    const { allAssessments } = await getActionableNamedBackupRestores({ currentStorage });
    namedEntries = allAssessments.map((assessment) => ({
        kind: "named" as const,
        label: assessment.backup.name,
        backup: assessment.backup,
        assessment: assessment.eligibleForRestore ? assessment : null,
        assessmentError: assessment.error,
    }));

    this eliminates the second per-file disk read and the doubled windows EBUSY window.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: lib/codex-manager.ts
    Line: 928-960
    
    Comment:
    **double file-read on the backup browser's non-startup path**
    
    when `startupAssessments` is not provided, `loadBackupBrowserEntries` calls `listNamedBackups()` which internally calls `scanNamedBackups()` — that already reads and fully parses every `.json` backup file to build the metadata + `LoadedBackupCandidate`. the parsed candidate is then discarded. immediately after, `assessNamedBackupRestore(backup.name, { currentStorage })` reads every same file a second time.
    
    on windows, antivirus scanners or vss shadow-copy can hold an exclusive handle between those two reads, causing the second read to fail with `EBUSY`. the error is caught and the entry gets an `assessmentError` label making it unrestorable from the browser — no user-visible explanation is shown.
    
    the fast path already solves this: `getActionableNamedBackupRestores()` uses `scanNamedBackups()` internally and calls `assessNamedBackupRestoreCandidate` directly from the already-loaded candidate (single read per file). consider replacing the `listNamedBackups` + per-backup `assessNamedBackupRestore` loop with a single `getActionableNamedBackupRestores()` call:
    
    ```ts
    // instead of:
    const namedBackups = await listNamedBackups();
    // ...then per-backup assessNamedBackupRestore() calls
    
    // use the fast path:
    const { allAssessments } = await getActionableNamedBackupRestores({ currentStorage });
    namedEntries = allAssessments.map((assessment) => ({
        kind: "named" as const,
        label: assessment.backup.name,
        backup: assessment.backup,
        assessment: assessment.eligibleForRestore ? assessment : null,
        assessmentError: assessment.error,
    }));
    ```
    
    this eliminates the second per-file disk read and the doubled windows EBUSY window.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Codex

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 928-960

Comment:
**double file-read on the backup browser's non-startup path**

when `startupAssessments` is not provided, `loadBackupBrowserEntries` calls `listNamedBackups()` which internally calls `scanNamedBackups()` — that already reads and fully parses every `.json` backup file to build the metadata + `LoadedBackupCandidate`. the parsed candidate is then discarded. immediately after, `assessNamedBackupRestore(backup.name, { currentStorage })` reads every same file a second time.

on windows, antivirus scanners or vss shadow-copy can hold an exclusive handle between those two reads, causing the second read to fail with `EBUSY`. the error is caught and the entry gets an `assessmentError` label making it unrestorable from the browser — no user-visible explanation is shown.

the fast path already solves this: `getActionableNamedBackupRestores()` uses `scanNamedBackups()` internally and calls `assessNamedBackupRestoreCandidate` directly from the already-loaded candidate (single read per file). consider replacing the `listNamedBackups` + per-backup `assessNamedBackupRestore` loop with a single `getActionableNamedBackupRestores()` call:

```ts
// instead of:
const namedBackups = await listNamedBackups();
// ...then per-backup assessNamedBackupRestore() calls

// use the fast path:
const { allAssessments } = await getActionableNamedBackupRestores({ currentStorage });
namedEntries = allAssessments.map((assessment) => ({
    kind: "named" as const,
    label: assessment.backup.name,
    backup: assessment.backup,
    assessment: assessment.eligibleForRestore ? assessment : null,
    assessmentError: assessment.error,
}));
```

this eliminates the second per-file disk read and the doubled windows EBUSY window.

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/sync-history.ts
Line: 190-206

Comment:
**`trimHistoryFileIfNeeded` is not atomic — windows crash between truncation and write corrupts history**

`fs.writeFile` with `O_WRONLY | O_CREAT | O_TRUNC` first truncates the file to zero bytes, then writes the trimmed content. if the process is killed (or windows EPERM/EBUSY interrupts the write) between the truncation and the full write completing, the history file is left empty or partially written with no way to recover the entries.

this is inside the `historyMutex` lock so concurrent access is safe, but the lock offers no protection against mid-write process termination on windows.

prefer the temp-file + rename pattern used elsewhere in this codebase for atomic writes:

```ts
const tempPath = `${paths.historyPath}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, trimmedContent, { encoding: "utf8", mode: 0o600 });
await fs.rename(tempPath, paths.historyPath);
```

`fs.rename` is atomic on windows (within the same volume) and means the old history is never lost — either the old file persists or the trimmed file is complete.

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/sync-history.ts
Line: 46-57

Comment:
**`readFileSync` import is unused in async-only module**

`readFileSync` is imported from `node:fs` at the top but the module only uses `promises as fs` for all I/O. the sync import is dead code. drop it to keep the import surface minimal and avoid confusion on windows where a mistaken sync read in a hot path could block the event loop.

```suggestion
import { promises as fs } from "node:fs";
```

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.ts
Line: 1087-1090

Comment:
**stale `startupAssessments` passed back to browser after cancelled restore**

in the recovery prompt block, `recoveryState.allAssessments` (the startup snapshot) is passed to `runBackupBrowserManager` as `startupAssessments`. if the user opens the browser and cancels, `runBackupBrowserManager` returns `"dismissed"`, `pendingRecoveryState` is set back to the same stale `recoveryState`, and `recoveryPromptAttempted` is reset to `false`. on the next loop iteration the browser re-opens with the same stale assessment data even if the user changed something (e.g., added an account manually between visits).

consider passing `undefined` as `startupAssessments` after the first visit so the browser re-scans from disk:

```ts
if (restoreResult !== "restored") {
    pendingRecoveryState = recoveryState;
    recoveryPromptAttempted = false;
    // clear startupAssessments so next open re-scans from disk
    recoveryState = { ...recoveryState, allAssessments: [] };
}
```

or simply don't pass `startupAssessments` on subsequent opens so `loadBackupBrowserEntries` always re-scans.

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/storage.ts
Line: 1839-1860

Comment:
**`scanNamedBackups` silently drops scan errors and continues — no vitest coverage for partial-scan failure**

when a chunk entry fails (non-ENOENT), the error is logged and the entry is filtered to `null`. the returned `backups` list is shorter than `totalBackups`, but callers have no signal that the scan was partial. `getActionableNamedBackupRestores` receives an honest `totalBackups` count but a shorter `backups` list — the display could show "3 backups" while only 2 have assessments, confusing users on windows where per-file lock failures are common.

no vitest test currently simulates a partial-scan failure (e.g., one file EBUSY mid-chunk). consider adding a test that verifies `allAssessments.length < totalBackups` when a file cannot be loaded, and that the returned `totalBackups` still reflects the full directory count.

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

Last reviewed commit: "fix(auth): bind rest..."

Greptile also left 1 inline comment on this PR.

Copilot AI review requested due to automatic review settings March 16, 2026 08:52
@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

📝 Walkthrough

walkthrough

adds a preview-restore workflow for named backups, new assessment and preview types, formatting helpers, and refactors restore flows to use assessment-driven previews and gated restores. changes span manager UI, storage assessment/loading, and tests.

changes

Cohort / File(s) Summary
manager preview-restore flow
lib/codex-manager.ts
adds BackupDetailAction (`"back"
storage assessment & preview interfaces
lib/storage.ts
introduces rich types for named/rotating backups and previews (NamedBackupMetadata, RotatingBackupMetadata, BackupRestoreAccountPreview, BackupRestoreConflictDetail, BackupRestoreActiveAccountPreview, BackupRestoreConflictPreview, NamedBackupRestorePreview, BackupRestoreAssessment, etc.). adds many helpers for scanning/loading/assessing backups, assessment-driven restoreNamedBackup(name, options?) (now accepts { assessment? }), assessNamedBackupRestore, listing APIs, and robust I/O/retry utilities.
manager test coverage
test/codex-manager-cli.test.ts
updates tests to expect restoreNamedBackup calls with an options object containing assessment. adds/updates tests for preview-restore flow, reassessment during preview, confirmation prompts, and subsequent restore invocation with assessment context.
storage restore flow tests
test/storage.test.ts
adds extensive tests exercising named backups, rotating backups, assessment behavior, restore previews, retry semantics (EPERM/EBUSY/EAGAIN), WAL/cleanup paths, and public API surface (assessNamedBackupRestore, listNamedBackups, listRotatingBackups, restoreNamedBackup).
recovery path timestamp updates
test/storage-recovery-paths.test.ts
adds utimes manipulation to simulate cache and manual backup mtimes to ensure newest valid manual backup selection. minor assertion formatting changes.

sequence diagram

sequenceDiagram
    actor user as User
    participant cli as CLI/Menu
    participant mgr as codex-manager
    participant assess as assess (storage)
    participant store as storage

    user->>cli: browse backups
    cli->>mgr: showBackupBrowserDetails(entry)
    mgr->>store: read backup metadata / candidate
    store-->>mgr: candidate metadata
    mgr-->>cli: action: preview-restore
    cli->>user: prompt preview
    user->>mgr: runBackupRestorePreview(entry)
    mgr->>assess: assessNamedBackupRestoreCandidate(name)
    assess->>store: load rawAccounts, read current storage
    store-->>assess: conflicts, active-account preview
    assess-->>mgr: BackupRestoreAssessment
    mgr->>mgr: buildRestoreAssessmentLines(assessment)
    mgr-->>cli: display preview + assessment
    cli->>user: confirm restore?
    user->>mgr: confirm
    mgr->>store: restoreNamedBackup(name, { assessment })
    store->>store: importNormalizedAccounts with assessment
    store-->>mgr: { imported, skipped, total }
    mgr->>mgr: formatNamedBackupRestoreResult(...)
    mgr-->>cli: display result
Loading

estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

review notes

missing regression tests: tests do not cover a stale-assessment case where assessment computed during preview becomes invalid before restore. add a test that mutates current storage between runBackupRestorePreview() and restoreNamedBackup() to ensure either re-validation occurs or restore fails safely. cite: test/codex-manager-cli.test.ts:line ~n/a, lib/storage.ts:line ~n/a.

windows edge case: test/storage-recovery-paths.test.ts:line ~n/a uses utimes to manipulate file mtimes. verify behavior on windows filesystems (precision, timezone, wsl). ensure selection logic in listNamedBackups / recovery path tolerates filesystem timestamp resolution differences. cite: test/storage-recovery-paths.test.ts:line ~n/a, lib/storage.ts:line ~n/a.

concurrency risk: current flow re-assesses in runBackupRestorePreview then passes assessment into restoreNamedBackup without guaranteed re-validation at import time. another process can add accounts or change backup files during user confirmation window, producing a stale assessment and unexpected import results. recommend adding a final validation/reconciliation inside restoreNamedBackup or explicit staleness detection. cite: lib/codex-manager.ts:line ~n/a, lib/storage.ts:line ~n/a.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.45% 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 'feat(auth): add restore preview' follows conventional commits format with valid type (feat), scope (auth), and clear lowercase imperative summary under 72 characters.
Description check ✅ Passed PR description is mostly complete: includes summary, what changed, validation checklist (all passing), docs/governance checklist, risk level (medium), rollback plan, and greptile findings. Non-critical sections are partially addressed.

✏️ 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/13-restore-preview
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fresh/13-restore-preview
📝 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

Adds a “restore preview” flow for named backup restores, including refreshed assessment at confirmation time and support for replace-only restores, with expanded assessment/preview metadata surfaced through the CLI and validated via new storage/CLI tests.

Changes:

  • Extends backup restore assessment with account/conflict/active-account preview fields and uses them to gate restore eligibility.
  • Adds an interactive CLI “Preview Restore” action that re-assesses just-in-time and confirms with a detailed summary.
  • Updates/adds tests covering replace-only restores, limit race reassessment, and new preview messaging.

Reviewed changes

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

Show a summary per file
File Description
lib/storage.ts Adds richer restore assessment/preview fields, recalculates merge/replace stats, and adds eligibility checks to restoreNamedBackup.
lib/codex-manager.ts Implements the restore preview UI, assessment summary formatting, and confirmation messaging updates.
test/storage.test.ts Adds coverage for preview fields, replace-only restore conflict details, and reassessment when limits change.
test/storage-recovery-paths.test.ts Adjusts recovery-path test timing and loosens an assertion around latestValidPath.
test/codex-manager-cli.test.ts Adds/updates CLI tests to validate preview flow, refreshed assessment confirmation, and failure handling.

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

}
| { type: "back" };

type BackupDetailAction = "back" | "preview-restore" | "restore";
Comment on lines +4662 to 4666
const action = await select<BackupDetailAction>(
[
{ label: "Restore This Backup", value: "restore", color: "green" },
{ label: "Back", value: "back" },
{ label: "Preview Restore", value: "preview-restore", color: "green" },
],
const parts: string[] = [];
if (replacements > 0) {
parts.push(
`${replacements} replace current${replacements === 1 ? "" : " accounts"}`,
Comment on lines 4663 to +4670
[
{ label: "Restore This Backup", value: "restore", color: "green" },
{ label: "Back", value: "back" },
{ label: "Preview Restore", value: "preview-restore", color: "green" },
],
{
message: "Backup Browser",
message: "Backup Actions",
subtitle: entry.label,
help: "Enter Select | Q Back",
help: "Enter Preview | Q Back",
lib/storage.ts Outdated
Comment on lines 2446 to 2453
name: string,
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
const assessment = await assessNamedBackupRestore(name);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
return importAccounts(backupPath);
const backupPath = await resolveNamedBackupRestorePath(name);
const assessment = await assessNamedBackupRestore(name);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
);

await fs.writeFile(`${storagePath}.cache`, "noise", "utf-8");
await new Promise((resolve) => setTimeout(resolve, 20));
const cacheEntries = accountSnapshots.filter((snapshot) => snapshot.path.endsWith(".cache"));
expect(cacheEntries).toHaveLength(0);
expect(metadata.accounts.latestValidPath).toBe(`${storagePath}.manual-meta-checkpoint`);
expect(metadata.accounts.latestValidPath).not.toBe(`${storagePath}.cache`);
Comment on lines +2073 to 2075
if (assessment.eligibleForRestore) {
actionable.push(assessment);
}
Copy link

Choose a reason for hiding this comment

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

P1 getActionableNamedBackupRestores filter broadened — verify startup-restore callers handle imported === 0

the filter changed from:

assessment.eligibleForRestore &&
!assessment.wouldExceedLimit &&
assessment.imported !== null &&
assessment.imported > 0

to just assessment.eligibleForRestore, which now includes replace-only restores (imported === 0, replacedExistingCount > 0).

the browser and preview paths were updated, but the startup auto-restore flow (which surfaces prompts when the user has no accounts or after a crash) may display restore hints where 0 accounts would be added — only existing tokens replaced. callers that previously formatted messages as "Importing N accounts…" without checking replacedExistingCount could silently show "Import 0 accounts" to users. trace all consumers of assessments from this function to confirm each surfaces the replace-only message from formatNamedBackupRestoreResult, not an inline string.

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

Comment:
**`getActionableNamedBackupRestores` filter broadened — verify startup-restore callers handle `imported === 0`**

the filter changed from:
```ts
assessment.eligibleForRestore &&
!assessment.wouldExceedLimit &&
assessment.imported !== null &&
assessment.imported > 0
```
to just `assessment.eligibleForRestore`, which now includes replace-only restores (`imported === 0, replacedExistingCount > 0`).

the browser and preview paths were updated, but the startup auto-restore flow (which surfaces prompts when the user has no accounts or after a crash) may display restore hints where 0 accounts would be added — only existing tokens replaced. callers that previously formatted messages as `"Importing N accounts…"` without checking `replacedExistingCount` could silently show `"Import 0 accounts"` to users. trace all consumers of `assessments` from this function to confirm each surfaces the replace-only message from `formatNamedBackupRestoreResult`, not an inline string.

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

Fix in Codex

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: 6

🤖 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/storage.ts`:
- Around line 2526-2563: importNormalizedAccounts currently computes skipped as
normalized.accounts.length - imported which treats replaced accounts as skipped;
instead compute how many of the incoming normalized.accounts were actually
applied (either added or replaced) and set skipped = normalized.accounts.length
- appliedCount. Concretely, after producing deduplicatedAccounts, build a lookup
(by account.id or your canonical account key) for existingAccounts and for
deduplicatedAccounts, then compute appliedCount = count of entries in
normalized.accounts that appear in deduplicatedAccounts (regardless of whether
they replaced an existing one); set imported to the number of new additions
(deduplicatedAccounts.length - existingAccounts.length) if you still need that
metric, but compute skipped from appliedCount so replaced items are not flagged
as skipped; update the return to use these computed values (references:
importNormalizedAccounts, existingAccounts, normalized.accounts,
deduplicatedAccounts, deduplicateAccounts).
- Around line 2429-2447: restoreNamedBackup currently always calls
loadImportableBackupCandidate, re-opening the backup even when the caller
already has the candidate, and importNormalizedAccounts (or the restore counting
logic) treats replacements as "skipped", causing preview/restore semantic drift.
Change restoreNamedBackup(name, options) to accept and use an optional preloaded
candidate (e.g., options.candidate) and only call loadImportableBackupCandidate
when that candidate is not provided; keep the existing
buildNamedBackupMetadata/assessNamedBackupRestoreCandidate flow but pass the
supplied candidate into assessment to avoid re-reading the file. Also update the
account import/counting logic in importNormalizedAccounts (the code that
increments skipped/replacements) so that only accounts actually dropped due to
dedup are counted as skipped and replacements are not counted as skipped,
ensuring assessment.skipped matches restore result.

In `@test/codex-manager-cli.test.ts`:
- Around line 927-930: The restoreNamedBackupMock assertion is too loose—replace
the expect.objectContaining({ assessment: expect.anything() }) check with a
deterministic assertion that the passed assessment equals the expected/refreshed
assessment used earlier in the test; locate the call to restoreNamedBackupMock
in this file (and similar occurrences like at lines ~985, 2231, 2623, 3734,
3782, 3912, 4034, 4225) and assert using a deep equality matcher (e.g.,
expect(restoreNamedBackupMock).toHaveBeenCalledWith("named-backup",
expect.objectContaining({ assessment: expectedAssessment })) or the exact
object) where expectedAssessment is the known/refreshed assessment variable
constructed in the test (or extracted right before invocation) so the test fails
if a stale assessment is passed.

In `@test/storage-recovery-paths.test.ts`:
- Around line 783-786: The test is setting newerManualMtime in the future which
can cause flakes on some filesystems; change the mtime values so both are in the
past (e.g., keep olderManualMtime = new Date(Date.now() - 5000) and set
newerManualMtime to a slightly more recent past time like new Date(Date.now() -
1000)) and then call fs.utimes(olderManualPath, olderManualMtime,
olderManualMtime) and fs.utimes(newerManualPath, newerManualMtime,
newerManualMtime) so the ordering is deterministic across Windows and other
filesystems.

In `@test/storage.test.ts`:
- Around line 1269-1291: The test currently asserts a mismatch between the
assessment preview and the actual restore: assessment.skipped is asserted as 0
while restoreNamedBackup("Replace Only") returns skipped: 1; make these
consistent by updating the assessment expectation to
expect(assessment.skipped).toBe(1) (leave assessment.imported and the
restoreResult assertion unchanged) so the preview and apply counts match;
reference the assessment object assertions and the restoreNamedBackup /
restoreResult check to locate the two assertions to adjust.
- Around line 1750-1795: The test "retries transient backup read errors while
restoring backups" currently increments backupReads when the mocked fs.readFile
hits the backupPath but never asserts it; add an explicit assertion that
backupReads equals the expected retry count after calling
restoreNamedBackup("retry-restore-read") (e.g., expect(backupReads).toBe(2) if
one retry was expected), keeping the existing cleanup of
readFileSpy.mockRestore(); ensure the assertion is placed inside the try block
after const result = await restoreNamedBackup(...) and before restoring the spy
so the test deterministically verifies the retry behavior of restoreNamedBackup
and the mocked read path.
🪄 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: 712ffd0d-b23c-4239-b773-91b028d18384

📥 Commits

Reviewing files that changed from the base of the PR and between 56dac78 and 4492f73.

📒 Files selected for processing (5)
  • lib/codex-manager.ts
  • lib/storage.ts
  • test/codex-manager-cli.test.ts
  • test/storage-recovery-paths.test.ts
  • test/storage.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-recovery-paths.test.ts
  • test/codex-manager-cli.test.ts
  • test/storage.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/codex-manager.ts
  • lib/storage.ts
🔇 Additional comments (7)
lib/codex-manager.ts (1)

4673-4757: good preview gating and confirmation flow.

lib/codex-manager.ts:4673 blocks non-actionable restores before action selection, and lib/codex-manager.ts:4734 adds a clear confirmation that includes replacement/active-account impact.

test/storage-recovery-paths.test.ts (1)

751-753: nice assertion hardening here.

the exact-path assertion in test/storage-recovery-paths.test.ts:751 prevents the prior weak pass condition and locks this regression to the intended winner snapshot.

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

991-1164: good replace-only preview regression coverage

this test exercises inspect → preview-restore → confirm → restore and verifies replace-only messaging, which is the right contract for this flow. good coverage against restore-preview regressions in lib/codex-manager.ts:4695-4755 and lib/codex-manager.ts:4912-4950.


4231-4326: good windows ebusy reassessment guard test

this is a solid windows filesystem edge-case regression: reassessment failure exits safely, skips confirm, and avoids restore. it matches the guard behavior in lib/codex-manager.ts:4704-4718.

test/storage.test.ts (3)

1213-1225: good coverage for explicit-null active-account preview state.

the assertions in test/storage.test.ts:1213-test/storage.test.ts:1225 pin the active-account transition surface well and protect the restore-preview contract.


1301-1338: nice regression for prevalidated assessment reuse.

this is a solid guard for redundant backup reads in the preview→restore path at test/storage.test.ts:1301-test/storage.test.ts:1338, which is where windows ebusy windows tend to show up.


1477-1548: strong concurrency regression for stale assessment race.

test/storage.test.ts:1477-test/storage.test.ts:1548 correctly reproduces the mutation-time limit race and verifies reassessment blocks restore.

Comment on lines 2429 to 2447
export async function restoreNamedBackup(
name: string,
options: { assessment?: BackupRestoreAssessment } = {},
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
return importAccounts(backupPath);
const candidate = await loadImportableBackupCandidate(backupPath);
if (options.assessment !== undefined) {
const assessment = assessNamedBackupRestoreCandidate(
await buildNamedBackupMetadata(name, backupPath, { candidate }),
candidate,
await loadAccounts(),
candidate.rawAccounts,
);
if (!assessment.eligibleForRestore || assessment.wouldExceedLimit) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
}
return importNormalizedAccounts(candidate.normalized, backupPath);
}
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
set -euo pipefail

# check preview/restore coverage
rg -nP --type=ts -C3 'preview-restore|Restore Preview|restoreNamedBackup\(' test

# check explicit windows lock-error coverage
rg -nP --type=ts -C3 'EBUSY|EPERM|EAGAIN' test

Repository: ndycode/codex-multi-auth

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the restoreNamedBackup implementation (lines 2429-2447)
echo "=== restoreNamedBackup function (2429-2447) ==="
sed -n '2429,2447p' lib/storage.ts

echo -e "\n=== loadImportableBackupCandidate definition ==="
rg -nP 'async function loadImportableBackupCandidate' lib/storage.ts -A 5

echo -e "\n=== importNormalizedAccounts definition ==="
rg -nP 'async function importNormalizedAccounts' lib/storage.ts -A 15

Repository: ndycode/codex-multi-auth

Length of output: 1850


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get full importNormalizedAccounts function
sed -n '2526,2573p' lib/storage.ts

echo -e "\n=== deduplicateAccounts definition ==="
rg -nP 'function deduplicateAccounts' lib/storage.ts -A 20

Repository: ndycode/codex-multi-auth

Length of output: 3151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the preview path to understand the flow mentioned in comment
echo "=== codex-manager.ts around line 4702 (preview restore) ==="
sed -n '4695,4710p' lib/codex-manager.ts

echo -e "\n=== Check if assessNamedBackupRestoreCandidate requires fresh load ==="
rg -nP 'function assessNamedBackupRestoreCandidate' lib/storage.ts -A 10

echo -e "\n=== Check preview flow in codex-manager ==="
rg -nP 'async function.*Restore.*Preview|preview.*restore' lib/codex-manager.ts -i -C 3 | head -60

Repository: ndycode/codex-multi-auth

Length of output: 2264


🏁 Script executed:

#!/bin/bash
set -euo pipefandet

# Check the Replace Only test (line 1289)
echo "=== Replace Only test context ==="
sed -n '1265,1295p' test/storage.test.ts

echo -e "\n=== Check retry-restore-read test ==="
sed -n '1775,1805p' test/storage.test.ts

echo -e "\n=== Check retry-restore-dir test ==="
sed -n '1673,1750p' test/storage.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the Replace Only test (line 1289)
echo "=== Replace Only test context ==="
sed -n '1265,1295p' test/storage.test.ts

echo -e "\n=== Check retry-restore-read test ==="
sed -n '1775,1805p' test/storage.test.ts

echo -e "\n=== Check retry-restore-dir test ==="
sed -n '1673,1750p' test/storage.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 4437


restore re-opens backup file, widening windows lock exposure.

lib/storage.ts:2434 calls loadImportableBackupCandidate unconditionally, re-reading the backup from disk even after preview has already assessed it at lib/codex-manager.ts:4702. this doubles filesystem touchpoints and increases ebusy/eagain exposure during preview→restore flows on windows under concurrent scanners.

separately, lib/storage.ts:2563 counts replacements as "skipped", causing semantic drift between assessment (which reports skipped: 0 for replace-only) and restore result (which reports skipped: 1). this inconsistency confuses preview/result semantics. the fix: only count accounts actually dropped due to dedup as skipped, not replacements.

test/storage.test.ts already has working coverage for ebusy/eagain retries during restore (test/storage.test.ts:1742, 1790).

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

In `@lib/storage.ts` around lines 2429 - 2447, restoreNamedBackup currently always
calls loadImportableBackupCandidate, re-opening the backup even when the caller
already has the candidate, and importNormalizedAccounts (or the restore counting
logic) treats replacements as "skipped", causing preview/restore semantic drift.
Change restoreNamedBackup(name, options) to accept and use an optional preloaded
candidate (e.g., options.candidate) and only call loadImportableBackupCandidate
when that candidate is not provided; keep the existing
buildNamedBackupMetadata/assessNamedBackupRestoreCandidate flow but pass the
supplied candidate into assessment to avoid re-reading the file. Also update the
account import/counting logic in importNormalizedAccounts (the code that
increments skipped/replacements) so that only accounts actually dropped due to
dedup are counted as skipped and replacements are not counted as skipped,
ensuring assessment.skipped matches restore result.

Comment on lines +1269 to +1291
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
expect(assessment.skipped).toBe(0);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);

const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });

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

do not codify a preview/apply skipped-count mismatch.

test/storage.test.ts:1272 expects assessment.skipped to be 0, while test/storage.test.ts:1290 expects restore skipped to be 1 for the same replace-only case. this bakes in inconsistent behavior instead of catching it.

proposed test fix
-			expect(assessment.skipped).toBe(0);
+			// preview/apply counters should stay consistent for the same restore intent.
+			// once lib behavior is corrected, this guards against regressions.
+			// test/storage.test.ts:1272
+			expect(assessment.skipped).toBe(1);
@@
-			const restoreResult = await restoreNamedBackup("Replace Only");
-			expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
+			const restoreResult = await restoreNamedBackup("Replace Only");
+			expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
+			expect(restoreResult.skipped).toBe(assessment.skipped);
📝 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
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
expect(assessment.skipped).toBe(0);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);
const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
expect(assessment.backupAccountCount).toBe(1);
expect(assessment.dedupedBackupAccountCount).toBe(1);
expect(assessment.imported).toBe(0);
// preview/apply counters should stay consistent for the same restore intent.
// once lib behavior is corrected, this guards against regressions.
// test/storage.test.ts:1272
expect(assessment.skipped).toBe(1);
expect(assessment.replacedExistingCount).toBe(1);
expect(assessment.eligibleForRestore).toBe(true);
expect(assessment.wouldExceedLimit).toBe(false);
expect(assessment.activeAccountOutcome).toBe("unchanged");
expect(assessment.activeAccountChanged).toBe(false);
expect(assessment.namedBackupRestorePreview?.conflicts).toEqual([
expect.objectContaining({
conflict: expect.objectContaining({
backupIndex: 0,
currentIndex: 0,
resolution: "backup-kept",
reasons: expect.arrayContaining(["accountId", "email"]),
}),
}),
]);
const restoreResult = await restoreNamedBackup("Replace Only");
expect(restoreResult).toEqual({ imported: 0, skipped: 1, total: 1 });
expect(restoreResult.skipped).toBe(assessment.skipped);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage.test.ts` around lines 1269 - 1291, The test currently asserts a
mismatch between the assessment preview and the actual restore:
assessment.skipped is asserted as 0 while restoreNamedBackup("Replace Only")
returns skipped: 1; make these consistent by updating the assessment expectation
to expect(assessment.skipped).toBe(1) (leave assessment.imported and the
restoreResult assertion unchanged) so the preview and apply counts match;
reference the assessment object assertions and the restoreNamedBackup /
restoreResult check to locate the two assertions to adjust.

@ndycode ndycode changed the base branch from fresh/12-backup-browser to dev March 18, 2026 06:47
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: 3

Caution

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

⚠️ Outside diff range comments (1)
lib/storage.ts (1)

3127-3148: ⚠️ Potential issue | 🔴 Critical

pin transaction persists to the storage path captured at transaction start.

lib/storage.ts:3134 and lib/storage.ts:3161 capture state.storagePath, but the transaction persist and rollback closures at lib/storage.ts:3141-3143 and lib/storage.ts:3174-3181 still route through saveAccountsUnlocked() / saveFlaggedAccountsUnlocked(), which resolve paths from the live globals at lib/storage.ts:2999 and lib/storage.ts:3446-3447. a concurrent setStoragePath() can make a transaction read repo a and commit into repo b, which is straight data corruption. test/storage.test.ts:369-395 already proves path drift is a real scenario; please add a vitest that flips the path before persist() and asserts only the original files change. 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.

Also applies to: 3151-3202

🤖 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/storage.ts`:
- Around line 2082-2090: The fast-path in
getActionableNamedBackupRestores()/scannedBackups currently drops
entry.candidate.rawAccounts which causes inconsistent
skipped/conflict/replacedExistingCount math between
assessNamedBackupRestoreCandidate(), restoreNamedBackup(), and preview paths;
update the fast assessment loop to thread entry.candidate.rawAccounts (or the
exact transaction inputs) into assessNamedBackupRestoreCandidate() and use those
inputs when computing skipped/conflict/replacedExistingCount (instead of
normalized.accounts.length or preview-time replacedExistingCount) so counters
are computed from actual transaction inputs; add vitest tests that cover
duplicate raw backup rows and a preview→apply storage race to assert counts
remain identical, and ensure any new queuing/code handles EBUSY/429 retries and
that logging in these changes does not leak tokens/emails.
- Around line 2429-2448: restoreNamedBackup currently trusts a caller-provided
options.assessment without proving the freshly loaded candidate (via
loadImportableBackupCandidate) is the same backup the assessment was built
against; to fix, always compute and compare a stable fingerprint/metadata of the
loaded candidate (use buildNamedBackupMetadata and any stable fields used by
assessNamedBackupRestoreCandidate such as checksum/mtime/size or an explicit id)
against the assessment passed in options, and if they differ re-run
assessNamedBackupRestoreCandidate (or reject with a clear error) before calling
importNormalizedAccounts; update restoreNamedBackup to reference
loadImportableBackupCandidate, buildNamedBackupMetadata,
assessNamedBackupRestoreCandidate and importNormalizedAccounts accordingly and
add a vitest that simulates a file-modify-between-preview-and-apply case (same
name, changed valid JSON) to assert the operation is rejected or re-assessed.

In `@test/storage.test.ts`:
- Around line 1424-1478: The tests "throws when a named backup is deleted after
assessment" and "throws when a named backup becomes invalid JSON after
assessment" call assessNamedBackupRestore(...) to get an assessment but then
call restoreNamedBackup(...) without passing that assessment; update both
restoreNamedBackup calls to pass the previewed result (e.g.
restoreNamedBackup("deleted-after-assessment", { assessment }) and
restoreNamedBackup("invalid-after-assessment", { assessment })) so the tests
exercise the prevalidated/previewed restore path used in lib/codex-manager.ts
(restoreNamedBackup and assessNamedBackupRestore).
🪄 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: 17d66d4c-8201-4556-9326-9e58f7786fd9

📥 Commits

Reviewing files that changed from the base of the PR and between 4492f73 and 0f03325.

📒 Files selected for processing (2)
  • lib/storage.ts
  • test/storage.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/storage.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/storage.test.ts

Comment on lines +2082 to +2090
if (usesFastPath) {
for (const entry of scannedBackups) {
try {
const assessment = assessNamedBackupRestoreCandidate(
entry.backup,
entry.candidate,
currentStorage,
);
recordAssessment(assessment);
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

thread raw backup rows through every preview/apply path.

lib/storage.ts:2085-2089 drops entry.candidate.rawAccounts on the fast assessment path, and lib/storage.ts:2563-2569 derives skipped from normalized.accounts.length. backups with duplicate raw rows will therefore report different conflict/skipped counts between getActionableNamedBackupRestores(), assessNamedBackupRestore(), and restoreNamedBackup(). restoreNamedBackup() also feeds preview-time replacedExistingCount into this math from lib/storage.ts:2446-2448, so a current-storage race can skew the apply result even when the file is unchanged. the existing replace-only case at test/storage.test.ts:1228-1293 will not catch either drift. please compute these counters from the actual transaction inputs and add vitest coverage for duplicate backup rows plus preview→apply storage races. 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.

Also applies to: 2528-2570

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

In `@lib/storage.ts` around lines 2082 - 2090, The fast-path in
getActionableNamedBackupRestores()/scannedBackups currently drops
entry.candidate.rawAccounts which causes inconsistent
skipped/conflict/replacedExistingCount math between
assessNamedBackupRestoreCandidate(), restoreNamedBackup(), and preview paths;
update the fast assessment loop to thread entry.candidate.rawAccounts (or the
exact transaction inputs) into assessNamedBackupRestoreCandidate() and use those
inputs when computing skipped/conflict/replacedExistingCount (instead of
normalized.accounts.length or preview-time replacedExistingCount) so counters
are computed from actual transaction inputs; add vitest tests that cover
duplicate raw backup rows and a preview→apply storage race to assert counts
remain identical, and ensure any new queuing/code handles EBUSY/429 retries and
that logging in these changes does not leak tokens/emails.

Comment on lines +2429 to +2448
export async function restoreNamedBackup(
name: string,
options: { assessment?: BackupRestoreAssessment } = {},
): Promise<{ imported: number; total: number; skipped: number }> {
const backupPath = await resolveNamedBackupRestorePath(name);
const candidate = await loadImportableBackupCandidate(backupPath);
const assessment =
options.assessment ??
assessNamedBackupRestoreCandidate(
await buildNamedBackupMetadata(name, backupPath, { candidate }),
candidate,
await loadAccounts(),
candidate.rawAccounts,
);
if (!assessment.eligibleForRestore) {
throw new Error(assessment.error ?? "Backup is not eligible for restore");
}
return importNormalizedAccounts(candidate.normalized, backupPath, {
replacedExistingCount: assessment.replacedExistingCount ?? 0,
});
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

revalidate the preview against the file you actually import.

lib/storage.ts:2433-2444 will honor options.assessment without proving the candidate loaded at lib/storage.ts:2434 is still the same backup the user previewed. if that file is rewritten with new valid contents between preview and apply, restore can import different accounts under stale eligibility and counter data. test/storage.test.ts:1424-1478 only covers missing and invalid files, so please add a vitest for the { assessment } path where the backup keeps the same name but new valid json. 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/storage.ts` around lines 2429 - 2448, restoreNamedBackup currently trusts
a caller-provided options.assessment without proving the freshly loaded
candidate (via loadImportableBackupCandidate) is the same backup the assessment
was built against; to fix, always compute and compare a stable
fingerprint/metadata of the loaded candidate (use buildNamedBackupMetadata and
any stable fields used by assessNamedBackupRestoreCandidate such as
checksum/mtime/size or an explicit id) against the assessment passed in options,
and if they differ re-run assessNamedBackupRestoreCandidate (or reject with a
clear error) before calling importNormalizedAccounts; update restoreNamedBackup
to reference loadImportableBackupCandidate, buildNamedBackupMetadata,
assessNamedBackupRestoreCandidate and importNormalizedAccounts accordingly and
add a vitest that simulates a file-modify-between-preview-and-apply case (same
name, changed valid JSON) to assert the operation is rejected or re-assessed.

Comment on lines +1424 to +1478
it("throws when a named backup is deleted after assessment", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "deleted-backup",
refreshToken: "ref-deleted-backup",
addedAt: 1,
lastUsed: 1,
},
],
});

const backup = await createNamedBackup("deleted-after-assessment");
await clearAccounts();

const assessment = await assessNamedBackupRestore("deleted-after-assessment");
expect(assessment.eligibleForRestore).toBe(true);

await removeWithRetry(backup.path, { force: true });

await expect(
restoreNamedBackup("deleted-after-assessment"),
).rejects.toThrow(/Import file not found/);
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
});

it("throws when a named backup becomes invalid JSON after assessment", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "invalid-backup",
refreshToken: "ref-invalid-backup",
addedAt: 1,
lastUsed: 1,
},
],
});

const backup = await createNamedBackup("invalid-after-assessment");
await clearAccounts();

const assessment = await assessNamedBackupRestore("invalid-after-assessment");
expect(assessment.eligibleForRestore).toBe(true);

await fs.writeFile(backup.path, "not valid json {[", "utf-8");

await expect(
restoreNamedBackup("invalid-after-assessment"),
).rejects.toThrow(/Invalid JSON in import file/);
expect((await loadAccounts())?.accounts ?? []).toHaveLength(0);
});
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

exercise the prevalidated restore path in these stale-backup tests.

lib/codex-manager.ts:4700-4710 and lib/codex-manager.ts:4952-4987 pass the previewed assessment into restoreNamedBackup(), but test/storage.test.ts:1446-1476 does not. as written, both tests only cover a fresh restore, not the preview→apply path this pr adds. pass { assessment } into each restore so the suite actually proves stale previews still fail after deletion or file corruption.

proposed test fix
-			await expect(
-				restoreNamedBackup("deleted-after-assessment"),
-			).rejects.toThrow(/Import file not found/);
+			await expect(
+				restoreNamedBackup("deleted-after-assessment", { assessment }),
+			).rejects.toThrow(/Import file not found/);
@@
-			await expect(
-				restoreNamedBackup("invalid-after-assessment"),
-			).rejects.toThrow(/Invalid JSON in import file/);
+			await expect(
+				restoreNamedBackup("invalid-after-assessment", { assessment }),
+			).rejects.toThrow(/Invalid JSON in import file/);

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 1424 - 1478, The tests "throws when a
named backup is deleted after assessment" and "throws when a named backup
becomes invalid JSON after assessment" call assessNamedBackupRestore(...) to get
an assessment but then call restoreNamedBackup(...) without passing that
assessment; update both restoreNamedBackup calls to pass the previewed result
(e.g. restoreNamedBackup("deleted-after-assessment", { assessment }) and
restoreNamedBackup("invalid-after-assessment", { assessment })) so the tests
exercise the prevalidated/previewed restore path used in lib/codex-manager.ts
(restoreNamedBackup and assessNamedBackupRestore).

Comment on lines +190 to +206
}

export async function appendSyncHistoryEntry(
entry: SyncHistoryEntry,
): Promise<void> {
const writePromise = withHistoryLock(async () => {
const paths = getSyncHistoryPaths();
lastAppendPaths = paths;
await ensureHistoryDir(paths.directory);
await fs.appendFile(paths.historyPath, `${serializeEntry(entry)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await trimHistoryFileIfNeeded(paths);
await fs.writeFile(paths.latestPath, `${JSON.stringify(entry, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
Copy link

Choose a reason for hiding this comment

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

P1 trimHistoryFileIfNeeded is not atomic — windows crash between truncation and write corrupts history

fs.writeFile with O_WRONLY | O_CREAT | O_TRUNC first truncates the file to zero bytes, then writes the trimmed content. if the process is killed (or windows EPERM/EBUSY interrupts the write) between the truncation and the full write completing, the history file is left empty or partially written with no way to recover the entries.

this is inside the historyMutex lock so concurrent access is safe, but the lock offers no protection against mid-write process termination on windows.

prefer the temp-file + rename pattern used elsewhere in this codebase for atomic writes:

const tempPath = `${paths.historyPath}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, trimmedContent, { encoding: "utf8", mode: 0o600 });
await fs.rename(tempPath, paths.historyPath);

fs.rename is atomic on windows (within the same volume) and means the old history is never lost — either the old file persists or the trimmed file is complete.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/sync-history.ts
Line: 190-206

Comment:
**`trimHistoryFileIfNeeded` is not atomic — windows crash between truncation and write corrupts history**

`fs.writeFile` with `O_WRONLY | O_CREAT | O_TRUNC` first truncates the file to zero bytes, then writes the trimmed content. if the process is killed (or windows EPERM/EBUSY interrupts the write) between the truncation and the full write completing, the history file is left empty or partially written with no way to recover the entries.

this is inside the `historyMutex` lock so concurrent access is safe, but the lock offers no protection against mid-write process termination on windows.

prefer the temp-file + rename pattern used elsewhere in this codebase for atomic writes:

```ts
const tempPath = `${paths.historyPath}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, trimmedContent, { encoding: "utf8", mode: 0o600 });
await fs.rename(tempPath, paths.historyPath);
```

`fs.rename` is atomic on windows (within the same volume) and means the old history is never lost — either the old file persists or the trimmed file is complete.

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

Fix in Codex

@ndycode
Copy link
Owner Author

ndycode commented Mar 19, 2026

Closing this preserved fresh/* PR as superseded. Restore-preview behavior is already represented on current dev, and this branch is still the old large dirty review surface rather than the clean replacement path. Keeping it open no longer improves review safety.

@ndycode ndycode closed this Mar 19, 2026
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