fix(usage): preserve saved usage when a provider is renamed

Polling identity belongs to the ordered login inputs. Apply current display labels when returning cached or pending reports so renaming a configured provider keeps cached values and successful history.
This commit is contained in:
paseo-bot[bot]
2026-11-08 06:52:04 +00:00
parent bde2cfadf1
commit 96a47b42e0
2 changed files with 68 additions and 8 deletions
@@ -175,7 +175,7 @@ test("concurrent requests for the same ID share one vendor fetch", async () => {
const second = registry.listReports();
finish({ status: "available", windows: [] });
const [one, two] = await Promise.all([first, second]);
expect(one[0]).toBe(two[0]);
expect(one[0]).toEqual(two[0]);
expect(fetches).toBe(1);
});
@@ -963,3 +963,42 @@ test("unregistering a source drops the accounts each host scope kept for it", as
fails = true;
expect(await registry.listReports()).toEqual([]);
});
test("#3589: renaming a provider preserves cached usage and recovery history while updating error labels", async () => {
let label = "Before";
let available = true;
let polls = 0;
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: () => false,
usageSession: () => null,
providerEnvironments: () => [
{ provider: "claude", label, env: { CLAUDE_CONFIG_DIR: "/work" } },
],
});
registry.register({
id: "claude",
label: "Claude",
discover: async (scope) =>
scope.kind === "global" ? [] : [{ key: "work", input: { path: "/work" } }],
fetch: async () => {
polls++;
return available
? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }
: { status: "error", error: "offline" };
},
});
const [success] = await registry.listReports();
available = false;
label = "After";
const [cached] = await registry.listReports();
expect(cached?.report).toEqual(success?.report);
expect(polls).toBe(1);
const [failed] = await registry.listReports({ forceRefresh: true });
expect(failed?.lastSuccessful?.report).toEqual(success?.report);
expect(failed?.loginErrors?.map((login) => login.harness)).toEqual(["After"]);
label = "Renamed again";
const [relabeled] = await registry.listReports();
expect(polls).toBe(2);
expect(relabeled?.lastSuccessful).toEqual(failed?.lastSuccessful);
expect(relabeled?.loginErrors?.map((login) => login.harness)).toEqual(["Renamed again"]);
});
@@ -45,6 +45,11 @@ interface DiscoverOptions {
previous?: ReadonlyMap<string, KnownReport>;
}
interface PresentReportOptions {
entry: UsageReportEntry;
known: KnownReport;
}
interface MergeReportsOptions {
into: Map<string, KnownReport>;
reports: Iterable<[string, KnownReport]>;
@@ -157,8 +162,9 @@ export class UsageSourceRegistry {
if (!known) return [];
return [
this.fetchId(id, known, options.forceRefresh).then((entry) => {
options.onReport?.(entry);
return entry;
const report = presentReport({ entry, known });
options.onReport?.(report);
return report;
}),
];
}),
@@ -452,10 +458,6 @@ function sameLogin(a: Login, b: Login): boolean {
return canonicalJson(a.input) === canonicalJson(b.input);
}
function loginKey(login: Login): string {
return canonicalJson(login);
}
function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, nested) =>
nested !== null && typeof nested === "object" && !Array.isArray(nested)
@@ -466,6 +468,25 @@ function canonicalJson(value: unknown): string {
function cacheIdentity(id: string, known: KnownReport): string {
return `${id}:${createHash("sha256")
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
.update(canonicalJson(known.logins.map((login) => login.input)))
.digest("hex")}`;
}
// Labels describe the current discovery, while cached polls and history belong to login inputs.
function presentReport({ entry, known }: PresentReportOptions): UsageReportEntry {
return {
...entry,
sourceLabel: known.source.label,
icon: known.source.icon,
account: { label: known.label },
...(entry.loginErrors
? {
// Errors are emitted only when every ordered login failed, so positions match inputs.
loginErrors: entry.loginErrors.map((error, index) => ({
...error,
harness: known.logins[index]!.harness,
})),
}
: {}),
};
}