Skip to content

Commit 0964138

Browse files
committed
feat: surface explicit storage health state
1 parent d981715 commit 0964138

7 files changed

Lines changed: 222 additions & 5 deletions

File tree

lib/codex-manager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
clearAccounts,
101101
findMatchingAccountIndex,
102102
formatStorageErrorHint,
103+
inspectStorageHealth,
103104
getLastAccountsSaveTimestamp,
104105
getNamedBackups,
105106
getStoragePath,
@@ -3203,6 +3204,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
32033204
setStoragePath,
32043205
getStoragePath,
32053206
loadAccounts,
3207+
inspectStorageHealth,
32063208
resolveActiveIndex,
32073209
formatRateLimitEntry,
32083210
});
@@ -3234,6 +3236,7 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
32343236
setStoragePath,
32353237
getStoragePath,
32363238
loadAccounts,
3239+
inspectStorageHealth,
32373240
saveAccounts,
32383241
resolveActiveIndex,
32393242
hasUsableAccessToken,

lib/codex-manager/commands/report.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import {
2626
getModelProfile,
2727
resolveNormalizedModel,
2828
} from "../../request/helpers/model-map.js";
29-
import { type AccountMetadataV3, type AccountStorageV3 } from "../../storage.js";
29+
import {
30+
type AccountMetadataV3,
31+
type AccountStorageV3,
32+
type StorageHealthSummary,
33+
} from "../../storage.js";
3034
import type { TokenFailure, TokenResult } from "../../types.js";
3135
import { sleep } from "../../utils.js";
3236

