From 3e19534ef3e4dcf913c54e567501cb30efd6e7c1 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sun, 26 Jul 2026 12:54:54 +0000 Subject: [PATCH 1/7] fix(usage): keep large dashboard logs responsive --- gui/src/pages/dashboard-core-poll.ts | 22 +- gui/src/pages/use-dashboard-data.ts | 13 +- gui/tests/dashboard-contracts.test.ts | 12 + src/server/management/logs-usage-routes.ts | 4 +- src/usage/log.ts | 248 ++++++++++++++++++++- structure/05_gui-and-management-api.md | 17 ++ tests/usage-log.test.ts | 59 ++++- 7 files changed, 346 insertions(+), 29 deletions(-) diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index 050d05c4a32..fc127e33541 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -38,7 +38,6 @@ export type DashboardCorePoll = { startupHealthSeed: SettingsData["startupHealth"] | null | undefined; sidecar: SidecarData | null; shadowCall: ShadowCallData | null | undefined; - usage30d: UsageSummary30d | null; maMode: "v1" | "default" | "v2"; maModeResolved: boolean; /** Absent when the optional endpoint failed — callers must keep prior UI state. */ @@ -89,6 +88,14 @@ export async function fetchDashboardModels(apiBase: string, signal: AbortSignal) return requireJson(response); } +export async function fetchDashboardUsage(apiBase: string, signal: AbortSignal): Promise { + const response = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); + // Usage can be expensive on an older server. Keeping it in its own resource means + // it cannot delay health/provider/settings commits, and a failed refresh retains + // the last good usage snapshot. + return requireJson(response); +} + export async function fetchDashboardCore( apiBase: string, signal: AbortSignal, @@ -112,7 +119,6 @@ export async function fetchDashboardCore( startupHealthSeed: undefined, sidecar: null, shadowCall: undefined, - usage30d: null, maMode: "default", maModeResolved: true, injection: undefined, @@ -121,13 +127,12 @@ export async function fetchDashboardCore( }; try { - const [hRes, pRes, sRes, scRes, shRes, uRes] = await Promise.all([ + const [hRes, pRes, sRes, scRes, shRes] = await Promise.all([ fetch(`${apiBase}/healthz`, { signal }), fetch(`${apiBase}/api/providers`, { signal }), fetch(`${apiBase}/api/settings`, { signal }), fetch(`${apiBase}/api/sidecar-settings`, { signal }), fetch(`${apiBase}/api/shadow-call-settings`, { signal }), - fetch(`${apiBase}/api/usage?range=30d`, { signal }), ]); const health = await requireJson(hRes); @@ -176,14 +181,6 @@ export async function fetchDashboardCore( } } - // Usage is best-effort: a malformed/empty body must not mark the whole dashboard offline. - let usage30d: UsageSummary30d | null = null; - try { - usage30d = (await readJsonIfOk(uRes)) ?? null; - } catch { - usage30d = null; - } - let maMode: "v1" | "default" | "v2" = "default"; let maModeResolved = false; try { @@ -236,7 +233,6 @@ export async function fetchDashboardCore( startupHealthSeed, sidecar, shadowCall, - usage30d, maMode, maModeResolved, injection, diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 5736e5f9176..8f49c599eb7 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -8,6 +8,7 @@ import { } from "../startup-health-ui"; import { fetchDashboardCore, + fetchDashboardUsage, fetchDashboardModels, fetchProjectConfigDiagnostics, fetchStartupHealth, @@ -147,6 +148,13 @@ export function useDashboardData(apiBase: string) { { pollMs: 5000 }, ); + const usagePoll = useKeyedClientResource( + `dashboard-usage:${apiBase}`, + [apiBase], + (signal) => fetchDashboardUsage(apiBase, signal), + { pollMs: 5000 }, + ); + const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, [apiBase], @@ -188,7 +196,6 @@ export function useDashboardData(apiBase: string) { } if (data.sidecar) setSidecar(data.sidecar); if (data.shadowCall !== undefined) setShadowCall(data.shadowCall); - setUsage30d(data.usage30d); setMaMode(data.maMode); setMaModeResolved(data.maModeResolved); if (data.injection) { @@ -205,6 +212,10 @@ export function useDashboardData(apiBase: string) { setError(data.error); }, [corePoll.data]); + useEffect(() => { + if (usagePoll.data !== undefined) setUsage30d(usagePoll.data); + }, [usagePoll.data]); + useEffect(() => { if (diagnosticsPoll.data) setProjectConfigWarnings(diagnosticsPoll.data); }, [diagnosticsPoll.data]); diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 101703ac23a..8a643e64189 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -19,6 +19,18 @@ test("Dashboard wires a single project-config diagnostics owner outside the sett expect(coreBody).not.toContain("diagnostics/project-config"); }); +test("Dashboard usage polling cannot delay core health and settings", async () => { + const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text(); + const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text(); + const coreFnStart = core.indexOf("export async function fetchDashboardCore"); + const usageFnStart = core.indexOf("export async function fetchDashboardUsage"); + expect(coreFnStart).toBeGreaterThan(-1); + expect(usageFnStart).toBeGreaterThan(-1); + expect(core.slice(coreFnStart)).not.toContain("/api/usage?range=30d"); + expect(hook).toContain("dashboard-usage:${apiBase}"); + expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); +}); + test("Dashboard workspace pane is a labelled section, not a nested main landmark", async () => { const src = await Bun.file(new URL("../src/pages/Dashboard.tsx", import.meta.url)).text(); expect(src).toContain("dashboard-workspace-main"); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 3cf8d586f66..45226301a25 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -34,7 +34,7 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; -import { readUsageEntries } from "../../usage/log"; +import { readUsageEntriesForManagement } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; @@ -123,7 +123,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise } | null = null; + +/** Test-only observability for proving that unchanged prefixes are not reparsed. */ +export function usageReadCacheStatsForTests(): Readonly { + return { ...usageReadCacheStats }; +} + +export function resetUsageReadCacheForTests(): void { + usageReadCache = null; + usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 }; + managementUsageReadInflight = null; +} + +function parseUsageText(text: string): PersistedUsageEntry[] { + const lines = text.split(/\r?\n/); + usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length; + return parseUsageLines(lines); +} + +function sameUsageFile(cache: UsageReadCache, stat: ReturnType): boolean { + const dev = Number(stat.dev); + const ino = Number(stat.ino); + if (cache.dev !== dev) return false; + // Node exposes a stable inode on the supported Windows and Unix runtimes. Keep a + // birth-time fallback for filesystems that report zero for every inode. + if (cache.ino !== 0 || ino !== 0) return cache.ino === ino; + return cache.birthtimeMs === Number(stat.birthtimeMs); +} + +function readExactly(fd: number, length: number, position: number): Buffer | null { + const output = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const read = readSync(fd, output, offset, length - offset, position + offset); + if (read === 0) return null; + offset += read; + } + return output; +} + +function usageTailSignature(bytes: Buffer): Buffer { + return bytes.subarray(Math.max(0, bytes.length - USAGE_CACHE_SIGNATURE_BYTES)).subarray(0); +} + +function readUsageEntriesFull(path: string, fd: number, stat: ReturnType): PersistedUsageEntry[] { + const size = Number(stat.size); + const bytes = readExactly(fd, size, 0); + if (bytes === null) { + usageReadCache = null; + return []; + } + const entries = parseUsageText(bytes.toString("utf-8")); + usageReadCacheStats.fullReads += 1; + usageReadCache = { + path, + dev: Number(stat.dev), + ino: Number(stat.ino), + birthtimeMs: Number(stat.birthtimeMs), + size, + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + endedWithNewline: bytes.length === 0 || bytes[bytes.length - 1] === 0x0a, + tailSignature: usageTailSignature(bytes), + entries, + }; + return entries.slice(); +} + +async function parseUsageTextCooperatively(text: string): Promise { + const lines = text.split(/\r?\n/); + usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length; + const entries: PersistedUsageEntry[] = []; + const batchSize = 1_000; + for (let offset = 0; offset < lines.length; offset += batchSize) { + entries.push(...parseUsageLines(lines.slice(offset, offset + batchSize))); + if (offset + batchSize < lines.length) { + // JSON parsing dominates large-log startup. Yield between bounded batches so + // Bun can continue serving health and settings requests on the same thread. + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + return entries; +} + +async function readUsageEntriesFullCooperatively(path: string): Promise { + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const stat = fstatSync(fd); + const size = Number(stat.size); + const bytes = readExactly(fd, size, 0); + if (bytes === null) return []; + const entries = await parseUsageTextCooperatively(bytes.toString("utf-8")); + usageReadCacheStats.fullReads += 1; + // A different OPENCODEX_HOME may have become active while the cooperative parse + // yielded. Return this snapshot to its caller without poisoning the new path cache. + if (usageLogPath() === path) { + usageReadCache = { + path, + dev: Number(stat.dev), + ino: Number(stat.ino), + birthtimeMs: Number(stat.birthtimeMs), + size, + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + endedWithNewline: bytes.length === 0 || bytes[bytes.length - 1] === 0x0a, + tailSignature: usageTailSignature(bytes), + entries, + }; + } + return entries.slice(); + } catch { + if (usageReadCache?.path === path) usageReadCache = null; + return []; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +function usageCacheNeedsFullRead(path: string): boolean { + const cache = usageReadCache; + if (!cache || cache.path !== path) return true; + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const stat = fstatSync(fd); + const size = Number(stat.size); + if (!sameUsageFile(cache, stat) || size < cache.size) return true; + if (size === cache.size) { + return Number(stat.mtimeMs) !== cache.mtimeMs || Number(stat.ctimeMs) !== cache.ctimeMs; + } + if (!cache.endedWithNewline) return true; + const signatureStart = Math.max(0, cache.size - cache.tailSignature.length); + const signature = readExactly(fd, cache.tailSignature.length, signatureStart); + return signature === null || !signature.equals(cache.tailSignature); + } catch { + return true; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +/** + * Management API reader: full rebuilds yield between parse batches, while the steady + * append-only path stays synchronous and only processes the small new tail. + */ +export async function readUsageEntriesForManagement(): Promise { const path = usageLogPath(); if (!existsSync(path)) return []; - const lines = readFileSync(path, "utf-8").split(/\r?\n/); - const entries: PersistedUsageEntry[] = []; - for (const line of lines) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line) as PersistedUsageEntry; - if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") { - entries.push(normalizeUsageEntry(parsed)); + if (!usageCacheNeedsFullRead(path)) return readUsageEntries(); + if (managementUsageReadInflight?.path === path) { + return (await managementUsageReadInflight.promise).slice(); + } + const promise = readUsageEntriesFullCooperatively(path); + managementUsageReadInflight = { path, promise }; + try { + return await promise; + } finally { + if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null; + } +} + +export function readUsageEntries(): PersistedUsageEntry[] { + const path = usageLogPath(); + if (!existsSync(path)) { + if (usageReadCache?.path === path) usageReadCache = null; + return []; + } + + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const stat = fstatSync(fd); + const size = Number(stat.size); + const mtimeMs = Number(stat.mtimeMs); + const ctimeMs = Number(stat.ctimeMs); + const cache = usageReadCache; + if (!cache || cache.path !== path || !sameUsageFile(cache, stat) || size < cache.size) { + return readUsageEntriesFull(path, fd, stat); + } + + if (size === cache.size) { + if (mtimeMs !== cache.mtimeMs || ctimeMs !== cache.ctimeMs) { + return readUsageEntriesFull(path, fd, stat); } - } catch { - /* keep reading after a partially written or hand-edited line */ + return cache.entries.slice(); } + + // A writer may truncate and rewrite the same inode between polls. Verify the old + // suffix before trusting a larger size as an append; this costs at most 64 bytes. + const signatureStart = Math.max(0, cache.size - cache.tailSignature.length); + const currentSignature = readExactly(fd, cache.tailSignature.length, signatureStart); + if ( + !cache.endedWithNewline + || currentSignature === null + || !currentSignature.equals(cache.tailSignature) + ) { + return readUsageEntriesFull(path, fd, stat); + } + + const appended = readExactly(fd, size - cache.size, cache.size); + if (appended === null) return readUsageEntriesFull(path, fd, stat); + const appendedEntries = parseUsageText(appended.toString("utf-8")); + usageReadCacheStats.tailReads += 1; + const entries = [...cache.entries, ...appendedEntries]; + const combinedTail = Buffer.concat([cache.tailSignature, appended]); + usageReadCache = { + path, + dev: Number(stat.dev), + ino: Number(stat.ino), + birthtimeMs: Number(stat.birthtimeMs), + size, + mtimeMs, + ctimeMs, + endedWithNewline: appended.length === 0 || appended[appended.length - 1] === 0x0a, + tailSignature: usageTailSignature(combinedTail), + entries, + }; + return entries.slice(); + } catch { + usageReadCache = null; + return []; + } finally { + if (fd !== undefined) closeSync(fd); } - return entries; } function parseUsageLines(lines: string[]): PersistedUsageEntry[] { diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index ae1ba50d63d..4fd4cd9d1b7 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -78,6 +78,23 @@ Missing usage is never treated as zero. The dashboard Usage tab renders the same main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. +The reader keeps an in-process, append-aware cache for aggregate requests. An unchanged file reuses +normalized entries, while an ordinary append reads and parses only the new bytes. The cache is +discarded when the configured path changes, the file identity changes, the size shrinks, a same-size +rewrite is observed, or the previous suffix no longer matches. The Dashboard polls its 30-day usage +summary as an independent client resource so a large or older usage log cannot hold up health, +provider, or settings state. Full cache rebuilds used by the management API parse in bounded batches +and yield between them so unrelated management requests remain serviceable during an upgrade's first +read of an existing large log. + +[Decision Log] +- 목적과 의도: Keep five-second dashboard refreshes responsive as `usage.jsonl` grows. +- 기존 구현 및 제약 조건: The JSONL file remains the durable source of truth and may be truncated, replaced, or hand-edited. +- 검토한 주요 대안: Reparse the complete file, maintain a separate database, or cache normalized rows and read appended bytes. +- 선택한 방식: Verify file identity and a short prefix-boundary signature, incrementally parse appends, cooperatively batch full rebuilds, and poll usage separately in the GUI. +- 다른 대안 대신 이 방식을 선택한 이유: It removes repeated parsing without introducing a second persistence format or migration path. +- 장점, 단점 및 영향: Normal polling work scales with new rows; the first read and any detected rewrite still pay one full parse for correctness. + For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized request to `~/.opencodex/usage-debug.jsonl` (mode `0o600`, auto-trimmed to the most-recent 100 lines diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index 5dd574e354f..3dfbf715a7f 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -1,15 +1,18 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { appendUsageEntry, readRecentUsageEntries, readUsageEntries, + readUsageEntriesForManagement, + resetUsageReadCacheForTests, usageForFinalLog, usageLogPath, usageStatusForFinalLog, usageTotalTokens, + usageReadCacheStatsForTests, } from "../src/usage/log"; let testDir = ""; @@ -19,6 +22,7 @@ beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; testDir = mkdtempSync(join(tmpdir(), "ocx-usage-")); process.env.OPENCODEX_HOME = testDir; + resetUsageReadCacheForTests(); }); afterEach(() => { @@ -28,6 +32,59 @@ afterEach(() => { }); describe("usage log", () => { + const persistedLine = (requestId: string) => JSON.stringify({ + requestId, + timestamp: 1, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }); + + test("caches unchanged prefixes and parses only appended JSONL rows", () => { + writeFileSync(usageLogPath(), `${persistedLine("a")}\n${persistedLine("b")}\n`); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b"]); + expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2 }); + + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b"]); + expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2 }); + + appendFileSync(usageLogPath(), `${persistedLine("c")}\n`); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b", "c"]); + expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 1, parsedLines: 3 }); + }); + + test("invalidates the incremental cache after truncate or in-place rewrite", () => { + writeFileSync(usageLogPath(), `${persistedLine("old-a")}\n${persistedLine("old-b")}\n`); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["old-a", "old-b"]); + + writeFileSync(usageLogPath(), `${persistedLine("new")}\n`); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["new"]); + expect(usageReadCacheStatsForTests().fullReads).toBe(2); + + // Grow the same inode with a different prefix. The suffix signature prevents this + // from being mistaken for an append that could retain old entries. + writeFileSync(usageLogPath(), `${persistedLine("replacement-a")}\n${persistedLine("replacement-b")}\n`); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["replacement-a", "replacement-b"]); + expect(usageReadCacheStatsForTests().fullReads).toBe(3); + }); + + test("management full reads yield while parsing a large existing log", async () => { + writeFileSync( + usageLogPath(), + `${Array.from({ length: 2_100 }, (_, index) => persistedLine(`row-${index}`)).join("\n")}\n`, + ); + let timerRan = false; + setTimeout(() => { timerRan = true; }, 0); + const entries = await readUsageEntriesForManagement(); + expect(entries).toHaveLength(2_100); + expect(timerRan).toBe(true); + expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2_100 }); + }); + test("persists only canonical ordered attempt fields", () => { appendUsageEntry({ requestId: "ocx-attempts", From 6ebf84e65019aedc131b0708d4f9e53749dc87da Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sun, 26 Jul 2026 13:04:24 +0000 Subject: [PATCH 2/7] fix(usage): preserve read failure diagnostics --- src/usage/log.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/usage/log.ts b/src/usage/log.ts index 124ad561444..83a7f02892c 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -414,9 +414,9 @@ async function readUsageEntriesFullCooperatively(path: string): Promise Date: Sun, 26 Jul 2026 13:23:58 +0000 Subject: [PATCH 3/7] fix(usage): bound dashboard summary caching --- gui/src/pages/use-dashboard-data.ts | 2 +- gui/tests/dashboard-contracts.test.ts | 1 + src/server/management/logs-usage-routes.ts | 66 +++++- src/usage/log.ts | 222 +++++++-------------- structure/05_gui-and-management-api.md | 24 +-- tests/api-usage.test.ts | 32 ++- tests/usage-log.test.ts | 46 ++--- 7 files changed, 197 insertions(+), 196 deletions(-) diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 8f49c599eb7..b9ea608ac18 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -152,7 +152,7 @@ export function useDashboardData(apiBase: string) { `dashboard-usage:${apiBase}`, [apiBase], (signal) => fetchDashboardUsage(apiBase, signal), - { pollMs: 5000 }, + { pollMs: 60_000 }, ); const diagnosticsPoll = useKeyedClientResource( diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 8a643e64189..370808bc9c9 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -29,6 +29,7 @@ test("Dashboard usage polling cannot delay core health and settings", async () = expect(core.slice(coreFnStart)).not.toContain("/api/usage?range=30d"); expect(hook).toContain("dashboard-usage:${apiBase}"); expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); + expect(hook).toMatch(/dashboard-usage:\$\{apiBase\}[\s\S]*pollMs: 60_000/); }); test("Dashboard workspace pane is a labelled section, not a nested main landmark", async () => { diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 45226301a25..a12ebc7fd92 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -34,9 +34,14 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; -import { readUsageEntriesForManagement } from "../../usage/log"; +import { + currentUsageLogRevision, + readUsageSnapshotForManagement, + usageLogRevisionKey, + type PersistedUsageEntry, +} from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; +import { parseRange, parseUsageSurface, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; @@ -60,6 +65,48 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; +const USAGE_DAY_MS = 86_400_000; +const usageSummaryCache = new Map(); + +function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean { + if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop"; + if (surface === "grok") return entry.surface === "grok"; + if (surface === "codex") return entry.surface === undefined; + return true; +} + +function nextLocalMidnight(now: number): number { + const next = new Date(now); + next.setHours(24, 0, 0, 0); + return next.getTime(); +} + +function usageSummaryExpiresAt( + entries: PersistedUsageEntry[], + range: UsageRange, + surface: UsageSurface, + now: number, +): number { + let expiresAt = nextLocalMidnight(now); + const windowMs = range === "7d" ? 7 * USAGE_DAY_MS : range === "30d" ? 30 * USAGE_DAY_MS : null; + if (windowMs === null) return expiresAt; + for (const entry of entries) { + if (!usageEntryMatchesSurface(entry, surface)) continue; + const expiry = entry.timestamp + windowMs; + if (expiry > now && expiry < expiresAt) expiresAt = expiry; + } + return expiresAt; +} + +function refreshedUsageSummary(summary: UsageSummary, range: UsageRange, now: number): UsageSummary { + const since = range === "7d" ? now - 7 * USAGE_DAY_MS : range === "30d" ? now - 30 * USAGE_DAY_MS : null; + return { ...summary, since, generatedAt: now }; +} + export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -123,7 +170,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise } | null = null; +let managementUsageReadInflight: { + key: string; + promise: Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }>; +} | null = null; /** Test-only observability for proving that unchanged prefixes are not reparsed. */ export function usageReadCacheStatsForTests(): Readonly { @@ -311,27 +309,10 @@ export function usageReadCacheStatsForTests(): Readonly line.trim()).length; - return parseUsageLines(lines); -} - -function sameUsageFile(cache: UsageReadCache, stat: ReturnType): boolean { - const dev = Number(stat.dev); - const ino = Number(stat.ino); - if (cache.dev !== dev) return false; - // Node exposes a stable inode on the supported Windows and Unix runtimes. Keep a - // birth-time fallback for filesystems that report zero for every inode. - if (cache.ino !== 0 || ino !== 0) return cache.ino === ino; - return cache.birthtimeMs === Number(stat.birthtimeMs); -} - function readExactly(fd: number, length: number, position: number): Buffer | null { const output = Buffer.allocUnsafe(length); let offset = 0; @@ -343,32 +324,41 @@ function readExactly(fd: number, length: number, position: number): Buffer | nul return output; } -function usageTailSignature(bytes: Buffer): Buffer { - return bytes.subarray(Math.max(0, bytes.length - USAGE_CACHE_SIGNATURE_BYTES)).subarray(0); -} - -function readUsageEntriesFull(path: string, fd: number, stat: ReturnType): PersistedUsageEntry[] { - const size = Number(stat.size); - const bytes = readExactly(fd, size, 0); - if (bytes === null) { - usageReadCache = null; - return []; - } - const entries = parseUsageText(bytes.toString("utf-8")); - usageReadCacheStats.fullReads += 1; - usageReadCache = { +function usageLogRevision(path: string, stat: ReturnType): UsageLogRevision { + return { path, dev: Number(stat.dev), ino: Number(stat.ino), birthtimeMs: Number(stat.birthtimeMs), - size, + size: Number(stat.size), mtimeMs: Number(stat.mtimeMs), ctimeMs: Number(stat.ctimeMs), - endedWithNewline: bytes.length === 0 || bytes[bytes.length - 1] === 0x0a, - tailSignature: usageTailSignature(bytes), - entries, }; - return entries.slice(); +} + +export function usageLogRevisionKey(revision: UsageLogRevision | null): string { + if (!revision) return "missing"; + return [ + revision.path, + revision.dev, + revision.ino, + revision.birthtimeMs, + revision.size, + revision.mtimeMs, + revision.ctimeMs, + ].join("\0"); +} + +export function currentUsageLogRevision(): UsageLogRevision | null { + const path = usageLogPath(); + if (!existsSync(path)) return null; + let fd: number | undefined; + try { + fd = openSync(path, "r"); + return usageLogRevision(path, fstatSync(fd)); + } finally { + if (fd !== undefined) closeSync(fd); + } } async function parseUsageTextCooperatively(text: string): Promise { @@ -387,77 +377,43 @@ async function parseUsageTextCooperatively(text: string): Promise { +async function readUsageEntriesFullCooperatively( + path: string, +): Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }> { let fd: number | undefined; try { fd = openSync(path, "r"); const stat = fstatSync(fd); const size = Number(stat.size); const bytes = readExactly(fd, size, 0); - if (bytes === null) return []; + if (bytes === null) throw new Error("usage log changed while it was being read"); const entries = await parseUsageTextCooperatively(bytes.toString("utf-8")); usageReadCacheStats.fullReads += 1; - // A different OPENCODEX_HOME may have become active while the cooperative parse - // yielded. Return this snapshot to its caller without poisoning the new path cache. - if (usageLogPath() === path) { - usageReadCache = { - path, - dev: Number(stat.dev), - ino: Number(stat.ino), - birthtimeMs: Number(stat.birthtimeMs), - size, - mtimeMs: Number(stat.mtimeMs), - ctimeMs: Number(stat.ctimeMs), - endedWithNewline: bytes.length === 0 || bytes[bytes.length - 1] === 0x0a, - tailSignature: usageTailSignature(bytes), - entries, - }; - } - return entries.slice(); - } catch (error) { - if (usageReadCache?.path === path) usageReadCache = null; - throw error; - } finally { - if (fd !== undefined) closeSync(fd); - } -} - -function usageCacheNeedsFullRead(path: string): boolean { - const cache = usageReadCache; - if (!cache || cache.path !== path) return true; - let fd: number | undefined; - try { - fd = openSync(path, "r"); - const stat = fstatSync(fd); - const size = Number(stat.size); - if (!sameUsageFile(cache, stat) || size < cache.size) return true; - if (size === cache.size) { - return Number(stat.mtimeMs) !== cache.mtimeMs || Number(stat.ctimeMs) !== cache.ctimeMs; - } - if (!cache.endedWithNewline) return true; - const signatureStart = Math.max(0, cache.size - cache.tailSignature.length); - const signature = readExactly(fd, cache.tailSignature.length, signatureStart); - return signature === null || !signature.equals(cache.tailSignature); - } catch { - return true; + return { entries, revision: usageLogRevision(path, stat) }; } finally { if (fd !== undefined) closeSync(fd); } } /** - * Management API reader: full rebuilds yield between parse batches, while the steady - * append-only path stays synchronous and only processes the small new tail. + * 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. */ -export async function readUsageEntriesForManagement(): Promise { +export async function readUsageSnapshotForManagement(): Promise<{ + entries: PersistedUsageEntry[]; + revision: UsageLogRevision | null; +}> { const path = usageLogPath(); - if (!existsSync(path)) return []; - if (!usageCacheNeedsFullRead(path)) return readUsageEntries(); - if (managementUsageReadInflight?.path === path) { - return (await managementUsageReadInflight.promise).slice(); + if (!existsSync(path)) return { entries: [], revision: null }; + const observed = currentUsageLogRevision(); + const key = usageLogRevisionKey(observed); + if (managementUsageReadInflight?.key === key) { + const shared = await managementUsageReadInflight.promise; + return { entries: shared.entries.slice(), revision: shared.revision }; } const promise = readUsageEntriesFullCooperatively(path); - managementUsageReadInflight = { path, promise }; + managementUsageReadInflight = { key, promise }; try { return await promise; } finally { @@ -465,69 +421,27 @@ export async function readUsageEntriesForManagement(): Promise { + return (await readUsageSnapshotForManagement()).entries; +} + export function readUsageEntries(): PersistedUsageEntry[] { const path = usageLogPath(); - if (!existsSync(path)) { - if (usageReadCache?.path === path) usageReadCache = null; - return []; - } - - let fd: number | undefined; - try { - fd = openSync(path, "r"); - const stat = fstatSync(fd); - const size = Number(stat.size); - const mtimeMs = Number(stat.mtimeMs); - const ctimeMs = Number(stat.ctimeMs); - const cache = usageReadCache; - if (!cache || cache.path !== path || !sameUsageFile(cache, stat) || size < cache.size) { - return readUsageEntriesFull(path, fd, stat); - } - - if (size === cache.size) { - if (mtimeMs !== cache.mtimeMs || ctimeMs !== cache.ctimeMs) { - return readUsageEntriesFull(path, fd, stat); + if (!existsSync(path)) return []; + const lines = readFileSync(path, "utf-8").split(/\r?\n/); + const entries: PersistedUsageEntry[] = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as PersistedUsageEntry; + if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") { + entries.push(normalizeUsageEntry(parsed)); } - return cache.entries.slice(); - } - - // A writer may truncate and rewrite the same inode between polls. Verify the old - // suffix before trusting a larger size as an append; this costs at most 64 bytes. - const signatureStart = Math.max(0, cache.size - cache.tailSignature.length); - const currentSignature = readExactly(fd, cache.tailSignature.length, signatureStart); - if ( - !cache.endedWithNewline - || currentSignature === null - || !currentSignature.equals(cache.tailSignature) - ) { - return readUsageEntriesFull(path, fd, stat); + } catch { + /* keep reading after a partially written or hand-edited line */ } - - const appended = readExactly(fd, size - cache.size, cache.size); - if (appended === null) return readUsageEntriesFull(path, fd, stat); - const appendedEntries = parseUsageText(appended.toString("utf-8")); - usageReadCacheStats.tailReads += 1; - const entries = [...cache.entries, ...appendedEntries]; - const combinedTail = Buffer.concat([cache.tailSignature, appended]); - usageReadCache = { - path, - dev: Number(stat.dev), - ino: Number(stat.ino), - birthtimeMs: Number(stat.birthtimeMs), - size, - mtimeMs, - ctimeMs, - endedWithNewline: appended.length === 0 || appended[appended.length - 1] === 0x0a, - tailSignature: usageTailSignature(combinedTail), - entries, - }; - return entries.slice(); - } catch (error) { - usageReadCache = null; - throw error; - } finally { - if (fd !== undefined) closeSync(fd); } + return entries; } function parseUsageLines(lines: string[]): PersistedUsageEntry[] { diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 4fd4cd9d1b7..cce18d60fd2 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -78,22 +78,20 @@ Missing usage is never treated as zero. The dashboard Usage tab renders the same main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. -The reader keeps an in-process, append-aware cache for aggregate requests. An unchanged file reuses -normalized entries, while an ordinary append reads and parses only the new bytes. The cache is -discarded when the configured path changes, the file identity changes, the size shrinks, a same-size -rewrite is observed, or the previous suffix no longer matches. The Dashboard polls its 30-day usage -summary as an independent client resource so a large or older usage log cannot hold up health, -provider, or settings state. Full cache rebuilds used by the management API parse in bounded batches -and yield between them so unrelated management requests remain serviceable during an upgrade's first -read of an existing large log. +The management API caches only the compact summary for an exact file revision and query; it never +retains normalized per-request rows after a response. The cache invalidates on any identity, size, or +timestamp change and at the next range expiry or local-day boundary. Rebuilds parse in bounded +batches and yield between them, so unrelated management requests remain serviceable even for a large +existing log. The Dashboard polls its 30-day usage summary independently once per minute, so usage +work cannot delay health/provider/settings state or run every five seconds. [Decision Log] -- 목적과 의도: Keep five-second dashboard refreshes responsive as `usage.jsonl` grows. +- 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows. - 기존 구현 및 제약 조건: The JSONL file remains the durable source of truth and may be truncated, replaced, or hand-edited. -- 검토한 주요 대안: Reparse the complete file, maintain a separate database, or cache normalized rows and read appended bytes. -- 선택한 방식: Verify file identity and a short prefix-boundary signature, incrementally parse appends, cooperatively batch full rebuilds, and poll usage separately in the GUI. -- 다른 대안 대신 이 방식을 선택한 이유: It removes repeated parsing without introducing a second persistence format or migration path. -- 장점, 단점 및 영향: Normal polling work scales with new rows; the first read and any detected rewrite still pay one full parse for correctness. +- 검토한 주요 대안: Retain normalized rows, maintain a second database, or cache only revision-keyed summaries and cooperatively rebuild them. +- 선택한 방식: Keep only bounded summary results, share full reads by exact file identity, yield during parsing, and poll usage separately at a slower cadence. +- 다른 대안 대신 이 방식을 선택한 이유: It bounds resident heap and avoids a second persistence format while keeping unrelated endpoints responsive. +- 장점, 단점 및 영향: Unchanged queries are cheap and memory stays bounded; a changed large log still consumes rebuild CPU, but cooperatively and at most once per observed revision/query. For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 427b62f74f4..c0a24f785ee 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { resetUsageReadCacheForTests, usageReadCacheStatsForTests } from "../src/usage/log"; let testDir = ""; let previousHome: string | undefined; @@ -69,6 +70,7 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-api-usage-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-api-usage-")); process.env.OPENCODEX_HOME = testDir; + resetUsageReadCacheForTests(); saveConfig(baseConfig()); }); @@ -102,6 +104,34 @@ describe("GET /api/usage", () => { } }); + test("reuses only a compact summary for an unchanged revision", async () => { + writeFixture(Date.now()); + const server = startServer(0); + try { + const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(second.summary).toEqual(first.summary); + expect(usageReadCacheStatsForTests().fullReads).toBe(1); + + appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ + requestId: "ocx-appended", + timestamp: Date.now(), + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + 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); + } finally { + await server.stop(true); + } + }); + test("range=7d drops entries older than 7 days", async () => { writeFixture(Date.now()); const server = startServer(0); diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index 3dfbf715a7f..80c18374bd0 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -1,18 +1,21 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { appendUsageEntry, + currentUsageLogRevision, readRecentUsageEntries, readUsageEntries, readUsageEntriesForManagement, + readUsageSnapshotForManagement, resetUsageReadCacheForTests, usageForFinalLog, usageLogPath, usageStatusForFinalLog, usageTotalTokens, usageReadCacheStatsForTests, + usageLogRevisionKey, } from "../src/usage/log"; let testDir = ""; @@ -44,32 +47,12 @@ describe("usage log", () => { totalTokens: 2, }); - test("caches unchanged prefixes and parses only appended JSONL rows", () => { + test("file revisions change after append and in-place rewrite", () => { writeFileSync(usageLogPath(), `${persistedLine("a")}\n${persistedLine("b")}\n`); - expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b"]); - expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2 }); - - expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b"]); - expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2 }); - - appendFileSync(usageLogPath(), `${persistedLine("c")}\n`); - expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["a", "b", "c"]); - expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 1, parsedLines: 3 }); - }); - - test("invalidates the incremental cache after truncate or in-place rewrite", () => { - writeFileSync(usageLogPath(), `${persistedLine("old-a")}\n${persistedLine("old-b")}\n`); - expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["old-a", "old-b"]); - + const first = usageLogRevisionKey(currentUsageLogRevision()); writeFileSync(usageLogPath(), `${persistedLine("new")}\n`); + expect(usageLogRevisionKey(currentUsageLogRevision())).not.toBe(first); expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["new"]); - expect(usageReadCacheStatsForTests().fullReads).toBe(2); - - // Grow the same inode with a different prefix. The suffix signature prevents this - // from being mistaken for an append that could retain old entries. - writeFileSync(usageLogPath(), `${persistedLine("replacement-a")}\n${persistedLine("replacement-b")}\n`); - expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["replacement-a", "replacement-b"]); - expect(usageReadCacheStatsForTests().fullReads).toBe(3); }); test("management full reads yield while parsing a large existing log", async () => { @@ -85,6 +68,21 @@ describe("usage log", () => { expect(usageReadCacheStatsForTests()).toEqual({ fullReads: 1, tailReads: 0, parsedLines: 2_100 }); }); + test("a replacement does not join an in-flight read for the previous file revision", async () => { + writeFileSync( + usageLogPath(), + `${Array.from({ length: 2_100 }, (_, index) => persistedLine(`old-${index}`)).join("\n")}\n`, + ); + const oldRead = readUsageSnapshotForManagement(); + await new Promise(resolve => setTimeout(resolve, 0)); + writeFileSync(usageLogPath(), `${persistedLine("replacement")}\n`); + const newRead = readUsageSnapshotForManagement(); + const [oldSnapshot, newSnapshot] = await Promise.all([oldRead, newRead]); + expect(oldSnapshot.entries).toHaveLength(2_100); + expect(newSnapshot.entries.map(entry => entry.requestId)).toEqual(["replacement"]); + expect(usageLogRevisionKey(newSnapshot.revision)).not.toBe(usageLogRevisionKey(oldSnapshot.revision)); + }); + test("persists only canonical ordered attempt fields", () => { appendUsageEntry({ requestId: "ocx-attempts", From 42dd81596d05901ab849ebbd1312cf4d5d266d8f Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sun, 26 Jul 2026 13:35:05 +0000 Subject: [PATCH 4/7] fix(usage): reject non-file log paths --- src/usage/log.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/usage/log.ts b/src/usage/log.ts index 6104e300703..04916561c7d 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -325,6 +325,7 @@ function readExactly(fd: number, length: number, position: number): Buffer | nul } function usageLogRevision(path: string, stat: ReturnType): UsageLogRevision { + if (!stat.isFile()) throw new Error("usage log is not a regular file"); return { path, dev: Number(stat.dev), From 1f3eddac32b7883a8f5d17899b397ebeaeaeb278 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Sun, 26 Jul 2026 22:45:10 +0900 Subject: [PATCH 5/7] ci: allow Windows suite to finish --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27ca1e34e28..bdcd84dcee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,9 @@ jobs: test: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 8 + # The Windows full suite now completes near the old 8-minute ceiling; leave + # enough room for the privacy/build smokes instead of cancelling a green run. + timeout-minutes: 12 strategy: fail-fast: false matrix: From 79f3daa8b78d9e08e9603754c796465027188aeb Mon Sep 17 00:00:00 2001 From: ingwannu Date: Sun, 26 Jul 2026 22:49:43 +0900 Subject: [PATCH 6/7] test(ci): pin split timeout ceilings --- tests/ci-workflows.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index b53ae0574b5..83e78e6a5b4 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -16,7 +16,7 @@ describe("GitHub Actions hardening", () => { test("cross-platform CI keeps bounded jobs and immutable action references", async () => { const workflow = await readText(".github/workflows/ci.yml"); - expect(count(workflow, "timeout-minutes: 8")).toBe(2); + expect(count(workflow, "timeout-minutes: 12")).toBe(1);\n expect(count(workflow, "timeout-minutes: 8")).toBe(1); expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"); From 63b15e59a963ae7dec6b8785cccc537dd384c289 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Sun, 26 Jul 2026 22:54:26 +0900 Subject: [PATCH 7/7] fix(test): restore CI timeout assertions --- tests/ci-workflows.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 83e78e6a5b4..c082d94b1be 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -16,7 +16,8 @@ describe("GitHub Actions hardening", () => { test("cross-platform CI keeps bounded jobs and immutable action references", async () => { const workflow = await readText(".github/workflows/ci.yml"); - expect(count(workflow, "timeout-minutes: 12")).toBe(1);\n expect(count(workflow, "timeout-minutes: 8")).toBe(1); + expect(count(workflow, "timeout-minutes: 12")).toBe(1); + expect(count(workflow, "timeout-minutes: 8")).toBe(1); expect(workflow).toContain("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"); expect(workflow).toContain("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); expect(workflow).toContain("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e");