Skip to content

Commit a6da983

Browse files
committed
refactor: share forecast and report command helpers
The forecast and report commands duplicated the same refreshed-account persistence flow and forecast-result serialization logic. That increases maintenance cost and makes future fixes easy to miss in one path. Extract the shared pieces into a single helper module and keep both commands delegating to the same implementation.
1 parent 91bd5a4 commit a6da983

3 files changed

Lines changed: 151 additions & 256 deletions

File tree

lib/codex-manager/commands/forecast.ts

Lines changed: 8 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import {
44
buildForecastExplanation,
55
type ForecastAccountResult,
66
} from "../../forecast.js";
7+
import {
8+
applyRefreshedAccountPatch,
9+
persistRefreshedAccountPatch,
10+
serializeForecastResults,
11+
type AccountIdentityMatch,
12+
type RefreshedAccountPatch,
13+
} from "../forecast-report-shared.js";
714
import type { QuotaCacheData } from "../../quota-cache.js";
815
import type { CodexQuotaSnapshot } from "../../quota-probe.js";
916
import { resolveNormalizedModel } from "../../request/helpers/model-map.js";
10-
import {
11-
findMatchingAccountIndex,
12-
type AccountMetadataV3,
13-
type AccountStorageV3,
14-
} from "../../storage.js";
17+
import { type AccountMetadataV3, type AccountStorageV3 } from "../../storage.js";
1518
import type { TokenFailure, TokenResult } from "../../types.js";
16-
import { sleep } from "../../utils.js";
1719

