diff --git a/src/utils/__tests__/jsonl-lines.test.ts b/src/utils/__tests__/jsonl-lines.test.ts new file mode 100644 index 00000000..308b9e40 --- /dev/null +++ b/src/utils/__tests__/jsonl-lines.test.ts @@ -0,0 +1,249 @@ +import * as fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { + clearJsonlLineCache, + readJsonlLines, + readJsonlLinesSync +} from '../jsonl-lines'; + +/** Matches MAX_CACHED_FILES in jsonl-lines.ts. */ +const MAX_CACHED_FILES = 8; + +function makeLine(value: string): string { + return JSON.stringify({ value }); +} + +/** Indexed access that fails loudly, since the config forbids non-null assertions. */ +function nth(items: readonly T[], index: number): T { + const item = items[index]; + if (item === undefined) { + throw new Error(`no element at index ${index}`); + } + + return item; +} + +describe('jsonl line cache', () => { + let tempDir: string; + let transcript: string; + + function transcriptAt(name: string, contents: string): string { + const filePath = path.join(tempDir, name); + fs.writeFileSync(filePath, contents); + return filePath; + } + + /** Rewrites a file at a byte length and modification time of the caller's choosing. */ + function rewrite(filePath: string, contents: string, mtime: Date): void { + fs.writeFileSync(filePath, contents); + fs.utimesSync(filePath, mtime, mtime); + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-lines-')); + transcript = path.join(tempDir, 'session.jsonl'); + clearJsonlLineCache(); + }); + + afterEach(() => { + clearJsonlLineCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('splits a transcript into its non-empty lines', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n\n${makeLine('b')}\n`); + + expect(readJsonlLinesSync(transcript)).toEqual([makeLine('a'), makeLine('b')]); + }); + + it('reuses the split lines while the file is unchanged', () => { + fs.writeFileSync(transcript, `${makeLine('aa')}\n`); + + expect(readJsonlLinesSync(transcript)).toBe(readJsonlLinesSync(transcript)); + }); + + it('re-reads once the file grows', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + readJsonlLinesSync(transcript); + + fs.appendFileSync(transcript, `${makeLine('b')}\n`); + + expect(readJsonlLinesSync(transcript)).toEqual([makeLine('a'), makeLine('b')]); + }); + + it('re-reads when only the size changes, at a pinned modification time', () => { + const pinned = new Date('2020-01-01T00:00:00Z'); + rewrite(transcript, `${makeLine('a')}\n`, pinned); + readJsonlLinesSync(transcript); + + // Same mtime, different length: only size can tell these apart. + rewrite(transcript, `${makeLine('a')}\n${makeLine('b')}\n`, pinned); + + expect(readJsonlLinesSync(transcript)).toEqual([makeLine('a'), makeLine('b')]); + }); + + it('re-reads when only the modification time changes, at a pinned size', () => { + rewrite(transcript, `${makeLine('aa')}\n`, new Date('2020-01-01T00:00:00Z')); + readJsonlLinesSync(transcript); + + // Same length, later mtime: only the timestamp can tell these apart. + rewrite(transcript, `${makeLine('bb')}\n`, new Date('2021-01-01T00:00:00Z')); + + expect(readJsonlLinesSync(transcript)).toEqual([makeLine('bb')]); + }); + + it('serves one entry for the many spellings of a path', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + + // Built by hand rather than through path.join, which would normalize + // these back into the very string they are meant to differ from. + const base = path.basename(transcript); + const sep = path.sep; + const spellings = [ + transcript, + `${tempDir}${sep}.${sep}${base}`, + `${tempDir}${sep}${sep}${base}` + ]; + + // A backslash separates paths on Windows and is an ordinary filename + // character elsewhere, so only Windows has the slash-direction spelling. + if (process.platform === 'win32') { + spellings.push(transcript.split('\\').join('/')); + } + + const first = readJsonlLinesSync(nth(spellings, 0)); + + for (const spelling of spellings.slice(1)) { + expect(readJsonlLinesSync(spelling)).toBe(first); + } + }); + + it('shares one entry between the sync and async readers', async () => { + fs.writeFileSync(transcript, `${makeLine('aa')}\n`); + + const fromSync = readJsonlLinesSync(transcript); + + await expect(readJsonlLines(transcript)).resolves.toBe(fromSync); + }); + + it('re-reads after the cache is cleared', async () => { + fs.writeFileSync(transcript, `${makeLine('aa')}\n`); + const first = readJsonlLinesSync(transcript); + + clearJsonlLineCache(); + + const second = await readJsonlLines(transcript); + expect(second).not.toBe(first); + expect(second).toEqual(first); + }); + + it('caches each transcript separately', () => { + const other = transcriptAt('other.jsonl', `${makeLine('z')}\n`); + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + + const first = readJsonlLinesSync(transcript); + readJsonlLinesSync(other); + + expect(readJsonlLinesSync(transcript)).toBe(first); + expect(readJsonlLinesSync(other)).toEqual([makeLine('z')]); + }); + + it('keeps a second transcript when a first is written', () => { + const other = transcriptAt('other.jsonl', `${makeLine('z')}\n`); + const kept = readJsonlLinesSync(other); + + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + readJsonlLinesSync(transcript); + + expect(readJsonlLinesSync(other)).toBe(kept); + }); + + it('evicts the oldest entry once the cap is passed', () => { + const paths = Array.from({ length: MAX_CACHED_FILES + 1 }, (_unused, index) => transcriptAt(`t${index}.jsonl`, `${makeLine(`v${index}`)}\n`)); + const cached = paths.slice(0, MAX_CACHED_FILES).map(filePath => readJsonlLinesSync(filePath)); + + // One past the cap, which evicts the least recently written entry. + readJsonlLinesSync(nth(paths, MAX_CACHED_FILES)); + + // Assert the survivor before re-reading the evicted one, since that read re-inserts. + expect(readJsonlLinesSync(nth(paths, 1))).toBe(nth(cached, 1)); + expect(readJsonlLinesSync(nth(paths, 0))).not.toBe(nth(cached, 0)); + }); + + it('counts a rewritten entry as the most recently used', () => { + const paths = Array.from({ length: MAX_CACHED_FILES + 1 }, (_unused, index) => transcriptAt(`r${index}.jsonl`, `${makeLine(`v${index}`)}\n`)); + for (const filePath of paths.slice(0, MAX_CACHED_FILES)) { + readJsonlLinesSync(filePath); + } + + // Rewriting the oldest entry should move it off the eviction block. + fs.appendFileSync(nth(paths, 0), `${makeLine('grown')}\n`); + const refreshed = readJsonlLinesSync(nth(paths, 0)); + + readJsonlLinesSync(nth(paths, MAX_CACHED_FILES)); + + expect(readJsonlLinesSync(nth(paths, 0))).toBe(refreshed); + }); + + it('retains every entry up to the cap', () => { + const paths = Array.from({ length: MAX_CACHED_FILES }, (_unused, index) => transcriptAt(`k${index}.jsonl`, `${makeLine(`v${index}`)}\n`)); + const first = paths.map(filePath => readJsonlLinesSync(filePath)); + + paths.forEach((filePath, index) => { + expect(readJsonlLinesSync(filePath)).toBe(nth(first, index)); + }); + }); + + it('does not cache when caching is disabled', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + + const first = readJsonlLinesSync(transcript, { cache: false }); + const second = readJsonlLinesSync(transcript, { cache: false }); + + expect(second).not.toBe(first); + expect(second).toEqual(first); + }); + + it('does not cache when caching is disabled on the async reader', async () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + + const first = await readJsonlLines(transcript, { cache: false }); + const second = await readJsonlLines(transcript, { cache: false }); + + expect(second).not.toBe(first); + expect(second).toEqual(first); + }); + + it('does not populate the cache from an uncached read', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + + readJsonlLinesSync(transcript, { cache: false }); + + // A cached read after an uncached one still has to do its own work. + const cached = readJsonlLinesSync(transcript); + expect(readJsonlLinesSync(transcript)).toBe(cached); + }); + + it('propagates the read failure for a missing transcript', () => { + expect(() => readJsonlLinesSync(path.join(tempDir, 'absent.jsonl'))).toThrow(); + }); + + it('serves a recreated transcript rather than the deleted one', () => { + fs.writeFileSync(transcript, `${makeLine('a')}\n`); + readJsonlLinesSync(transcript); + + fs.rmSync(transcript); + fs.writeFileSync(transcript, `${makeLine('b')}\n${makeLine('c')}\n`); + + expect(readJsonlLinesSync(transcript)).toEqual([makeLine('b'), makeLine('c')]); + }); +}); diff --git a/src/utils/compaction.ts b/src/utils/compaction.ts index 315f4141..7355f890 100644 --- a/src/utils/compaction.ts +++ b/src/utils/compaction.ts @@ -48,7 +48,7 @@ export function getCompactBoundaryPostTokens(record: unknown): number | null { * `tokensReclaimed` sums `preTokens - postTokens` only for markers where both * are finite numbers; older markers without `postTokens` contribute 0. */ -export function computeCompactionStats(lines: string[]): CompactionData { +export function computeCompactionStats(lines: readonly string[]): CompactionData { const stats: CompactionData = { count: 0, byTrigger: { auto: 0, manual: 0, unknown: 0 }, diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index 2b519176..9057735d 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -183,7 +183,9 @@ function findMostRecentBlockStartTime( function getAllTimestampsFromFile(filePath: string): Date[] { const timestamps: Date[] = []; try { - const lines = readJsonlLinesSync(filePath); + // This sweep visits each transcript once, across as many as the lookback + // selects, so caching cannot hit and would retain every one of them. + const lines = readJsonlLinesSync(filePath, { cache: false }); for (const line of lines) { const json = parseJsonlLine(line) as { diff --git a/src/utils/jsonl-lines.ts b/src/utils/jsonl-lines.ts index 3663a800..1833e97a 100644 --- a/src/utils/jsonl-lines.ts +++ b/src/utils/jsonl-lines.ts @@ -3,19 +3,136 @@ import { promisify } from 'util'; const readFile = promisify(fs.readFile); const readFileSync = fs.readFileSync; +const stat = promisify(fs.stat); +const statSync = fs.statSync; + +/** Transcripts to retain. A render reads the session transcript and the subagent transcripts it references. */ +const MAX_CACHED_FILES = 8; + +interface CacheEntry { + /** Size and modification time, so an appended transcript is re-read rather than served stale. */ + readonly version: string; + readonly lines: readonly string[]; +} + +/** + * Split lines, keyed by file identity. + * + * @remarks + * A render reads the session transcript from five call sites: token metrics, + * session duration, speed metrics, compaction stats and thinking effort. Each + * one re-reads and re-splits the whole file, so the transcript is walked five + * times per repaint. Reuse keeps that to one walk. + */ +const lineCache = new Map(); + +/** + * Identifies the file itself rather than the string used to reach it. + * + * @remarks + * Windows accepts many spellings of one path, and the code mixes them: a + * transcript path arrives with backslashes while a glob yields forward slashes. + * Keying on the device and inode gives those one entry instead of several. + * Inodes exceed the safe integer range, so the stat is taken as bigint. + */ +function identify(stats: fs.BigIntStats, filePath: string): string { + // A filesystem that reports no inode leaves the path as the only identity. + if (stats.ino === 0n) { + return `path:${filePath}`; + } + + return `ino:${stats.dev}:${stats.ino}`; +} + +function versionOf(stats: fs.BigIntStats): string { + return `${stats.size}:${stats.mtimeNs}`; +} + +function readCached(identity: string, version: string): readonly string[] | undefined { + const entry = lineCache.get(identity); + return entry?.version === version ? entry.lines : undefined; +} + +function writeCached(identity: string, version: string, lines: readonly string[]): readonly string[] { + // Re-inserting moves the entry to the end, so eviction stays least-recently-written. + lineCache.delete(identity); + lineCache.set(identity, { version, lines }); + + while (lineCache.size > MAX_CACHED_FILES) { + const oldest = lineCache.keys().next(); + if (oldest.done) { + break; + } + + lineCache.delete(oldest.value); + } + + return lines; +} function splitJsonlContent(content: string): string[] { return content.trim().split('\n').filter(line => line.length > 0); } -export async function readJsonlLines(filePath: string): Promise { +/** + * Options accepted by both readers. + */ +export interface ReadJsonlLinesOptions { + /** + * Whether to serve and populate the shared cache. Defaults to true. + * + * @remarks + * Pass false for a sweep across many transcripts. Those read each file once, + * so caching cannot hit, and retaining their lines holds arbitrarily many + * whole transcripts for the life of the process. + */ + readonly cache?: boolean; +} + +/** + * Discards every cached transcript. + * + * @remarks + * Exported for tests, which need to isolate cases that reuse a path. + */ +export function clearJsonlLineCache(): void { + lineCache.clear(); +} + +export async function readJsonlLines(filePath: string, options?: ReadJsonlLinesOptions): Promise { + if (options?.cache === false) { + return splitJsonlContent(await readFile(filePath, 'utf-8')); + } + + const stats = await stat(filePath, { bigint: true }); + const identity = identify(stats, filePath); + const version = versionOf(stats); + + const cached = readCached(identity, version); + if (cached !== undefined) { + return cached; + } + const content = await readFile(filePath, 'utf-8'); - return splitJsonlContent(content); + return writeCached(identity, version, splitJsonlContent(content)); } -export function readJsonlLinesSync(filePath: string): string[] { +export function readJsonlLinesSync(filePath: string, options?: ReadJsonlLinesOptions): readonly string[] { + if (options?.cache === false) { + return splitJsonlContent(readFileSync(filePath, 'utf-8')); + } + + const stats = statSync(filePath, { bigint: true }); + const identity = identify(stats, filePath); + const version = versionOf(stats); + + const cached = readCached(identity, version); + if (cached !== undefined) { + return cached; + } + const content = readFileSync(filePath, 'utf-8'); - return splitJsonlContent(content); + return writeCached(identity, version, splitJsonlContent(content)); } export function parseJsonlLine(line: string): unknown { diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 2321bf41..9f7aa7de 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -70,7 +70,7 @@ function collectAgentIds(value: unknown, agentIds: Set) { } } -function getReferencedSubagentIds(lines: string[]): Set { +function getReferencedSubagentIds(lines: readonly string[]): Set { const agentIds = new Set(); for (const line of lines) { @@ -329,7 +329,7 @@ function normalizeWindowSeconds(value: number | undefined): number | null { return normalized > 0 ? normalized : null; } -function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics { +function collectSpeedMetricsFromLines(lines: readonly string[], ignoreSidechain: boolean): CollectedSpeedMetrics { const requests: SpeedRequest[] = []; let lastUserTimestamp: Date | null = null;