diff --git a/src/utils/__tests__/block-cache.test.ts b/src/utils/__tests__/block-cache.test.ts index f57031bf3..4b3c44cd8 100644 --- a/src/utils/__tests__/block-cache.test.ts +++ b/src/utils/__tests__/block-cache.test.ts @@ -262,6 +262,21 @@ describe('getCachedBlockMetrics integration', () => { expect(result).toBeNull(); }); + it('should recalculate when the cached block starts in the future', async () => { + const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl'); + + // Written while the system clock ran ahead, before it was set back + const futureStartTime = new Date(); + futureStartTime.setHours(futureStartTime.getHours() + 2); + + writeBlockCache(futureStartTime); + + const result = getCachedBlockMetrics(); + + // Should return null because the block hasn't started and no real JSONL files exist + expect(result).toBeNull(); + }); + it('should recalculate when no cache exists', async () => { const { getCachedBlockMetrics } = await import('../jsonl'); @@ -359,6 +374,22 @@ describe('getCachedBlockMetrics negative caching', () => { expect(readRawCache().emptyUntil).not.toBe(lapsed); }); + it('scans again when a record reaches further out than one interval', async () => { + const { getCachedBlockMetrics } = await import('../jsonl'); + const cachePath = getBlockCachePath(); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + // Written while the system clock ran ahead, before it was set back + const farAhead = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync(cachePath, JSON.stringify({ + emptyUntil: farAhead, + configDir: path.resolve(process.env.CLAUDE_CONFIG_DIR ?? '') + })); + + expect(getCachedBlockMetrics()).toBeNull(); + + expect(readRawCache().emptyUntil).not.toBe(farAhead); + }); + it('ignores a record belonging to another config directory', async () => { const { getCachedBlockMetrics } = await import('../jsonl'); const otherProfile = path.join(tempDir, '.claude-other'); diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 8193f330a..3d669da3d 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -17,6 +17,10 @@ function floorToHourUtc(timestamp: Date): Date { return floored; } +function hoursAgo(now: Date, hours: number): Date { + return new Date(now.getTime() - (hours * 60 * 60 * 1000)); +} + function makeUsageLine(timestamp: Date): string { return JSON.stringify({ timestamp: timestamp.toISOString(), @@ -29,6 +33,48 @@ function makeUsageLine(timestamp: Date): string { }); } +// The row Claude Code writes when a request is rejected (a usage limit, an +// expired login, an outage): its usage is all zeros +function makeApiErrorLine(timestamp: Date): string { + return JSON.stringify({ + type: 'assistant', + timestamp: timestamp.toISOString(), + isApiErrorMessage: true, + message: { + model: '', + stop_reason: 'stop_sequence', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + } + }); +} + +// A session left open since yesterday: one row 30h ago, then one every 30 +// minutes from 9h ago, so its own rows show a 21h gap +function writeResumedSession(claudeDir: string, now: Date): void { + const projectDir = path.join(claudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectDir, { recursive: true }); + const rows = [hoursAgo(now, 30)]; + for (let hours = 9; hours > 0; hours -= 0.5) { + rows.push(hoursAgo(now, hours)); + } + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), rows.map(row => makeUsageLine(row)).join('\n')); +} + +// Another project's session with rows the given hours ago, last written at the newest +function writeOtherProjectSession(claudeDir: string, now: Date, rowHoursAgo: number[]): void { + const projectDir = path.join(claudeDir, 'projects', 'project-b'); + fs.mkdirSync(projectDir, { recursive: true }); + const transcriptPath = path.join(projectDir, 'session.jsonl'); + fs.writeFileSync(transcriptPath, rowHoursAgo.map(hours => makeUsageLine(hoursAgo(now, hours))).join('\n')); + const lastWritten = hoursAgo(now, Math.min(...rowHoursAgo)); + fs.utimesSync(transcriptPath, lastWritten, lastWritten); +} + describe('jsonl block metrics integration', () => { let tempClaudeDir: string; let originalClaudeConfigDir: string | undefined; @@ -71,6 +117,120 @@ describe('jsonl block metrics integration', () => { expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); }); + // Windows forbids * and ? in directory names. + const globDirectoryNames = ['claude (work)', 'cfg[1]', 'brace{a}', 'star*x', 'q?mark'] + .filter(dirName => process.platform !== 'win32' || !/[*?]/.test(dirName)); + + it.each(globDirectoryNames)('finds transcripts under a config directory named %j', (dirName) => { + const configDir = path.join(tempClaudeDir, dirName); + process.env.CLAUDE_CONFIG_DIR = configDir; + const projectsDir = path.join(configDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + + const now = new Date(); + const blockStartSource = new Date(now.getTime() - (30 * 60 * 1000)); + const recentActivity = new Date(now.getTime() - (10 * 60 * 1000)); + + fs.writeFileSync(path.join(projectsDir, 'session.jsonl'), [ + makeUsageLine(blockStartSource), + makeUsageLine(recentActivity) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(blockStartSource).toISOString()); + }); + + it('returns null when the only recent rows are rejected requests', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + fs.writeFileSync(transcriptPath, [ + makeApiErrorLine(new Date(now.getTime() - (170 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (120 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (45 * 60 * 1000))) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics).toBeNull(); + }); + + it('starts the block at the first real request after rejected ones', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + const firstRealRequest = new Date(now.getTime() - (45 * 60 * 1000)); + const recentActivity = new Date(now.getTime() - (10 * 60 * 1000)); + + fs.writeFileSync(transcriptPath, [ + makeApiErrorLine(new Date(now.getTime() - (170 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (160 * 60 * 1000))), + makeUsageLine(firstRealRequest), + makeUsageLine(recentActivity) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(firstRealRequest).toISOString()); + expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); + }); + + it('ignores rows that are not served requests or lack a valid timestamp', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + const realRequest = new Date(now.getTime() - (45 * 60 * 1000)); + const ignoredRowTime = new Date(now.getTime() - (10 * 60 * 1000)); + const usage = { input_tokens: 100, output_tokens: 50 }; + + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ timestamp: 'not a date', message: { usage } }), + '{not json', + JSON.stringify({ type: 'user', timestamp: ignoredRowTime.toISOString(), message: { content: 'hello' } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), message: { usage: { input_tokens: 100 } } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), message: { usage: { output_tokens: 50 } } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), isSidechain: true, message: { usage } }), + JSON.stringify({ timestamp: ignoredRowTime.getTime(), message: { usage } }), + makeUsageLine(realRequest) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(realRequest).toISOString()); + expect(metrics?.lastActivity.toISOString()).toBe(realRequest.toISOString()); + }); + + it('reads further back when a gap reaches past the lookback, since an older transcript may fill it', () => { + const now = new Date(); + writeResumedSession(tempClaudeDir, now); + // Last written 10.5h ago, just outside the first lookback, and fills the gap + writeOtherProjectSession(tempClaudeDir, now, [12, 11.5, 11, 10.5]); + + const metrics = getBlockMetrics(); + + // One chain from 12h ago, through blocks starting about 7h and 2h ago + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(hoursAgo(now, 2)).toISOString()); + }); + + it('still ends the block chain at a gap that no older transcript fills', () => { + const now = new Date(); + writeResumedSession(tempClaudeDir, now); + // Inside the gap, but more than 5h before the session resumed + writeOtherProjectSession(tempClaudeDir, now, [27, 26]); + + const metrics = getBlockMetrics(); + + // One chain from 9h ago, through a block starting about 4h ago + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(hoursAgo(now, 4)).toISOString()); + }); + it('returns null when the most recent activity is older than the session window', () => { const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); fs.mkdirSync(projectsDir, { recursive: true }); diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index 703319aa6..54e869e32 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -1,5 +1,4 @@ import * as fs from 'node:fs'; -import path from 'node:path'; import { globSync } from 'tinyglobby'; import type { BlockMetrics } from '../types'; @@ -40,9 +39,9 @@ function findMostRecentBlockStartTime( const now = new Date(); // Step 1: Find all JSONL files with their modification times - // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) - const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); - const files = globSync([pattern], { + // Keep the pattern relative to cwd: inside a pattern, characters a directory + // name can contain, such as ( ) [ ] { } * ?, are glob syntax + const files = globSync(['projects/**/*.jsonl'], { absolute: true, // Ensure we get absolute paths cwd: rootDir // Set working directory to rootDir }); @@ -104,6 +103,7 @@ function findMostRecentBlockStartTime( } // Look for a session gap in this chunk + const isLastChunk = lookbackHours === lookbackChunks[lookbackChunks.length - 1]; continuousWorkStart = mostRecentTimestamp; for (let i = 1; i < timestamps.length; i++) { const currentTimestamp = timestamps[i]; @@ -115,8 +115,11 @@ function findMostRecentBlockStartTime( const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); if (gap >= sessionDurationMs) { - // Found a true session boundary - foundSessionGap = true; + // Transcripts last written before the cutoff weren't read, and + // their rows could fill the part of this gap before it. Unless the + // part after the cutoff spans a session by itself, look further back + foundSessionGap = isLastChunk + || previousTimestamp.getTime() - cutoffTime.getTime() >= sessionDurationMs; break; } @@ -129,7 +132,7 @@ function findMostRecentBlockStartTime( } // If this was our last chunk, use what we have - if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { + if (isLastChunk) { break; } } @@ -177,6 +180,13 @@ function findMostRecentBlockStartTime( return null; } +interface TranscriptRow { + timestamp?: string; + isSidechain?: boolean; + isApiErrorMessage?: boolean; + message?: { usage?: { input_tokens?: number; output_tokens?: number } }; +} + /** * Gets all timestamps from a JSONL file */ @@ -184,34 +194,13 @@ function getAllTimestampsFromFile(filePath: string): Date[] { const timestamps: Date[] = []; try { for (const line of iterateJsonlLinesSync(filePath)) { - const json = parseJsonlLine(line) as { - timestamp?: string; - isSidechain?: boolean; - message?: { usage?: { input_tokens?: number; output_tokens?: number } }; - } | null; + const json = parseJsonlLine(line) as TranscriptRow | null; if (!json) { continue; } - // Only treat entries with real token usage as block activity - const usage = json.message?.usage; - if (!usage) - continue; - - const hasInputTokens = typeof usage.input_tokens === 'number'; - const hasOutputTokens = typeof usage.output_tokens === 'number'; - if (!hasInputTokens || !hasOutputTokens) - continue; - - if (json.isSidechain === true) - continue; - - const timestamp = json.timestamp; - if (typeof timestamp !== 'string') - continue; - - const date = new Date(timestamp); - if (!Number.isNaN(date.getTime())) + const date = getActivityTimestamp(json); + if (date) timestamps.push(date); } @@ -221,6 +210,36 @@ function getAllTimestampsFromFile(filePath: string): Date[] { } } +/** + * Gets when a transcript row was served, or null when the row isn't block activity + */ +function getActivityTimestamp(json: TranscriptRow): Date | null { + // Only treat entries with real token usage as block activity + const usage = json.message?.usage; + if (!usage) + return null; + + const hasInputTokens = typeof usage.input_tokens === 'number'; + const hasOutputTokens = typeof usage.output_tokens === 'number'; + if (!hasInputTokens || !hasOutputTokens) + return null; + + if (json.isSidechain === true) + return null; + + // Claude Code logs a rejected request (a usage limit, an expired + // login, an outage) as a row with all-zero usage; nothing was served + if (json.isApiErrorMessage === true) + return null; + + const timestamp = json.timestamp; + if (typeof timestamp !== 'string') + return null; + + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? null : date; +} + /** * Floors a timestamp to the beginning of the hour (matching existing logic) */ diff --git a/src/utils/jsonl-cache.ts b/src/utils/jsonl-cache.ts index 3a88e5556..3866ac91d 100644 --- a/src/utils/jsonl-cache.ts +++ b/src/utils/jsonl-cache.ts @@ -187,7 +187,8 @@ export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | const cachedStartTime = readBlockCache(activeConfigDir); if (cachedStartTime) { const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs); - if (now.getTime() <= blockEndTime.getTime()) { + // A start still ahead of now was written before the clock was set back + if (cachedStartTime.getTime() <= now.getTime() && now.getTime() <= blockEndTime.getTime()) { // Cache is valid - return cached result return { startTime: cachedStartTime, @@ -197,9 +198,11 @@ export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | // Cache expired - need to recalculate } - // A recent scan already found nothing; repeating it walks every transcript again + // A recent scan already found nothing; repeating it walks every transcript again. + // Bounded by one interval, so a record written before the clock was set back + // cannot hide a new block for longer. const emptyUntil = readEmptyBlockCache(activeConfigDir); - if (emptyUntil && now.getTime() < emptyUntil.getTime()) { + if (emptyUntil && now.getTime() < emptyUntil.getTime() && emptyUntil.getTime() <= now.getTime() + EMPTY_RESULT_TTL_MS) { return null; }