mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d20208bda6
commit
7c21973c54
3 files changed
+93
-20
No files matched your search
+39
-19
@@ -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<string, CacheEntry>({ maxSize: MAX_TRANSCRIPTS });
|
||||
const inFlight = new Map<string, Promise<StoryEntriesResult>>();
|
||||
|
||||
@@ -112,8 +125,9 @@ async function load(path: string): Promise<StoryEntriesResult> {
|
||||
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<StoryEntriesResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string | null> {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user