From f152ea6ae58aa47827972d85d2af4153bffae6c9 Mon Sep 17 00:00:00 2001 From: ptweezy Date: Tue, 21 Jul 2026 13:35:24 -0400 Subject: [PATCH] fix: fail soft when a single .jsonl cannot be timestamp-probed sortUsageFilesByEarliestTimestamp mapped readEarliestTimestamp over every file with no per-file error handling. A file that trips the probe's 1 MiB no-timestamp guard (TimestampProbeLimitError), such as a large workflow journal.jsonl, rejected the whole batch, so loadUsageRecords returned empty records and the dashboard blanked on every refresh (failed=1, bytes=0). Catch the per-file failure and fall back to timestampMs 0 so the file sorts by discovery order, matching the existing small-no-timestamp behavior. The rest of the load proceeds and the file still reaches the main reader. readEarliestTimestamp and its test are unchanged. Adds a regression test. --- CHANGELOG.md | 8 +++++++ src/claudeUsageFiles.ts | 28 +++++++++++++++++++----- src/test/claudeUsageFiles.test.ts | 36 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c00545..c4537518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel are credited as development tools, separately from human contributors. ### Fixed +- **Usage dashboard no longer blanks on one oversized non-transcript file** — a + single `.jsonl` with more than 1 MiB of leading lines that carry no + `timestamp` (such as a large workflow `journal.jsonl`, whose records hold + `type`/`key`/`result`/`agentId` and never a timestamp) made the + earliest-timestamp probe throw `TimestampProbeLimitError`. That rejection was + unhandled, so it aborted the whole load and left the dashboard empty on every + refresh (`failed=1`, `bytes=0` in diagnostics). The probe now fails soft for + that one file and the rest load normally. - **High-CPU refresh mitigation (#70)** — polling now always honors the configured 30–3600 second `refreshInterval`; file watching is quiet-debounce only and adds 60/120/300-second choices. First-timestamp reads stop after the diff --git a/src/claudeUsageFiles.ts b/src/claudeUsageFiles.ts index 0d381956..0db5792e 100644 --- a/src/claudeUsageFiles.ts +++ b/src/claudeUsageFiles.ts @@ -204,11 +204,29 @@ export async function readEarliestTimestamp( export async function sortUsageFilesByEarliestTimestamp( entries: readonly UsageFileFingerprint[], ): Promise<{ files: string[]; bytesRead: number }> { - const stamped = await mapWithConcurrency(entries, 8, async (entry) => ({ - file: entry.path, - discoveryIndex: entry.discoveryIndex, - ...(await readEarliestTimestamp(entry.path)), - })); + const stamped = await mapWithConcurrency(entries, 8, async (entry) => { + try { + return { + file: entry.path, + discoveryIndex: entry.discoveryIndex, + ...(await readEarliestTimestamp(entry.path)), + }; + } catch { + // A single file we cannot probe must not abort the whole load. This + // happens when a .jsonl has more than 1 MiB of leading lines with no + // `timestamp` (readEarliestTimestamp throws TimestampProbeLimitError) — + // e.g. a large workflow `journal.jsonl`, whose records carry no + // timestamp — or on a transient read error. Fall back to a neutral stamp + // so the file sorts by discovery order, exactly as a small no-timestamp + // file already does, and the main reader still processes it. + return { + file: entry.path, + discoveryIndex: entry.discoveryIndex, + timestampMs: 0, + bytesRead: 0, + }; + } + }); stamped.sort((a, b) => a.timestampMs - b.timestampMs || a.discoveryIndex - b.discoveryIndex, ); diff --git a/src/test/claudeUsageFiles.test.ts b/src/test/claudeUsageFiles.test.ts index b15ae3f6..840370fd 100644 --- a/src/test/claudeUsageFiles.test.ts +++ b/src/test/claudeUsageFiles.test.ts @@ -191,3 +191,39 @@ test('equal or invalid timestamps preserve v2.2.0 discovery order', async () => ]); assert.deepEqual(invalidPair.files, [invalid, invalid2]); }); + +test('a single unprobeable file does not abort ordering the rest', async () => { + const { root } = await fixture(); + const project = path.join(root, 'projects', '-tmp-demo'); + const good = path.join(project, 'good.jsonl'); + const journal = path.join(project, 'journal.jsonl'); + await writeFile(good, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n'); + // A large workflow journal.jsonl: >1 MiB of records that never carry a + // `timestamp`, which makes readEarliestTimestamp throw TimestampProbeLimitError. + await writeFile(journal, '{"type":"result","key":"k","agentId":"a"}\n'.repeat(40_000)); + await assert.rejects( + () => readEarliestTimestamp(journal), + { name: 'TimestampProbeLimitError' }, + ); + + const make = async (file: string, discoveryIndex: number) => { + const fileStat = await stat(file); + return { + path: file, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + discoveryIndex, + dev: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.dev : undefined, + ino: fileStat.dev > 0 && fileStat.ino > 0 ? fileStat.ino : undefined, + }; + }; + + // Before the fix this rejected — one unprobeable file zeroed the whole load. + // Now it resolves and keeps every file, the unprobeable one stamped 0 so it + // sorts ahead of the real timestamp by discovery order. + const sorted = await sortUsageFilesByEarliestTimestamp([ + await make(journal, 0), + await make(good, 1), + ]); + assert.deepEqual(sorted.files, [journal, good]); +});