mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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>
This commit is contained in:
1 parent
bf2a705705
commit
de43657676
10 files changed
+229
-35
No files matched your search
@@ -25,6 +25,7 @@
|
||||
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._-]+$/;
|
||||
@@ -207,26 +208,35 @@ export async function readClaudeEditSources(
|
||||
return { transcriptId, files, metas };
|
||||
}
|
||||
|
||||
/** Per path its resolved path, kind, size and mtime, or null when it does not exist. `~/` means this user's home. */
|
||||
/**
|
||||
* 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()
|
||||
home: string = homedir(),
|
||||
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
|
||||
): Promise<Array<PathProbe | null>> {
|
||||
return Promise.all(
|
||||
paths.map(async (p) => {
|
||||
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
||||
try {
|
||||
const real = await realpath(path);
|
||||
const s = await stat(real);
|
||||
return {
|
||||
realPath: real,
|
||||
kind: s.isFile() ? 'file' : s.isDirectory() ? 'directory' : 'other',
|
||||
size: s.isFile() ? s.size : 0,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
} satisfies PathProbe;
|
||||
} catch {
|
||||
return 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;
|
||||
}
|
||||
@@ -104,13 +104,19 @@ const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
|
||||
const PATH_TOKEN_RE = new RegExp(
|
||||
[
|
||||
// 1. file:// URL (Codex: `Saved to: file:///…`) — may contain spaces up to the extension.
|
||||
`file://(/[^\\n]*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// No `:` inside: it keeps the lazy scan from running to the line end from every
|
||||
// `file://` of a line that repeats it (quadratic otherwise).
|
||||
`file://(/[^\\n:]*?\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 2./3. Quoted paths, spaces allowed.
|
||||
`"([^"\\n]*\\.${IMAGE_EXT})"`,
|
||||
`'([^'\\n]*\\.${IMAGE_EXT})'`,
|
||||
// 4. Unquoted token. The lookbehind keeps a match from starting inside a URL, a glob
|
||||
// or a longer name; the name before the extension must not be empty.
|
||||
`(?<![\\w:/.~*?$-])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
|
||||
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
|
||||
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
|
||||
// some characters let a match start at every other character of a long token
|
||||
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
|
||||
// length. The name before the extension must not be empty.
|
||||
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
|
||||
].join('|'),
|
||||
'gi'
|
||||
);
|
||||
|
||||
@@ -436,8 +436,14 @@ export class EditHistorySync {
|
||||
this.dropMissing(store, requests, res);
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
if (!wanted.has(f.relPath)) continue;
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: let the server breathe
|
||||
// between them. A store removed meanwhile ignores the rest (it is inert once removed).
|
||||
if (parsed) await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
parsed = true;
|
||||
if (slot.gone) break;
|
||||
const r = store.applyOlder({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
|
||||
@@ -105,13 +105,16 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
const { sync: doSync } = req.query as { sync?: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
// Never read: answer an empty list without loading (or creating) a store for it.
|
||||
if (why) {
|
||||
return {
|
||||
success: true,
|
||||
data: { state: 'unsupported', reason: why, backfillTruncated: false, agents: {}, entries: [] },
|
||||
};
|
||||
}
|
||||
// The sync starts in the same tick as the session check: a deletion right after it marks
|
||||
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
|
||||
const status: EditHistoryStatus = why
|
||||
? { state: 'unsupported', reason: why }
|
||||
: doSync === '1'
|
||||
? await sync.sync(view)
|
||||
: sync.getStatus(session.id);
|
||||
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user