Files
Codeman/src/edit-history-local-source.ts
T
RandalixandClaude Opus 5.5 de43657676 fix(edit-history): bounded image probe, linear path scan (review of #693)
- probeLocalPaths: every agent-printed candidate goes through
  probePathKind() first (no pastCap, bulk scan), sequentially; only a path
  that answered 'file' gets realpath + stat. A path on a mount that stopped
  answering no longer hangs the sync, a queued sync or remove().
- PATH_TOKEN_RE: branch 4 starts only at a token boundary (`:` excluded so
  URLs stay ignored), branch 1 stops at `:`. Linear in the line length
  (30,000 `#` took ~3 s before).
- Load older yields to the event loop between the files it parses.
- GET /edits answers unsupported without loading a store.
- CLAUDE.md paragraph trimmed to the rules; probe and regex rules added to
  architecture-invariants#edit-history-panel.

Tests: stall test through the real sync and probe with a stat that never
settles; time-budget tests with 30,000-character inputs and a 139-result
base64 chunk; route test asserts no store load for unsupported sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:46:23 +01:00

243 lines
8.8 KiB
TypeScript

/**
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
* transcript and its subagent transcripts, each from a byte offset.
*
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
* hooks report, so a session is read from wherever its claude really writes. Before the first
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
* guessed; it is found with the next hook. Subagents live in
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
*
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
* report their size with an empty chunk. A request with `len` is a block that must end at
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
* With `onlyRequested` ("older" reads) unrequested files are left out.
*
* Tests: `test/edit-history-local-source.test.ts`.
*
* @module edit-history-local-source
*/
import { open, readdir, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join } from 'node:path';
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
import { claudeProjectsDir } from './utils/claude-transcript.js';
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
const META_MAX_BYTES = 8192;
/** One file to read from where the last read stopped (`len` only for an "older" block). */
export interface EditSourceRequest {
/** `main` or `subagents/agent-<id>.jsonl`. */
relPath: string;
offset: number;
len?: number;
}
export interface EditSourceFile {
relPath: string;
/** The file's size when it was read. */
size: number;
startOffset: number;
chunk: Buffer;
}
export interface EditSources {
/** The conversation id: the main transcript's file name without `.jsonl`. */
transcriptId: string;
files: EditSourceFile[];
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
metas: Record<string, string>;
}
export interface EditSourceReadOptions {
/** Read only the requested files; the others are not listed. */
onlyRequested?: boolean;
}
/** What a path turned out to be (for Bash image candidates). */
export interface PathProbe {
/** The path with symlinks resolved. */
realPath: string;
kind: 'file' | 'directory' | 'other';
/** Size in bytes (0 for anything that is not a regular file). */
size: number;
mtimeMs: number;
}
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
if (len <= 0) return Buffer.alloc(0);
const fh = await open(path, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, offset);
return buf.subarray(0, bytesRead);
} catch {
return Buffer.alloc(0);
} finally {
await fh.close();
}
}
async function fileSize(path: string): Promise<number | null> {
try {
const s = await stat(path);
return s.isFile() ? s.size : null;
} catch {
return null;
}
}
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
export async function findClaudeTranscript(
ids: readonly string[],
projectsDir: string = claudeProjectsDir()
): Promise<string | null> {
let dirs: string[];
try {
dirs = await readdir(projectsDir);
} catch {
return null;
}
for (const id of new Set(ids)) {
if (!ID_PATTERN.test(id)) continue;
let best: { path: string; mtime: number } | null = null;
for (const dir of dirs) {
const path = join(projectsDir, dir, `${id}.jsonl`);
try {
const s = await stat(path);
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
} catch {
// not in this project dir
}
}
if (best) return best.path;
}
return null;
}
/**
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
* override; the server's config dir when unset). Null when neither finds one.
*/
export async function resolveClaudeTranscript(
watcherPath: string | null | undefined,
ids: readonly string[],
configDir?: string
): Promise<string | null> {
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
}
/**
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
* is not an absolute `<id>.jsonl` path or the file does not exist.
*/
export async function readClaudeEditSources(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
const transcriptId = basename(mainPath, '.jsonl');
if (!ID_PATTERN.test(transcriptId)) return null;
const byPath = new Map<string, EditSourceRequest>();
for (const r of requests) {
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
byPath.set(r.relPath, r);
}
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
try {
for (const name of (await readdir(subDir)).sort()) {
const m = AGENT_FILE_PATTERN.exec(name);
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
}
} catch {
// no subagents yet
}
let remaining = Math.max(0, Math.floor(budgetBytes));
const files: EditSourceFile[] = [];
const metas: Record<string, string> = {};
for (const src of sources) {
const size = await fileSize(src.path);
if (size === null) {
if (src.relPath === 'main') return null;
continue;
}
const req = byPath.get(src.relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req?.len !== undefined) {
const blockStart = Math.min(req.offset, size);
const blockEnd = Math.min(req.offset + req.len, size);
len = Math.min(blockEnd - blockStart, remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, size);
len = Math.min(remaining, size - start);
} else {
len = Math.min(size, remaining);
start = size - len;
}
const chunk = await readRange(src.path, start, len);
remaining -= chunk.length;
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
if (!req && src.agentId) {
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
const metaSize = await fileSize(metaPath);
if (metaSize !== null) {
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
}
}
}
return { transcriptId, files, metas };
}
/**
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
*
* The paths come from Bash output, so the agent chose them: each one first goes through the
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
* Sequential, so one sync holds at most one threadpool worker.
*/
export async function probeLocalPaths(
paths: readonly string[],
home: string = homedir(),
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
): Promise<Array<PathProbe | null>> {
const out: Array<PathProbe | null> = [];
for (const p of paths) {
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
out.push(null);
continue;
}
try {
const real = await realpath(path);
const s = await stat(real);
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
} catch {
out.push(null);
}
}
return out;
}