From 96a47b42e02dc07dd9c18ca2b81d28377247db6e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:52:04 +0000 Subject: [PATCH] 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. --- .../plugins/usage-sources/index.test.ts | 41 ++++++++++++++++++- .../src/server/plugins/usage-sources/index.ts | 35 ++++++++++++---- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 376f482dd7..3550b3acca 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -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"]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index a2121fa334..2aedf99a2c 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -45,6 +45,11 @@ interface DiscoverOptions { previous?: ReadonlyMap; } +interface PresentReportOptions { + entry: UsageReportEntry; + known: KnownReport; +} + interface MergeReportsOptions { into: Map; 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, + })), + } + : {}), + }; +}