@@ -76,6 +80,7 @@ export interface ReportCommandDeps {
7680
now: number,
7781
family: "codex",
7882
) => string | null;
83+
inspectStorageHealth?: () => Promise<StorageHealthSummary>;
7984
normalizeFailureDetail: (
8085
message: string | undefined,
8186
reason: string | undefined,
@@ -285,6 +290,7 @@ export async function runReportCommand(
285290
deps.setStoragePath(null);
286291
const storagePath = deps.getStoragePath();
287292
const storage = await deps.loadAccounts();
293+
const storageHealth = await deps.inspectStorageHealth?.();
288294
const now = deps.getNow?.() ?? Date.now();
289295
const accountCount = storage?.accounts.length ?? 0;
290296
const activeIndex = storage ? deps.resolveActiveIndex(storage, "codex") : 0;
@@ -455,6 +461,7 @@ export async function runReportCommand(
455461
command: "report",
456462
generatedAt: new Date(now).toISOString(),
457463
storagePath,
464+
storageHealth,
458465
model: requestedModel,
459466
modelSelection: {
460467
requested: modelInspection.requested,
@@ -521,6 +528,9 @@ export async function runReportCommand(
521528

522529
logInfo(`Report generated at ${report.generatedAt}`);
523530
logInfo(`Storage: ${report.storagePath}`);
531+
if (report.storageHealth) {
532+
logInfo(`Storage health: ${report.storageHealth.state}`);
533+
}
524534
logInfo(`Model: ${formatModelInspection(modelInspection)}`);
525535
if (options.live) {
526536
const budgetParts = [

lib/codex-manager/commands/status.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
recommendForecastAccount,
99
} from "../../forecast.js";
1010
import type { ModelFamily } from "../../prompts/codex.js";
11-
import type { AccountStorageV3 } from "../../storage.js";
11+
import type { AccountStorageV3, StorageHealthSummary } from "../../storage.js";
1212

1313
type LoadedStorage = AccountStorageV3 | null;
1414

@@ -25,6 +25,7 @@ export interface StatusCommandDeps {
2525
now: number,
2626
family: ModelFamily,
2727
) => string | null;
28+
inspectStorageHealth?: () => Promise<StorageHealthSummary>;
2829
getNow?: () => number;
2930
logInfo?: (message: string) => void;
3031
}
@@ -35,10 +36,22 @@ export async function runStatusCommand(
3536
deps.setStoragePath(null);
3637
const storage = await deps.loadAccounts();
3738
const path = deps.getStoragePath();
39+
const storageHealth = await deps.inspectStorageHealth?.();
3840
const logInfo = deps.logInfo ?? console.log;
3941
if (!storage || storage.accounts.length === 0) {
40-
logInfo("No accounts configured.");
42+
logInfo(
43+
storageHealth?.state === "intentional-reset"
44+
? "No accounts configured. Storage was intentionally reset."
45+
: storageHealth?.state === "recoverable"
46+
? "No accounts configured. Recovery artifacts are available."
47+
: storageHealth?.state === "corrupt"
48+
? "No accounts configured. Storage appears corrupted."
49+
: "No accounts configured.",
50+
);
4151
logInfo(`Storage: ${path}`);
52+
if (storageHealth) {
53+
logInfo(`Storage health: ${storageHealth.state}`);
54+
}
4255
return 0;
4356
}
4457

@@ -59,6 +72,9 @@ export async function runStatusCommand(
5972
logInfo(
6073
`Selection reason: account ${recommendation.recommendedIndex + 1} (${recommendation.reason})`,
6174
);
75+
}
76+
if (storageHealth) {
77+
logInfo(`Storage health: ${storageHealth.state}`);
6278
}
6379
logInfo("");
6480

lib/storage.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ import { clearFlaggedAccountsEntry } from "./storage/flagged-entry.js";
5151
import { loadFlaggedAccountsEntry } from "./storage/flagged-load-entry.js";
5252
import { saveFlaggedAccountsEntry } from "./storage/flagged-save-entry.js";
5353
import { normalizeFlaggedStorage } from "./storage/flagged-storage.js";
54+
import {
55+
createStorageHealthSummary,
56+
type StorageHealthSummary,
57+
} from "./storage/health.js";
5458
import {
5559
clearFlaggedAccountsOnDisk,
5660
loadFlaggedAccountsState,
@@ -128,6 +132,7 @@ import {
128132
} from "./storage/transactions.js";
129133

130134
export type {
135+
StorageHealthSummary,
131136
CooldownReason,
132137
RateLimitStateV3,
133138
AccountMetadataV1,
@@ -1264,6 +1269,105 @@ export async function getRestoreAssessment(): Promise<RestoreAssessment> {
12641269
});
12651270
}
12661271

1272+
export async function inspectStorageHealth(): Promise<StorageHealthSummary> {
1273+
const path = getStoragePath();
1274+
const walPath = getAccountsWalPath(path);
1275+
const resetMarkerPath = getIntentionalResetMarkerPath(path);
1276+
if (existsSync(resetMarkerPath)) {
1277+
return createStorageHealthSummary({
1278+
state: "intentional-reset",
1279+
path,
1280+
walPath,
1281+
resetMarkerPath,
1282+
details: "intentional reset marker present",
1283+
});
1284+
}
1285+
if (!existsSync(path)) {
1286+
const walRecovered = await loadAccountsFromJournal(path);
1287+
if (walRecovered && walRecovered.accounts.length > 0) {
1288+
return createStorageHealthSummary({
1289+
state: "recoverable",
1290+
path,
1291+
walPath,
1292+
resetMarkerPath,
1293+
details: "primary storage missing but WAL recovery is available",
1294+
recoverySource: "wal",
1295+
});
1296+
}
1297+
return createStorageHealthSummary({
1298+
state: "empty",
1299+
path,
1300+
walPath,
1301+
resetMarkerPath,
1302+
details: "storage file is missing",
1303+
});
1304+
}
1305+
try {
1306+
const { normalized, schemaErrors } = await loadAccountsFromPath(path, {
1307+
normalizeAccountStorage,
1308+
isRecord,
1309+
});
1310+
if (normalized && normalized.accounts.length > 0) {
1311+
return createStorageHealthSummary({
1312+
state: "healthy",
1313+
path,
1314+
walPath,
1315+
resetMarkerPath,
1316+
schemaErrors,
1317+
});
1318+
}
1319+
if (normalized) {
1320+
return createStorageHealthSummary({
1321+
state: "empty",
1322+
path,
1323+
walPath,
1324+
resetMarkerPath,
1325+
schemaErrors,
1326+
details: "storage parsed but contains no accounts",
1327+
});
1328+
}
1329+
const walRecovered = await loadAccountsFromJournal(path);
1330+
if (walRecovered && walRecovered.accounts.length > 0) {
1331+
return createStorageHealthSummary({
1332+
state: "recoverable",
1333+
path,
1334+
walPath,
1335+
resetMarkerPath,
1336+
schemaErrors,
1337+
details: "primary storage is invalid but WAL recovery is available",
1338+
recoverySource: "wal",
1339+
});
1340+
}
1341+
return createStorageHealthSummary({
1342+
state: "corrupt",
1343+
path,
1344+
walPath,
1345+
resetMarkerPath,
1346+
schemaErrors,
1347+
details: "storage could not be normalized",
1348+
});
1349+
} catch (error) {
1350+
const walRecovered = await loadAccountsFromJournal(path);
1351+
if (walRecovered && walRecovered.accounts.length > 0) {
1352+
return createStorageHealthSummary({
1353+
state: "recoverable",
1354+
path,
1355+
walPath,
1356+
resetMarkerPath,
1357+
details: error instanceof Error ? error.message : String(error),
1358+
recoverySource: "wal",
1359+
});
1360+
}
1361+
return createStorageHealthSummary({
1362+
state: "corrupt",
1363+
path,
1364+
walPath,
1365+
resetMarkerPath,
1366+
details: error instanceof Error ? error.message : String(error),
1367+
});
1368+
}
1369+
}
1370+
12671371
async function loadAccountsFromJournal(
12681372
path: string,
12691373
): Promise<AccountStorageV3 | null> {

lib/storage/health.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { existsSync } from "node:fs";
2+
3+
export type StorageHealthState =
4+
| "healthy"
5+
| "empty"
6+
| "intentional-reset"
7+
| "corrupt"
8+
| "recoverable";
9+
10+
export interface StorageHealthSummary {
11+
state: StorageHealthState;
12+
path: string;
13+
resetMarkerPath: string;
14+
walPath: string;
15+
hasResetMarker: boolean;
16+
hasWal: boolean;
17+
details?: string;
18+
schemaErrors?: string[];
19+
recoverySource?: "wal";
20+
}
21+
22+
export function createStorageHealthSummary(params: {
23+
state: StorageHealthState;
24+
path: string;
25+
resetMarkerPath: string;
26+
walPath: string;
27+
details?: string;
28+
schemaErrors?: string[];
29+
recoverySource?: "wal";
30+
}): StorageHealthSummary {
31+
return {
32+
state: params.state,
33+
path: params.path,
34+
resetMarkerPath: params.resetMarkerPath,
35+
walPath: params.walPath,
36+
hasResetMarker: existsSync(params.resetMarkerPath),
37+
hasWal: existsSync(params.walPath),
38+
details: params.details,
39+
schemaErrors: params.schemaErrors,
40+
recoverySource: params.recoverySource,
41+
};
42+
}

test/codex-manager-report-command.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
type ReportCommandDeps,
44
runReportCommand,
55
} from "../lib/codex-manager/commands/report.js";
6-
import type { AccountStorageV3 } from "../lib/storage.js";
6+
import type { AccountStorageV3, StorageHealthSummary } from "../lib/storage.js";
77

88
function createStorage(
99
accounts: AccountStorageV3["accounts"] = [
@@ -50,6 +50,14 @@ function createDeps(
5050
secondary: {},
5151
})),
5252
formatRateLimitEntry: vi.fn(() => null),
53+
inspectStorageHealth: vi.fn(async (): Promise<StorageHealthSummary> => ({
54+
state: "healthy",
55+
path: "/mock/openai-codex-accounts.json",
56+
resetMarkerPath: "/mock/openai-codex-accounts.json.intentional-reset",
57+
walPath: "/mock/openai-codex-accounts.json.wal",
58+
hasResetMarker: false,
59+
hasWal: false,
60+
})),
5361
normalizeFailureDetail: vi.fn((message) => message ?? "unknown"),
5462
logInfo: vi.fn(),
5563
logError: vi.fn(),
@@ -111,6 +119,9 @@ describe("runReportCommand", () => {
111119
expect(deps.logInfo).toHaveBeenCalledWith(
112120
expect.stringContaining('"liveProbeBudget"'),
113121
);
122+
expect(deps.logInfo).toHaveBeenCalledWith(
123+
expect.stringContaining('"storageHealth"'),
124+
);
114125
});
115126

116127
it("respects live probe account and probe budgets", async () => {

test/codex-manager-status-command.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
runStatusCommand,
66
type StatusCommandDeps,
77
} from "../lib/codex-manager/commands/status.js";
8-
import type { AccountStorageV3 } from "../lib/storage.js";
8+
import type { AccountStorageV3, StorageHealthSummary } from "../lib/storage.js";
99

1010
function createStorage(): AccountStorageV3 {
1111
return {
@@ -39,6 +39,14 @@ function createStatusDeps(
3939
loadAccounts: vi.fn(async () => createStorage()),
4040
resolveActiveIndex: vi.fn(() => 0),
4141
formatRateLimitEntry: vi.fn(() => null),
42+
inspectStorageHealth: vi.fn(async (): Promise<StorageHealthSummary> => ({
43+
state: "healthy",
44+
path: "/tmp/codex.json",
45+
resetMarkerPath: "/tmp/codex.json.intentional-reset",
46+
walPath: "/tmp/codex.json.wal",
47+
hasResetMarker: false,
48+
hasWal: false,
49+
})),
4250
getNow: vi.fn(() => 2_000),
4351
logInfo: vi.fn(),
4452
...overrides,
@@ -55,6 +63,29 @@ describe("runStatusCommand", () => {
5563
expect(deps.getStoragePath).toHaveBeenCalledTimes(1);
5664
expect(deps.logInfo).toHaveBeenCalledWith("No accounts configured.");
5765
expect(deps.logInfo).toHaveBeenCalledWith("Storage: /tmp/codex.json");
66+
expect(deps.logInfo).toHaveBeenCalledWith("Storage health: healthy");
67+
});
68+
69+
it("prints explicit corrupt storage state for empty result cases", async () => {
70+
const deps = createStatusDeps({
71+
loadAccounts: vi.fn(async () => null),
72+
inspectStorageHealth: vi.fn(async () => ({
73+
state: "corrupt",
74+
path: "/tmp/codex.json",
75+
resetMarkerPath: "/tmp/codex.json.intentional-reset",
76+
walPath: "/tmp/codex.json.wal",
77+
hasResetMarker: false,
78+
hasWal: false,
79+
details: "Unexpected token",
80+
})),
81+
});
82+
83+
await runStatusCommand(deps);
84+
85+
expect(deps.logInfo).toHaveBeenCalledWith(
86+
"No accounts configured. Storage appears corrupted.",
87+
);
88+
expect(deps.logInfo).toHaveBeenCalledWith("Storage health: corrupt");
5889
});
5990

6091
it("prints account rows with current and disabled markers", async () => {

0 commit comments

Comments
 (0)