1820
interface ForecastCliOptions {
1921
live: boolean;
@@ -32,8 +34,6 @@ type QuotaEmailFallbackState = ReadonlyMap<
3234
{ matchingCount: number; distinctAccountIds: Set<string> }
3335
>;
3436

35-
const RETRYABLE_STORAGE_WRITE_CODES = new Set(["EBUSY", "EPERM"]);
36-
3737
export interface ForecastCommandDeps {
3838
setStoragePath: (path: string | null) => void;
3939
loadAccounts: () => Promise<AccountStorageV3 | null>;
@@ -112,82 +112,6 @@ export interface ForecastCommandDeps {
112112
getNow?: () => number;
113113
}
114114

115-
function isRetryableStorageWriteError(error: unknown): boolean {
116-
const code = (error as NodeJS.ErrnoException | undefined)?.code;
117-
return typeof code === "string" && RETRYABLE_STORAGE_WRITE_CODES.has(code);
118-
}
119-
120-
async function saveAccountsWithRetry(
121-
storage: AccountStorageV3,
122-
saveAccounts: ForecastCommandDeps["saveAccounts"],
123-
): Promise<void> {
124-
for (let attempt = 0; ; attempt += 1) {
125-
try {
126-
await saveAccounts(storage);
127-
return;
128-
} catch (error) {
129-
if (!isRetryableStorageWriteError(error) || attempt >= 3) {
130-
throw error;
131-
}
132-
await sleep(10 * 2 ** attempt);
133-
}
134-
}
135-
}
136-
137-
type AccountIdentityMatch = Pick<
138-
AccountMetadataV3,
139-
"accountId" | "email" | "refreshToken"
140-
>;
141-
type RefreshedAccountPatch = Pick<
142-
AccountMetadataV3,
143-
"refreshToken" | "accessToken" | "expiresAt"
144-
> & {
145-
email?: AccountMetadataV3["email"];
146-
accountId?: AccountMetadataV3["accountId"];
147-
accountIdSource?: AccountMetadataV3["accountIdSource"];
148-
};
149-
150-
function applyRefreshedAccountPatch(
151-
account: AccountMetadataV3,
152-
patch: RefreshedAccountPatch,
153-
): void {
154-
account.refreshToken = patch.refreshToken;
155-
account.accessToken = patch.accessToken;
156-
account.expiresAt = patch.expiresAt;
157-
if (patch.email) account.email = patch.email;
158-
if (patch.accountId) {
159-
account.accountId = patch.accountId;
160-
account.accountIdSource = patch.accountIdSource;
161-
}
162-
}
163-
164-
async function persistRefreshedAccountPatch(
165-
storage: AccountStorageV3,
166-
accountMatch: AccountIdentityMatch,
167-
patch: RefreshedAccountPatch,
168-
loadAccounts: ForecastCommandDeps["loadAccounts"],
169-
saveAccounts: ForecastCommandDeps["saveAccounts"],
170-
): Promise<void> {
171-
const latestStorage = (await loadAccounts()) ?? storage;
172-
const nextStorage = structuredClone(latestStorage);
173-
const targetIndex =
174-
findMatchingAccountIndex(nextStorage.accounts, accountMatch, {
175-
allowUniqueAccountIdFallbackWithoutEmail: true,
176-
}) ??
177-
findMatchingAccountIndex(nextStorage.accounts, patch, {
178-
allowUniqueAccountIdFallbackWithoutEmail: true,
179-
});
180-
if (targetIndex === undefined) {
181-
throw new Error("Unable to resolve refreshed account for persistence");
182-
}
183-
const targetAccount = nextStorage.accounts[targetIndex];
184-
if (!targetAccount) {
185-
throw new Error("Unable to resolve refreshed account for persistence");
186-
}
187-
applyRefreshedAccountPatch(targetAccount, patch);
188-
await saveAccountsWithRetry(nextStorage, saveAccounts);
189-
}
190-
191115
function joinStyledSegments(
192116
parts: string[],
193117
styleText: (text: string, tone: PromptTone) => string,
@@ -255,54 +179,6 @@ function parseForecastArgs(
255179
return { ok: true, options };
256180
}
257181

258-
function serializeForecastResults(
259-
results: ForecastAccountResult[],
260-
liveQuotaByIndex: Map<number, CodexQuotaSnapshot>,
261-
refreshFailures: Map<number, TokenFailure>,
262-
formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string,
263-
): Array<{
264-
index: number;
265-
label: string;
266-
isCurrent: boolean;
267-
availability: ForecastAccountResult["availability"];
268-
riskScore: number;
269-
riskLevel: ForecastAccountResult["riskLevel"];
270-
waitMs: number;
271-
reasons: string[];
272-
liveQuota?: {
273-
status: number;
274-
planType?: string;
275-
activeLimit?: number;
276-
model: string;
277-
summary: string;
278-
};
279-
refreshFailure?: TokenFailure;
280-
}> {
281-
return results.map((result) => {
282-
const liveQuota = liveQuotaByIndex.get(result.index);
283-
return {
284-
index: result.index,
285-
label: result.label,
286-
isCurrent: result.isCurrent,
287-
availability: result.availability,
288-
riskScore: result.riskScore,
289-
riskLevel: result.riskLevel,
290-
waitMs: result.waitMs,
291-
reasons: result.reasons,
292-
liveQuota: liveQuota
293-
? {
294-
status: liveQuota.status,
295-
planType: liveQuota.planType,
296-
activeLimit: liveQuota.activeLimit,
297-
model: liveQuota.model,
298-
summary: formatQuotaSnapshotLine(liveQuota),
299-
}
300-
: undefined,
301-
refreshFailure: refreshFailures.get(result.index),
302-
};
303-
});
304-
}
305-
306182
export async function runForecastCommand(
307183
args: string[],
308184
deps: ForecastCommandDeps & {

lib/codex-manager/commands/report.ts

Lines changed: 7 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import {
88
} from "../../accounts.js";
99
import {
1010
evaluateForecastAccounts,
11-
type ForecastAccountResult,
1211
recommendForecastAccount,
1312
summarizeForecast,
1413
} from "../../forecast.js";
14+
import {
15+
applyRefreshedAccountPatch,
16+
persistRefreshedAccountPatch,
17+
serializeForecastResults,
18+
} from "../forecast-report-shared.js";
1519
import {
1620
type CodexQuotaSnapshot,
1721
formatQuotaSnapshotLine,
@@ -22,11 +26,7 @@ import {
2226
getModelProfile,
2327
resolveNormalizedModel,
2428
} from "../../request/helpers/model-map.js";
25-
import {
26-
findMatchingAccountIndex,
27-
type AccountMetadataV3,
28-
type AccountStorageV3,
29-
} from "../../storage.js";
29+
import { type AccountMetadataV3, type AccountStorageV3 } from "../../storage.js";
3030
import type { TokenFailure, TokenResult } from "../../types.js";
3131
import { sleep } from "../../utils.js";
3232

@@ -159,53 +159,6 @@ function parseReportArgs(args: string[]): ParsedArgsResult<ReportCliOptions> {
159159
return { ok: true, options };
160160
}
161161

162-
function serializeForecastResults(
163-
results: ForecastAccountResult[],
164-
liveQuotaByIndex: Map<number, CodexQuotaSnapshot>,
165-
refreshFailures: Map<number, TokenFailure>,
166-
): Array<{
167-
index: number;
168-
label: string;
169-
isCurrent: boolean;
170-
availability: ForecastAccountResult["availability"];
171-
riskScore: number;
172-
riskLevel: ForecastAccountResult["riskLevel"];
173-
waitMs: number;
174-
reasons: string[];
175-
liveQuota?: {
176-
status: number;
177-
planType?: string;
178-
activeLimit?: number;
179-
model: string;
180-
summary: string;
181-
};
182-
refreshFailure?: TokenFailure;
183-
}> {
184-
return results.map((result) => {
185-
const liveQuota = liveQuotaByIndex.get(result.index);
186-
return {
187-
index: result.index,
188-
label: result.label,
189-
isCurrent: result.isCurrent,
190-
availability: result.availability,
191-
riskScore: result.riskScore,
192-
riskLevel: result.riskLevel,
193-
waitMs: result.waitMs,
194-
reasons: result.reasons,
195-
liveQuota: liveQuota
196-
? {
197-
status: liveQuota.status,
198-
planType: liveQuota.planType,
199-
activeLimit: liveQuota.activeLimit,
200-
model: liveQuota.model,
201-
summary: formatQuotaSnapshotLine(liveQuota),
202-
}
203-
: undefined,
204-
refreshFailure: refreshFailures.get(result.index),
205-
};
206-
});
207-
}
208-
209162
function inspectRequestedModel(requestedModel: string): ModelInspection {
210163
const normalized = resolveNormalizedModel(requestedModel);
211164
const profile = getModelProfile(normalized);
@@ -259,77 +212,6 @@ async function defaultWriteFile(path: string, contents: string): Promise<void> {
259212
}
260213
}
261214

262-
async function saveAccountsWithRetry(
263-
storage: AccountStorageV3,
264-
saveAccounts: ReportCommandDeps["saveAccounts"],
265-
): Promise<void> {
266-
for (let attempt = 0; ; attempt += 1) {
267-
try {
268-
await saveAccounts(storage);
269-
return;
270-
} catch (error) {
271-
if (!isRetryableWriteError(error) || attempt >= 3) {
272-
throw error;
273-
}
274-
await sleep(10 * 2 ** attempt);
275-
}
276-
}
277-
}
278-
279-
type AccountIdentityMatch = Pick<
280-
AccountMetadataV3,
281-
"accountId" | "email" | "refreshToken"
282-
>;
283-
type RefreshedAccountPatch = Pick<
284-
AccountMetadataV3,
285-
"refreshToken" | "accessToken" | "expiresAt"
286-
> & {
287-
email?: AccountMetadataV3["email"];
288-
accountId?: AccountMetadataV3["accountId"];
289-
accountIdSource?: AccountMetadataV3["accountIdSource"];
290-
};
291-
292-
function applyRefreshedAccountPatch(
293-
account: AccountMetadataV3,
294-
patch: RefreshedAccountPatch,
295-
): void {
296-
account.refreshToken = patch.refreshToken;
297-
account.accessToken = patch.accessToken;
298-
account.expiresAt = patch.expiresAt;
299-
if (patch.email) account.email = patch.email;
300-
if (patch.accountId) {
301-
account.accountId = patch.accountId;
302-
account.accountIdSource = patch.accountIdSource;
303-
}
304-
}
305-
306-
async function persistRefreshedAccountPatch(
307-
storage: AccountStorageV3,
308-
accountMatch: AccountIdentityMatch,
309-
patch: RefreshedAccountPatch,
310-
loadAccounts: ReportCommandDeps["loadAccounts"],
311-
saveAccounts: ReportCommandDeps["saveAccounts"],
312-
): Promise<void> {
313-
const latestStorage = (await loadAccounts()) ?? storage;
314-
const nextStorage = structuredClone(latestStorage);
315-
const targetIndex =
316-
findMatchingAccountIndex(nextStorage.accounts, accountMatch, {
317-
allowUniqueAccountIdFallbackWithoutEmail: true,
318-
}) ??
319-
findMatchingAccountIndex(nextStorage.accounts, patch, {
320-
allowUniqueAccountIdFallbackWithoutEmail: true,
321-
});
322-
if (targetIndex === undefined) {
323-
throw new Error("Unable to resolve refreshed account for persistence");
324-
}
325-
const targetAccount = nextStorage.accounts[targetIndex];
326-
if (!targetAccount) {
327-
throw new Error("Unable to resolve refreshed account for persistence");
328-
}
329-
applyRefreshedAccountPatch(targetAccount, patch);
330-
await saveAccountsWithRetry(nextStorage, saveAccounts);
331-
}
332-
333215
export async function runReportCommand(
334216
args: string[],
335217
deps: ReportCommandDeps,
@@ -522,6 +404,7 @@ export async function runReportCommand(
522404
forecastResults,
523405
liveQuotaByIndex,
524406
refreshFailures,
407+
formatQuotaSnapshotLine,
525408
),
526409
},
527410
};

0 commit comments

Comments
 (0)