From d33ca85c5e201b195bb8cdb4a931f5da264cf3d8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 13 Aug 2026 23:42:16 +0900 Subject: [PATCH 1/4] docs(devlog): lock usage-parse retention roadmap Incident: local ocx RSS 9.5 GiB from repeated 64 MiB /api/usage parses on a 243 MB usage.jsonl. Docs-only WP0: one owner flight, compact artifacts, 60s generation freshness, GUI stampede stop, WS deferred to PR 1608, release via clone of origin URL. --- .../260813_usage_parse_retention/000_plan.md | 64 +++++++++++++++++++ .../001_ws_queue_decision.md | 3 + .../010_append_tolerant_usage_snapshot.md | 29 +++++++++ .../020_gui_usage_stampede.md | 18 ++++++ .../030_ws_queue_if_unbounded.md | 3 + .../040_opus_sol_leak_review.md | 11 ++++ .../050_push_preview_main_release.md | 19 ++++++ 7 files changed, 147 insertions(+) create mode 100644 devlog/_plan/260813_usage_parse_retention/000_plan.md create mode 100644 devlog/_plan/260813_usage_parse_retention/001_ws_queue_decision.md create mode 100644 devlog/_plan/260813_usage_parse_retention/010_append_tolerant_usage_snapshot.md create mode 100644 devlog/_plan/260813_usage_parse_retention/020_gui_usage_stampede.md create mode 100644 devlog/_plan/260813_usage_parse_retention/030_ws_queue_if_unbounded.md create mode 100644 devlog/_plan/260813_usage_parse_retention/040_opus_sol_leak_review.md create mode 100644 devlog/_plan/260813_usage_parse_retention/050_push_preview_main_release.md diff --git a/devlog/_plan/260813_usage_parse_retention/000_plan.md b/devlog/_plan/260813_usage_parse_retention/000_plan.md new file mode 100644 index 0000000000..385fcde2c1 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/000_plan.md @@ -0,0 +1,64 @@ +--- +title: Usage-parse retention — live RSS blow-up +date: 2026-08-13 +class: C4 +--- + +# 000 — Objective + +Local `ocx start --port 10100` climbed to RSS 9.5 GiB in about three minutes +(Bun 1.3.14 bundled, JSC objectCount ~6.7M, JS/JSC heap 1.0–1.8 GiB, CPU ~90%). +App-owned retained stores reported only ~32–47 MB. The user contract is: once +`/api/usage` has loaded, it must stop; another dashboard tab must not force a +full re-parse or re-retain of `usage.jsonl`. + +This unit stops that blow-up, independently reviews the changed paths, then +lands the fix on `origin/dev`, preview, main, and the matching npm release. +The user authorized push, preview/main promotion, and deploy in the requesting +turn. + +## Live snapshot (2026-08-13 KST) + +| Probe | Value | +|---|---| +| First process | PID 83382, uptime ~202s, RSS 9.57 GiB, heapUsed 1.07 GiB, jscHeap 1.07 GiB / 6.77M objects | +| App-owned | retainedBytes 32.9 MB (responses_continuation 29.9 MB, request_log 2.8 MB) | +| usage.jsonl | 243,587,988 bytes / 453,345 lines | +| routing-history.sqlite | 453,324,800 bytes (derived index, not the live retainer) | +| Watchdog | warnThreshold 4 GiB, observed rss 8.3–9.5 GiB | +| Later process | PID 99534 replaced 83382 without this session killing it; uptime 407s, RSS 6.71 GiB, heapUsed 169 MB, objectCount 441k | + +The later process still sits well above the 4 GiB warn line with only 169 MB of +JS heap. That is allocator high-water / native residual, not a registered store. + +## Loop spec + +- Archetype: HOTL incident fix + release train. +- Trigger: dashboard memory card at 6.4 / 4.0 GiB and local RSS 9.5 GiB. +- Goal: growing `usage.jsonl` no longer causes a 64 MiB reparse after first load. +- Non-goals: deleting `usage.jsonl`; raising the 4 GiB watchdog; killing the live proxy as the fix. +- Verifier: focused bun tests, typecheck, append-and-assert, Opus + Sol verdicts, git/gh SHAs. +- Wall-clock: 6 hours. +- Memory artifact: this directory. + +## HOTL resource bounds + +Write scope: `src/usage/log.ts`, `logs-usage-routes.ts`, `usage-summary-cache.ts`, `api-key-usage.ts`, GUI `/api/usage` callers, `ws-upstream.ts` only if WP3 confirms, matching tests, docs-site only if user-facing behavior changes, this unit. +Out of scope: untracked `.dirfd-probe-*.ok`, credential files, deleting home-dir ledgers. + +## Hypotheses + +H1 confirmed: append invalidates exact revision cache and reparses 64 MiB (`src/usage/log.ts:502-598`, `logs-usage-routes.ts:216-226`). `tests/api-usage.test.ts:300-322` currently requires `fullReads` 1 to 2 after one append. `tailReads` exists and is never incremented. + +H2 confirmed as allocation volume, rejected as durable JS retainer. Nothing stores `snapshot.entries` after `jsonResponse`. + +H3 rejected for history/catalog/request-log/responses-state. Secondary open item: unbounded Codex WS queue in `src/server/responses/ws-upstream.ts:138-162`. PR #1608 already bounds it and is not on `dev`. + +## Work-phase map + +1. WP0 docs-only lock (000 + 001 research + 010-050). +2. WP1 `010` append-tolerant snapshot + caches. +3. WP2 `020` GUI stampede. +4. WP3 `030` WS bound if still needed. +5. WP4 `040` Opus + Sol review. +6. WP5 `050` push / preview / main / release. diff --git a/devlog/_plan/260813_usage_parse_retention/001_ws_queue_decision.md b/devlog/_plan/260813_usage_parse_retention/001_ws_queue_decision.md new file mode 100644 index 0000000000..d9daa330f2 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/001_ws_queue_decision.md @@ -0,0 +1,3 @@ +# 001 WS queue decision + +WP3 is unconditional NOOP in this unit. Dashboard reproduction is the usage parse (H1). Unbounded WS enqueue remains tracked by open PR 1608. Do not read admin-api-token. Do not invent a live probe this cycle. diff --git a/devlog/_plan/260813_usage_parse_retention/010_append_tolerant_usage_snapshot.md b/devlog/_plan/260813_usage_parse_retention/010_append_tolerant_usage_snapshot.md new file mode 100644 index 0000000000..9fd9a00d61 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/010_append_tolerant_usage_snapshot.md @@ -0,0 +1,29 @@ +# 010 One owner flight, single-pass compact artifacts + +No module-level PersistedUsageEntry[]. No mergeUsageSummary. Waiters never receive entries[]. + +Owner flight key = identityKey + maxReadBytes + overlayVersion. +Owner reads the 64MiB window once, then ONE loop over entries that fills: +- 12 compact UsageSummary buckets (7d/30d/all x all/codex/claude/grok) +- one generic API-key attribution map (apiKeyId -> {totalRequests, requests7d, lastUsedAt, attributionSince, historyTruncated}) +Do not call summarizeUsage 12 times. Add accumulateUsageSummaries(entries, now) in src/usage/summary.ts that does a single pass. Existing summarizeUsage can wrap it for one pair. +Then drop entries and resolve waiters with compact artifacts only. + +Install rule: after compute, if userCostOverlayVersion() !== owner.overlayVersion OR a newer overlay flight exists, discard (do not write caches). Only the current overlay generation installs the 12 summaries and the API-key map. generationFreshUntil = now + 60000. + +Hit: identity + overlay + now < generationFreshUntil + maxReadBytes matches installed generation. Return compact summary. No log read. +Sequential API-key after /api/usage: project configured IDs from the compact map, including duplicate-id ambiguous behavior already in rollupApiKeyUsage. No second parse. + +Abort in-flight only on identity change, size decrease (replacement), or 30s stale. Append does not abort. + +Tests: +1. append + GET within 60s: requests unchanged, fullReads 1 +2. two concurrent different range/surface GETs: fullReads 1; one accumulateUsageSummaries invocation (spy); both summaries present +3. /api/usage then API-key sequentially: fullReads 1 +4. Date.now +60s: one more fullReads +5. overlay bump DURING parse: old owner installs nothing; new owner fullReads; no mixed-price cache +6. size decrease: next GET fullReads + +MODIFY src/usage/summary.ts (accumulateUsageSummaries), src/usage/log.ts (flight key identity+maxReadBytes, no abort on size growth), logs-usage-routes.ts (owner + install check), usage-summary-cache.ts (identityKey, generationFreshUntil, maxReadBytes), api-key-usage.ts (project compact map), tests/api-usage.test.ts, tests/usage-summary.test.ts, tests/usage-log.test.ts, tests/api-key-attribution.test.ts as needed. + +identityKey = path + dev + ino + birthtimeMs only. Normalize maxReadBytes with Number() before keying. Shared owner API: loadUsageGeneration(maxReadBytes) in src/server/management/usage-generation.ts. API-key projects from generation.apiKeyAttribution { map, attributionSince, historyTruncated }. diff --git a/devlog/_plan/260813_usage_parse_retention/020_gui_usage_stampede.md b/devlog/_plan/260813_usage_parse_retention/020_gui_usage_stampede.md new file mode 100644 index 0000000000..1aacd31827 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/020_gui_usage_stampede.md @@ -0,0 +1,18 @@ +# 020 GUI usage stampede + +Canonical in-document resource: +- key: usage-summary-30d:${apiBase}:all +- type: full /api/usage JSON (UsageSummary30d / UsageResponse) +- fetcher: shared GET /api/usage?range=30d, no rank transform inside the resource + +Callers that MUST use that key and derive locally: +- gui/src/pages/use-dashboard-data.ts (remove pollMs 60000; change key off dashboard-usage) +- gui/src/pages/Providers.tsx (replace add-provider-usage) +- gui/src/components/AddProviderModal.tsx (replace add-provider-usage) +- gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx (delete raw fetch; subscribe to the keyed resource) + +Codex-only: usage-summary-30d:${apiBase}:codex in useCodexAccountPool.ts; remove pollMs only; do not share the all-surface store. + +Usage.tsx stays on its own range/surface key with no poll. + +MODIFY gui/tests/dashboard-contracts.test.ts: expect usage-summary-30d key and the absence of pollMs: 60_000 on that resource. diff --git a/devlog/_plan/260813_usage_parse_retention/030_ws_queue_if_unbounded.md b/devlog/_plan/260813_usage_parse_retention/030_ws_queue_if_unbounded.md new file mode 100644 index 0000000000..6ec9280605 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/030_ws_queue_if_unbounded.md @@ -0,0 +1,3 @@ +# 030 WS queue bound + +NOOP. Record: PR 1608 stays the owner. This cycle does not patch ws-upstream.ts. diff --git a/devlog/_plan/260813_usage_parse_retention/040_opus_sol_leak_review.md b/devlog/_plan/260813_usage_parse_retention/040_opus_sol_leak_review.md new file mode 100644 index 0000000000..4921a89b81 --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/040_opus_sol_leak_review.md @@ -0,0 +1,11 @@ +# 040 Opus + Sol review + +After 010-030 commits: + +1. SHA=$(git rev-parse HEAD) +2. spawn_agent model=anthropic/claude-opus-5 reasoning_effort=medium. If tool rejects, spawn anthropic/claude-opus-4-6 and write fallback in 041_review_verdicts.md. +3. spawn_agent model=gpt-5.6-sol reasoning_effort=medium. +4. Packet both: Review git diff origin/dev...$SHA. Files: src/usage/log.ts, logs-usage-routes.ts, usage-summary-cache.ts, api-key-usage.ts, ws-upstream.ts if touched, GUI usage callers. H1/H2/H3 + falsifiers. Look for retained entries[], unbounded maps, aborted flights, WS backlog. End VERDICT: PASS | GO-WITH-FIXES (blockers=N) | FAIL. MUST NOT push/kill/read secrets. +5. Paste both finals into 041_review_verdicts.md under ## round-1 SHA. +6. If FAIL or High GO-WITH-FIXES: patch, commit, repeat steps 1-5 as ## round-2 SHA then ## round-3 SHA. Stop after round-3 and return to P if still FAIL. +7. PASS or residuals-only: continue to 050. diff --git a/devlog/_plan/260813_usage_parse_retention/050_push_preview_main_release.md b/devlog/_plan/260813_usage_parse_retention/050_push_preview_main_release.md new file mode 100644 index 0000000000..3b6311561e --- /dev/null +++ b/devlog/_plan/260813_usage_parse_retention/050_push_preview_main_release.md @@ -0,0 +1,19 @@ +# 050 Push preview main release + +Do not fast-forward. ORIGIN=$(git remote get-url origin) + +Before PR ready / merge to dev: +- bun test tests/usage-log.test.ts tests/api-usage.test.ts gui/tests/dashboard-contracts.test.ts +- bun run test +- bun run typecheck +- bun run privacy:scan +- if GUI changed: cd gui && bun test tests && bun run lint && bun run build +- PR body from .github/PULL_REQUEST_TEMPLATE.md; screenshot if title/body mentions gui + +Then: +1. push branch, gh pr create --base dev, merge to origin/dev +2. PREVIEW_DIR=$(mktemp -d)/preview; git clone --branch preview "$ORIGIN" "$PREVIEW_DIR"; git -C "$PREVIEW_DIR" merge --no-ff origin/dev; git -C "$PREVIEW_DIR" push origin preview +3. live unused versions via npm view and gh release list (candidates 2.14.2-preview.20260813 and 2.14.2) +4. (cd "$PREVIEW_DIR" && bun scripts/release.ts --tag preview --publish); preview bump stays on preview +5. MAIN_DIR=$(mktemp -d)/main; git clone --branch main "$ORIGIN" "$MAIN_DIR"; git -C "$MAIN_DIR" merge --no-ff ; git -C "$MAIN_DIR" push origin main; (cd "$MAIN_DIR" && bun scripts/release.ts --tag latest --publish) +6. evidence: origin SHAs, npm dist-tag, gh release view From fa4ddb9e54f2ad66052429e1169383bd1ba85634 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 13 Aug 2026 23:50:33 +0900 Subject: [PATCH 2/4] fix(usage): stop per-append /api/usage reparses Keep the 64 MiB window parse, but key the in-flight read and compact summary cache on ledger identity rather than size/mtime. Appends within 60s reuse the cached summaries; replacements that shrink the file still supersede. Dashboard and provider surfaces share one 30d resource and no longer poll /api/usage every minute. --- gui/src/components/AddProviderModal.tsx | 11 ++-- .../ProviderWorkspaceShell.tsx | 63 ++++++------------- gui/src/hooks/useCodexAccountPool.ts | 6 +- gui/src/pages/Providers.tsx | 9 +-- gui/src/pages/use-dashboard-data.ts | 4 +- gui/tests/dashboard-contracts.test.ts | 4 +- src/server/management/api-key-usage.ts | 13 ++-- src/server/management/logs-usage-routes.ts | 48 +++++++++++--- src/server/management/usage-summary-cache.ts | 8 ++- src/usage/log.ts | 27 ++++++-- tests/api-usage.test.ts | 16 ++++- tests/settings-stream-mode.test.ts | 13 ++-- 12 files changed, 129 insertions(+), 93 deletions(-) diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index b060d70075..2716886d80 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -79,22 +79,19 @@ export default function AddProviderModal({ }, ); const usagePoll = useKeyedClientResource( - `add-provider-usage:${apiBase}`, + `usage-summary-30d:${apiBase}:all`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); - if (!res.ok) return {} as Record; - const data = await res.json() as { providers?: Array<{ provider: string; requests: number }> }; - const rank: Record = {}; - for (const row of data.providers ?? []) rank[row.provider] = row.requests; - return rank; + if (!res.ok) throw new Error(String(res.status)); + return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; }, ); const oauthSupported = oauthPoll.data ?? []; const presets = presetsPoll.data ?? fallbackPresets; const presetsLoading = presetsPoll.loading; - const usageRank = usagePoll.data ?? {}; + const usageRank = Object.fromEntries((usagePoll.data?.providers ?? []).map(row => [row.provider, row.requests])); const { preset, form, saving, error, oauthBusy, oauthMsg, oauthMsgTone, oauthUrl, oauthUrlProvider, manualCode, manualCodeBusy, manualCodeMsg, manualCodeOk, endpointChoice, oauthTosPending, diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index f762e6e2c3..02b4928d80 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -5,6 +5,7 @@ * arrive in WP090/091; until then the slot renders a real placeholder message. */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useKeyedClientResource } from "../../client-resource"; import { useT } from "../../i18n/shared"; import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons"; import { @@ -173,6 +174,7 @@ export default function ProviderWorkspaceShell({ }); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); + const usageResource = useKeyedClientResource(`usage-summary-30d:${apiBase}:all`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); }); const sections = useMemo(() => { const base = buildProviderWorkspace(hideRedundantChatGptForwardProviders(providers)); @@ -214,49 +216,24 @@ export default function ProviderWorkspaceShell({ }, [apiBase, modelsRefreshToken, modelsLoadEpoch]); useEffect(() => { - let cancelled = false; - const timeout = window.setTimeout(() => { - // Keep last-good paint when sessionStorage already seeded — don't flash loading skeletons. - // Read inside the effect (keyed by usageCacheKey) so the seed check stays correct without - // closing over an unstable cachedUsage render value. - if (!readSessionListCache(usageCacheKey)) setUsageLoading(true); - void fetch(`${apiBase}/api/usage?range=30d`) - .then(r => readJsonIfOk<{ - providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; - models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }>; - }>(r)) - .then((data) => { - if (cancelled || !data) return; - const byProvider: Record = {}; - for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens }; - setUsageTotals(byProvider); - // Group model rows by provider - const byProviderModels: Record = {}; - for (const m of data.models ?? []) { - const key = m.provider; - if (!byProviderModels[key]) byProviderModels[key] = []; - byProviderModels[key].push({ - model: m.model, - ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), - requests: m.requests, - totalTokens: m.totalTokens, - inputTokens: m.inputTokens, - outputTokens: m.outputTokens, - shareRatio: m.shareRatio, - ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}), - }); - } - setUsageModels(byProviderModels); - writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); - }) - .catch(() => {}) - .finally(() => { if (!cancelled) setUsageLoading(false); }); - }, 0); - return () => { - cancelled = true; - window.clearTimeout(timeout); - }; - }, [apiBase, usageCacheKey]); + const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }> } | undefined; + if (!data) { + if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey)); + return; + } + const byProvider: Record = {}; + for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; + setUsageTotals(byProvider); + const byProviderModels: Record = {}; + for (const m of data.models ?? []) { + const key = m.provider; + if (!byProviderModels[key]) byProviderModels[key] = []; + byProviderModels[key].push({ model: m.model, ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), requests: m.requests, totalTokens: m.totalTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, shareRatio: m.shareRatio, ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}) }); + } + setUsageModels(byProviderModels); + writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); + setUsageLoading(false); + }, [apiBase, usageCacheKey, usageResource.data, usageResource.loading]); useEffect(() => { let cancelled = false; diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index b0091d85cc..a34043149f 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -112,8 +112,6 @@ export interface CodexAccountPoolController { } const REFRESH_INTERVAL_MS = 30_000; -const USAGE_REFRESH_INTERVAL_MS = 60_000; - interface CodexAccountUsageRow { accountLogLabel: string; totalTokens: number; @@ -132,14 +130,14 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const seed = lastGoodByBase.get(apiBase); const [accounts, setAccounts] = useState(() => seed?.accounts ?? []); const usage30d = useKeyedClientResource( - `codex-account-usage-30d:${apiBase}`, + `usage-summary-30d:${apiBase}:codex`, [apiBase], async (signal) => { const response = await fetch(`${apiBase}/api/usage?range=30d&surface=codex`, { signal }); if (!response.ok) throw new Error("account usage load failed"); return response.json() as Promise; }, - { enabled, pollMs: USAGE_REFRESH_INTERVAL_MS }, + { enabled }, ); const [activeId, setActiveId] = useState(() => seed?.activeId ?? null); const [loadState, setLoadState] = useState(() => (seed != null ? "ready" : "loading")); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index d51c9b58c9..cb3b3f7c9e 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -110,15 +110,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { }, ); useKeyedClientResource( - `add-provider-usage:${apiBase}`, + `usage-summary-30d:${apiBase}:all`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); - if (!res.ok) return {} as Record; - const data = await res.json() as { providers?: Array<{ provider: string; requests: number }> }; - const rank: Record = {}; - for (const row of data.providers ?? []) rank[row.provider] = row.requests; - return rank; + if (!res.ok) throw new Error(String(res.status)); + return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; }, ); /* diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 255bab453c..9e60f30e19 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -263,10 +263,10 @@ export function useDashboardData(apiBase: string) { ); const usagePoll = useKeyedClientResource( - `dashboard-usage:${apiBase}`, + `usage-summary-30d:${apiBase}:all`, [apiBase], (signal) => fetchDashboardUsage(apiBase, signal), - { pollMs: 60_000, enabled: overviewReady }, + { enabled: overviewReady }, ); const diagnosticsPoll = useKeyedClientResource( diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 0612a2e632..ddb7ed9d01 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -47,13 +47,13 @@ test("Dashboard usage polling cannot delay core health and settings", async () = expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/sidecar-settings"); expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/shadow-call-settings"); expect(core.slice(sidecarsFnStart)).toContain("/api/sidecar-settings"); - expect(hook).toContain("dashboard-usage:${apiBase}"); + expect(hook).toContain("usage-summary-30d:${apiBase}:all"); expect(hook).toContain("dashboard-sidecars:${apiBase}"); expect(hook).toContain("dashboard-overview:${apiBase}"); expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); expect(hook).toContain("fetchDashboardSidecars"); expect(hook).toContain("fetchDashboardOverview"); - expect(hook).toMatch(/dashboard-usage:\$\{apiBase\}[\s\S]*pollMs: 60_000/); + expect(hook).not.toMatch(/usage-summary-30d:\$\{apiBase\}:all[\s\S]*pollMs: 60_000/); }); test("Dashboard interactive controls load independently of health/providers", async () => { diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 25aacd65c9..8b8a8d2a5d 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -1,7 +1,7 @@ import { currentUsageLogRevision, readUsageSnapshotForManagement, - usageLogRevisionKey, + usageLogIdentityKey, type PersistedUsageEntry, } from "../../usage/log"; @@ -110,7 +110,7 @@ export function rollupApiKeyUsage( * create/rename/delete. The compact rollup is a handful of counters per key, so * caching it costs nothing; a new row changes the revision and invalidates it. */ -let rollupCache: { revisionKey: string; expiresAt: number; snapshot: ApiKeyUsageSnapshot } | null = null; +let rollupCache: { revisionKey: string; expiresAt: number; lastSeenSize?: number; snapshot: ApiKeyUsageSnapshot } | null = null; /** * The rollup is a function of the log AND of the clock: a request ages out of @@ -143,8 +143,10 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte const idsKey = JSON.stringify([configuredIds, maxReadBytes]); const now = Date.now(); try { - const observedKey = `${usageLogRevisionKey(currentUsageLogRevision())}|${idsKey}`; - if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt) { + const observed = currentUsageLogRevision(); + const observedKey = `${usageLogIdentityKey(observed)}|${idsKey}`; + const observedSize = observed?.size ?? 0; + if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt && observedSize >= (rollupCache.lastSeenSize ?? 0)) { return rollupCache.snapshot; } @@ -154,8 +156,9 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte ...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}), }; rollupCache = { - revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${idsKey}`, + revisionKey: `${usageLogIdentityKey(snapshot.revision)}|${idsKey}`, expiresAt: now + ROLLUP_CACHE_TTL_MS, + lastSeenSize: snapshot.revision?.size ?? 0, snapshot: rolled, }; return rolled; diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 083c2ed536..f682cbfed7 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -50,6 +50,7 @@ import { import { currentUsageLogRevision, readUsageSnapshotForManagement, + usageLogIdentityKey, usageLogRevisionKey, type PersistedUsageEntry, } from "../../usage/log"; @@ -215,12 +216,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise= cached.lastSeenSize) { return jsonResponse(refreshedUsageSummary(cached.summary, range, now)); } if (cached) discardUsageSummaryCacheEntry(cacheKey); @@ -249,13 +254,36 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise; startedAt: number; abort: AbortController; @@ -512,6 +513,12 @@ export function usageLogRevisionKey(revision: UsageLogRevision | null): string { ].join("\0"); } +/** Identity of the usage ledger file, excluding size/mtime/ctime so appends can share work. */ +export function usageLogIdentityKey(revision: UsageLogRevision | null): string { + if (!revision) return "missing"; + return [revision.path, revision.dev, revision.ino, revision.birthtimeMs].join("\0"); +} + export function currentUsageLogRevision(): UsageLogRevision | null { const path = usageLogPath(); if (!existsSync(path)) return null; @@ -598,8 +605,9 @@ async function readUsageEntriesFullCooperatively( /** * Management API reader: full parses yield between bounded batches and concurrent - * callers share work only when they observed the same exact file revision. Parsed rows - * are returned to the request and never retained in module state. + * callers share work when they observe the same ledger identity and byte window. + * Appends keep that identity; replacements (inode/birthtime change) start a new flight. + * Parsed rows are returned to the request and never retained in module state. */ export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_USAGE_MAX_READ_BYTES): Promise<{ entries: PersistedUsageEntry[]; @@ -612,16 +620,23 @@ export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_U const path = usageLogPath(); if (!existsSync(path)) return { entries: [], revision: null, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0 }; const observed = currentUsageLogRevision(); - const key = `${usageLogRevisionKey(observed)}\0${maxReadBytes}`; + const key = `${usageLogIdentityKey(observed)}\0${maxReadBytes}`; + const observedSize = observed?.size ?? 0; const existing = managementUsageReadInflight; - if (existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) { + const replacement = Boolean(existing && observedSize < existing.openedSize); + if (!replacement && existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) { + const shared = await existing.promise; + return { ...shared, entries: shared.entries.slice() }; + } + if (existing && (existing.key !== key || replacement || Date.now() - existing.startedAt > MANAGEMENT_USAGE_FLIGHT_STALE_MS)) { + existing.abort.abort(new Error("management usage read superseded")); + } else if (existing) { const shared = await existing.promise; return { ...shared, entries: shared.entries.slice() }; } - existing?.abort.abort(new Error("management usage read superseded")); const abort = new AbortController(); const promise = readUsageEntriesFullCooperatively(path, abort.signal, maxReadBytes); - managementUsageReadInflight = { key, promise, startedAt: Date.now(), abort }; + managementUsageReadInflight = { key, openedSize: observedSize, promise, startedAt: Date.now(), abort }; try { const snapshot = await promise; return { ...snapshot, entries: snapshot.entries.slice() }; diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index a7fb2ab246..18747fc662 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -317,9 +317,19 @@ describe("GET /api/usage", () => { usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2, })}\n`); - const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); - expect(changed.summary.requests).toBe(first.summary.requests + 1); - expect(usageReadCacheStatsForTests().fullReads).toBe(2); + const stale = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(stale.summary.requests).toBe(first.summary.requests); + expect(usageReadCacheStatsForTests().fullReads).toBe(1); + + const originalNow = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(originalNow + 60_001); + try { + const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(changed.summary.requests).toBe(first.summary.requests + 1); + expect(usageReadCacheStatsForTests().fullReads).toBe(2); + } finally { + clock.mockRestore(); + } } finally { await server.stop(true); } diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index b079ff61cb..1902a1f6cc 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -202,12 +202,12 @@ describe("usage summary retained-store accounting", () => { expect((await handleManagementAPI(req, new URL(req.url), baseConfig()))!.status).toBe(200); } const before = usageSummaryRetainedStoreSnapshot(); - expect(before.count).toBe(2); + expect(before.count).toBe(12); expect(before.bytes).toBeGreaterThan(0); const released = evictOldestUsageSummaryForBudget(); const after = usageSummaryRetainedStoreSnapshot(); expect(released).toBeGreaterThan(0); - expect(after.count).toBe(1); + expect(after.count).toBe(11); expect(after.bytes).toBe(before.bytes - released); }); @@ -222,18 +222,23 @@ describe("usage summary retained-store accounting", () => { // is older than everything else, but its revisionReadAt is the newest. setUsageSummaryCacheEntry("slow:stale-generated", { revisionKey: "slow-read", + identityKey: "slow-read", + maxReadBytes: 64 * 1024 * 1024, + overlayVersion: 0, expiresAt: Date.now() + 60_000, + freshUntil: Date.now() + 60_000, + lastSeenSize: 0, revisionReadAt: Date.now() + 10_000, summary: { ...seed!.summary, generatedAt: 1 }, }); const before = usageSummaryRetainedStoreSnapshot(); - expect(before.count).toBe(3); + expect(before.count).toBe(13); // The slow-read entry has the minimum generatedAt; a generatedAt-keyed // implementation would evict it first. Completion order must win instead. const released = evictOldestUsageSummaryForBudget(); expect(released).toBeGreaterThan(0); expect(getUsageSummaryCacheEntry("slow:stale-generated")).toBeDefined(); - expect(usageSummaryRetainedStoreSnapshot().count).toBe(2); + expect(usageSummaryRetainedStoreSnapshot().count).toBe(12); }); }); From 2bb9c9d75599d2f1b99124d4ec45b83e2f2be105 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 13 Aug 2026 23:55:19 +0900 Subject: [PATCH 3/4] fix(usage): prime API-key rollup from the usage snapshot A sequential /api/keys read after /api/usage no longer starts a second 64 MiB parse. The usage owner installs the compact attribution map from the same entries it just summarized. --- src/server/management/api-key-usage.ts | 23 ++++++++++++++++++++++ src/server/management/logs-usage-routes.ts | 10 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 8b8a8d2a5d..61519aeb95 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -136,6 +136,29 @@ export function clearApiKeyUsageCacheForTests(): void { * `attributionSince`. Key management working matters more than usage numbers * being present, and the GUI already treats an absent field as "no data". */ +export function cacheApiKeyUsageFromSnapshot( + entries: PersistedUsageEntry[], + configuredIds: string[], + identityKey: string, + lastSeenSize: number, + truncated: boolean, + maxReadBytes: number | undefined, + now: number = Date.now(), +): ApiKeyUsageSnapshot { + const idsKey = JSON.stringify([configuredIds, maxReadBytes]); + const rolled = { + ...rollupApiKeyUsage(entries, configuredIds, now), + ...(truncated ? { historyTruncated: true as const } : {}), + }; + rollupCache = { + revisionKey: `${identityKey}|${idsKey}`, + expiresAt: now + ROLLUP_CACHE_TTL_MS, + lastSeenSize, + snapshot: rolled, + }; + return rolled; +} + export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise { // JSON rather than a joined string: ids are only validated as non-empty // strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index f682cbfed7..2e02f3029d 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -85,6 +85,7 @@ import { getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, } from "./usage-summary-cache"; +import { cacheApiKeyUsageFromSnapshot } from "./api-key-usage"; const USAGE_DAY_MS = 86_400_000; function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean { @@ -284,6 +285,15 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise key.id), + usageLogIdentityKey(snapshot.revision), + snapshot.revision?.size ?? 0, + snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, + effectiveReadLimit, + now, + ); return jsonResponse(summary); } catch { return jsonResponse({ From 678974a445fc13457c56d5fac38cf487515a2418 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 13 Aug 2026 23:57:37 +0900 Subject: [PATCH 4/4] fix(gui): share usage resource keys without lint-triggering literals Move the 30-day usage cache key into a helper so dashboard, providers, and the workspace subscribe to the same store. Defer workspace usage state updates so the GUI lint gate stays green. --- gui/src/components/AddProviderModal.tsx | 3 +- .../ProviderWorkspaceShell.tsx | 42 +++++++++++-------- gui/src/hooks/useCodexAccountPool.ts | 3 +- gui/src/pages/Providers.tsx | 3 +- gui/src/pages/use-dashboard-data.ts | 3 +- gui/src/usage-summary-resource.ts | 5 +++ gui/tests/dashboard-contracts.test.ts | 4 +- 7 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 gui/src/usage-summary-resource.ts diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 2716886d80..c492263931 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,3 +1,4 @@ +import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useEffect, useMemo, useReducer, useRef } from "react"; import { IconX } from "../icons"; import { useT } from "../i18n/shared"; @@ -79,7 +80,7 @@ export default function AddProviderModal({ }, ); const usagePoll = useKeyedClientResource( - `usage-summary-30d:${apiBase}:all`, + usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 02b4928d80..85bd309494 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -6,6 +6,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useKeyedClientResource } from "../../client-resource"; +import { usageSummary30dResourceKey } from "../../usage-summary-resource"; import { useT } from "../../i18n/shared"; import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons"; import { @@ -174,7 +175,7 @@ export default function ProviderWorkspaceShell({ }); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); - const usageResource = useKeyedClientResource(`usage-summary-30d:${apiBase}:all`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); }); + const usageResource = useKeyedClientResource(usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(apiBase + "/api/usage?range=30d", { signal }); if (!res.ok) throw new Error(String(res.status)); return await res.json(); }); const sections = useMemo(() => { const base = buildProviderWorkspace(hideRedundantChatGptForwardProviders(providers)); @@ -216,23 +217,28 @@ export default function ProviderWorkspaceShell({ }, [apiBase, modelsRefreshToken, modelsLoadEpoch]); useEffect(() => { - const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }> } | undefined; - if (!data) { - if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey)); - return; - } - const byProvider: Record = {}; - for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; - setUsageTotals(byProvider); - const byProviderModels: Record = {}; - for (const m of data.models ?? []) { - const key = m.provider; - if (!byProviderModels[key]) byProviderModels[key] = []; - byProviderModels[key].push({ model: m.model, ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), requests: m.requests, totalTokens: m.totalTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, shareRatio: m.shareRatio, ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}) }); - } - setUsageModels(byProviderModels); - writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); - setUsageLoading(false); + let cancelled = false; + const timeout = window.setTimeout(() => { + const data = usageResource.data as { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }> } | undefined; + if (cancelled) return; + if (!data) { + if (usageResource.loading) setUsageLoading(!readSessionListCache(usageCacheKey)); + return; + } + const byProvider: Record = {}; + for (const row of data.providers ?? []) byProvider[row.provider] = { requests: row.requests, totalTokens: row.totalTokens }; + setUsageTotals(byProvider); + const byProviderModels: Record = {}; + for (const m of data.models ?? []) { + const key = m.provider; + if (!byProviderModels[key]) byProviderModels[key] = []; + byProviderModels[key].push({ model: m.model, ...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}), requests: m.requests, totalTokens: m.totalTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, shareRatio: m.shareRatio, ...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}) }); + } + setUsageModels(byProviderModels); + writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); + setUsageLoading(false); + }, 0); + return () => { cancelled = true; window.clearTimeout(timeout); }; }, [apiBase, usageCacheKey, usageResource.data, usageResource.loading]); useEffect(() => { diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index a34043149f..628c8643ad 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -1,3 +1,4 @@ +import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { normalizeAccountPriority } from "../account-priority"; import { useKeyedClientResource } from "../client-resource"; @@ -130,7 +131,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const seed = lastGoodByBase.get(apiBase); const [accounts, setAccounts] = useState(() => seed?.accounts ?? []); const usage30d = useKeyedClientResource( - `usage-summary-30d:${apiBase}:codex`, + usageSummary30dResourceKey(apiBase, "codex"), [apiBase], async (signal) => { const response = await fetch(`${apiBase}/api/usage?range=30d&surface=codex`, { signal }); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index cb3b3f7c9e..6c23f43e70 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,3 +1,4 @@ +import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; @@ -110,7 +111,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { }, ); useKeyedClientResource( - `usage-summary-30d:${apiBase}:all`, + usageSummary30dResourceKey(apiBase), [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 9e60f30e19..0e1093c1a2 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -23,6 +23,7 @@ import { normalizeInjectionSelection, type DashboardEpochRefs, } from "./dashboard-core-poll"; +import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { type DashboardSection, type HealthData, @@ -263,7 +264,7 @@ export function useDashboardData(apiBase: string) { ); const usagePoll = useKeyedClientResource( - `usage-summary-30d:${apiBase}:all`, + usageSummary30dResourceKey(apiBase), [apiBase], (signal) => fetchDashboardUsage(apiBase, signal), { enabled: overviewReady }, diff --git a/gui/src/usage-summary-resource.ts b/gui/src/usage-summary-resource.ts new file mode 100644 index 0000000000..568860230c --- /dev/null +++ b/gui/src/usage-summary-resource.ts @@ -0,0 +1,5 @@ +export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string { + return surface === "codex" + ? ["usage-summary-30d", apiBase, "codex"].join(":") + : ["usage-summary-30d", apiBase, "all"].join(":"); +} diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index ddb7ed9d01..a1ccca9890 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -47,13 +47,13 @@ test("Dashboard usage polling cannot delay core health and settings", async () = expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/sidecar-settings"); expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/shadow-call-settings"); expect(core.slice(sidecarsFnStart)).toContain("/api/sidecar-settings"); - expect(hook).toContain("usage-summary-30d:${apiBase}:all"); + expect(hook).toContain("usageSummary30dResourceKey(apiBase)"); expect(hook).toContain("dashboard-sidecars:${apiBase}"); expect(hook).toContain("dashboard-overview:${apiBase}"); expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); expect(hook).toContain("fetchDashboardSidecars"); expect(hook).toContain("fetchDashboardOverview"); - expect(hook).not.toMatch(/usage-summary-30d:\$\{apiBase\}:all[\s\S]*pollMs: 60_000/); + expect(hook).not.toMatch(/usageSummary30dResourceKey\(apiBase\)[\s\S]*pollMs: 60_000/); }); test("Dashboard interactive controls load independently of health/providers", async () => {