From 7c21973c54466548c00cb9f6b26aec7bb3962b93 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:31:40 +0200 Subject: [PATCH] perf(story-mode): 304 for an unchanged page, and a longer read for a turn bigger than the tail GET /story now sends a weak ETag (server start, transcript size and mtime, turn) with Cache-Control: no-cache, and answers 304 before building anything when the transcript has not changed, so a live view's polls cost a stat. A turn can be bigger than the 8 MB tail on its own (tool results carry whole files, ~60 KB per result line on large local transcripts), which cut its first panels. When the window holds no prompt, the reader re-reads a 24 MB, then a 64 MB tail. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-reader.ts | 58 +++++++++++++++++++++----------- src/web/routes/story-routes.ts | 18 +++++++++- test/routes/story-routes.test.ts | 37 ++++++++++++++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/story-reader.ts b/src/story-reader.ts index c6fa1859..96859127 100644 --- a/src/story-reader.ts +++ b/src/story-reader.ts @@ -3,8 +3,9 @@ * * Reads the TAIL of a Claude Code transcript, never the whole file: transcripts reach tens of MB * (Write/Edit results carry whole files). The first read takes the last `STORY_TAIL_BYTES` and drops - * the partial first line; later reads, while the file only grows, read just the appended bytes. A line - * that is still being written (no newline yet) is left for the next read. + * the partial first line, and takes a bigger tail (up to 64 MB) only when that window holds no prompt; + * later reads, while the file only grows, read just the appended bytes. A line that is still being + * written (no newline yet) is left for the next read. * * Each transcript's reduced entries are cached (`createStoryReducer()` already dropped file contents, * images and long outputs), bounded per transcript by entry count and character budget, and across @@ -21,6 +22,12 @@ import { createStoryReducer, type StoryEntry, type StoryReducer } from './story- /** How much of the file's end a first read takes. */ export const STORY_TAIL_BYTES = 8 * 1024 * 1024; +/** + * Larger tails, tried in order only while the window holds no prompt at all: one long turn can be + * bigger than 8 MB on its own (tool results carry whole files; measured ~60 KB per result line on + * large local transcripts), and its page would then lose its first panels. + */ +const TAIL_STEPS = [STORY_TAIL_BYTES, 24 * 1024 * 1024, 64 * 1024 * 1024]; /** Entries kept per transcript (the oldest go first). */ const MAX_ENTRIES = 20_000; /** Approximate characters kept per transcript across all entries. */ @@ -43,8 +50,14 @@ interface CacheEntry { export interface StoryEntriesResult { entries: StoryEntry[]; partial: boolean; + /** Changes whenever the file does (size and mtime); the route builds its ETag from it. */ + version: string; } +const versionOf = (size: number, mtimeMs: number) => `${size}-${Math.trunc(mtimeMs)}`; +/** A turn starts inside the window: a prompt that was not queued into a running turn. */ +const hasTurnStart = (entries: StoryEntry[]) => entries.some((e) => e.k === 'prompt' && !e.queued); + const cache = new LRUMap({ maxSize: MAX_TRANSCRIPTS }); const inFlight = new Map>(); @@ -112,8 +125,9 @@ async function load(path: string): Promise { const info = await stat(path); if (!info.isFile()) throw new Error('Transcript is not a regular file'); const cached = cache.get(path); + const version = versionOf(info.size, info.mtimeMs); if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { - return { entries: cached.entries, partial: cached.partial }; + return { entries: cached.entries, partial: cached.partial, version }; } if ( cached && @@ -127,23 +141,29 @@ async function load(path: string): Promise { cached.size = info.size; cached.mtimeMs = info.mtimeMs; cache.set(path, cached); - return { entries: cached.entries, partial: cached.partial }; + return { entries: cached.entries, partial: cached.partial, version }; } - // First read, a rewrite, or more appended than a tail holds: start over from the tail. - const start = Math.max(0, info.size - STORY_TAIL_BYTES); - const fresh: CacheEntry = { - size: info.size, - mtimeMs: info.mtimeMs, - offset: start, - entries: [], - chars: 0, - reducer: createStoryReducer(), - partial: start > 0, - }; - const buf = await readRange(path, start, info.size); - ingest(fresh, buf, start > 0); - cache.set(path, fresh); - return { entries: fresh.entries, partial: fresh.partial }; + // First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a + // bigger one while the window has no turn start in it. Each step re-reads from scratch with a + // fresh reducer, because a result can only be read once its tool call has been seen. + let fresh: CacheEntry | null = null; + for (const tail of TAIL_STEPS) { + const start = Math.max(0, info.size - tail); + fresh = { + size: info.size, + mtimeMs: info.mtimeMs, + offset: start, + entries: [], + chars: 0, + reducer: createStoryReducer(), + partial: start > 0, + }; + ingest(fresh, await readRange(path, start, info.size), start > 0); + if (start === 0 || hasTurnStart(fresh.entries)) break; + } + const done = fresh as CacheEntry; + cache.set(path, done); + return { entries: done.entries, partial: done.partial, version }; } /** diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index db810966..9ccacd43 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -23,6 +23,12 @@ import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; import { findSessionOrFail } from '../route-helpers.js'; import type { ConfigPort, SessionPort } from '../ports/index.js'; +/** + * Part of every ETag, so a server restart (possibly with a new page format) never answers 304 for a + * page an older server built. + */ +const SERVER_EPOCH = Date.now().toString(36); + /** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */ async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise { if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath; @@ -59,13 +65,23 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } }; let entries; + let version; try { - ({ entries } = await readStoryEntries(transcriptPath)); + ({ entries, version } = await readStoryEntries(transcriptPath)); } catch { reply.code(500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript'); } + // A live view refetches every few seconds; while the transcript has not changed, answer 304 + // before building anything. `no-cache` makes the browser revalidate every time. + const etag = `W/"story-${SERVER_EPOCH}-${version}-${encodeURIComponent(typeof turn === 'string' && turn ? turn : 'latest')}"`; + reply.header('ETag', etag).header('Cache-Control', 'no-cache'); + if (req.headers['if-none-match'] === etag) { + reply.code(304); + return reply.send(); + } + const turns = buildStoryTurns(entries); const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1]; return { diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index 045b931a..681d6a24 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -116,6 +116,43 @@ describe('GET /api/sessions/:id/story', () => { expect(res.statusCode).toBe(404); }); + it('answers 304 to a matching If-None-Match until the transcript changes', async () => { + writeFileSync(transcript, jsonl(demoTurn())); + const { app } = await setup(); + const first = await app.inject({ method: 'GET', url: URL }); + const etag = first.headers.etag as string; + expect(etag).toMatch(/^W\/"story-/); + expect(first.headers['cache-control']).toBe('no-cache'); + const again = await app.inject({ method: 'GET', url: URL, headers: { 'if-none-match': etag } }); + expect(again.statusCode).toBe(304); + expect(again.body).toBe(''); + appendFileSync(transcript, jsonl([prompt('next job')])); + const changed = await app.inject({ method: 'GET', url: URL, headers: { 'if-none-match': etag } }); + expect(changed.statusCode).toBe(200); + expect(changed.headers.etag).not.toBe(etag); + // Another turn of the same file is another page, so another tag. + const other = await app.inject({ + method: 'GET', + url: `${URL}?turn=x`, + headers: { 'if-none-match': changed.headers.etag as string }, + }); + expect(other.statusCode).toBe(200); + }); + + it('reads further back when a long turn does not fit the first tail', async () => { + // A prompt, then ~10 MB of tool results (Read results carry whole files), then the turn's end. + const big = 'x'.repeat(100_000); + const lines: unknown[] = [prompt('a turn longer than eight megabytes')]; + for (let i = 0; i < 100; i++) lines.push(...bash(`b${i}`, `cat big${i}.txt`, `Show file ${i}`, big)); + lines.push(end(60_000)); + writeFileSync(transcript, jsonl(lines)); + const { app } = await setup(); + const page = (await app.inject({ method: 'GET', url: URL })).json().data.page; + expect(page).toMatchObject({ prompt: 'a turn longer than eight megabytes', partial: false, complete: true }); + expect(page.toolCalls).toBe(100); + expect(page.panels[0].kind).toBe('mission'); + }); + it('a live turn grows as the transcript grows', async () => { writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')])); const { app } = await setup();