mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 06:59:33 +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
@@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; `src/edit-history-parse.ts`, `edit-history-store.ts`, `edit-history-local-source.ts`, `web/edit-history-sync.ts`, `routes/edit-history-routes.ts`, `edit-history-ui.js`): which files a LOCAL claude session edited (main agent and subagents, labelled), each edit's diff, and images a Bash call produced. Read from the session's transcripts by byte offset, never from git. `GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId` (unified diff), `POST …/edits/older`. ⚠️ **Reads only on request:** the panel sends `?sync=1` when it opens and, while it is open on that session, after an edit-capable `transcript:tool_end` or the session's `session:idle` (debounced; `transcript:complete` never fires for interactive claude, its transcript has no `result` line). The server never syncs on its own: no hook, no timer, no second watcher; `server.ts` hands the reader to the routes and only calls `remove` (via `forgetEditHistory`), `sweep` (startup) and `flushAll` itself (wiring-guard test). The main transcript is the path the session's `TranscriptWatcher` follows (reported by the hooks), else `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's (`resolveClaudeTranscript`); a config dir set outside Codeman is found with the next hook, until then `unavailable` / `no-transcript`. ⚠️ The store holds every diff, secrets included: `dataPath('edit-history')/<id>.json`, 0600 via tmp + rename, capped per session (500 entries, 4 MiB diff text; damaged files are re-bounded on load), deleted with the session's persisted record (`forgetEditHistory`: kill, persisted-only DELETE, stale cleanup at startup and via `POST /api/cleanup-state`; a pinned killed session keeps it; the startup `sweep` removes files no session owns), blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only: thumbnails via `file-raw` inside the workspace; an image outside it has no thumbnail and opens in the file preview (attachment route, `notify: false`); no route here serves a file. A Bash image counts only when the file's mtime lies in the call's window (±2 s) and no entry already covers that file version. Remote, Docker and non-claude sessions answer `unsupported`. Tests: `test/edit-history-*.test.ts`, `test/routes/edit-history-routes.test.ts`, `test/edit-history.browser.test.ts` (browser suite).
|
||||
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
|
||||
|
||||
@@ -736,6 +736,8 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
|
||||
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at `:`. A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. "Load older" yields to the event loop between the files it parses (up to 3 × 8 MiB).
|
||||
|
||||
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
|
||||
|
||||
### Config backups
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
|
||||
@@ -141,12 +141,25 @@ describe('readClaudeEditSources', () => {
|
||||
});
|
||||
|
||||
describe('probeLocalPaths', () => {
|
||||
it('reports kind, size and mtime, expands ~/ against home, null for missing', async () => {
|
||||
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
|
||||
put('home/img.png', 'PNG!', 1_700_000_000);
|
||||
mkdirSync(join(root, 'dir'));
|
||||
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
|
||||
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
|
||||
expect(res[1]).toMatchObject({ kind: 'directory', size: 0 });
|
||||
expect(res[1]).toBeNull();
|
||||
expect(res[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('touches a path only after the bounded probe saw a file there', async () => {
|
||||
put('a.png', 'PNG!', 1_700_000_000);
|
||||
put('b.png', 'PNG!', 1_700_000_000);
|
||||
const asked: string[] = [];
|
||||
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
|
||||
asked.push(p);
|
||||
return p.endsWith('a.png') ? 'unknown' : 'file';
|
||||
});
|
||||
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
|
||||
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
|
||||
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
|
||||
});
|
||||
});
|
||||
@@ -349,6 +349,50 @@ describe('extractImagePathCandidates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
|
||||
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
|
||||
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
|
||||
const N = 30_000;
|
||||
const BUDGET_MS = 250;
|
||||
const timed = (text: string) => {
|
||||
const t = performance.now();
|
||||
const out = extractImagePathCandidates('render', text, '', CWD);
|
||||
return { out, ms: performance.now() - t };
|
||||
};
|
||||
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
|
||||
|
||||
it.each([
|
||||
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
|
||||
['a base64 blob', base64] as const,
|
||||
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
|
||||
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
|
||||
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
|
||||
['repeated =a', '=a'.repeat(N / 2)] as const,
|
||||
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
|
||||
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
|
||||
expect(ms).toBeLessThan(BUDGET_MS);
|
||||
expect(out).toContain('/work/repo/out/plot.png');
|
||||
});
|
||||
|
||||
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 139; i++) {
|
||||
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
|
||||
lines.push(
|
||||
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
|
||||
stdout: base64,
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
const t = performance.now();
|
||||
parseClaudeEditLines(lines.join('\n'));
|
||||
expect(performance.now() - t).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEditPath', () => {
|
||||
it.each([
|
||||
['/a/b.png', 'image'],
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
|
||||
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
|
||||
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
|
||||
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
|
||||
* process-wide for the rest of the run.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DEAD = '/mnt/dead-nas';
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
|
||||
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
|
||||
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
|
||||
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
|
||||
hangs(p)
|
||||
? new Promise(() => {})
|
||||
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
|
||||
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
|
||||
});
|
||||
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
|
||||
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
|
||||
function bashPair(id: string, start: number, end: number, stdout: string): string {
|
||||
const use = {
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
|
||||
};
|
||||
const res = {
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
|
||||
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
|
||||
};
|
||||
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
|
||||
}
|
||||
|
||||
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
|
||||
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let stores: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
|
||||
stores = join(dir, 'stores');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('edit history image probe on an unresponsive mount', () => {
|
||||
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
|
||||
const ok = join(dir, 'ok.png');
|
||||
writeFileSync(ok, 'PNG!');
|
||||
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
|
||||
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
|
||||
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: async (_path, requests, budget): Promise<EditSources> => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = Math.min(req?.offset ?? 0, main.length);
|
||||
const len = Math.min(budget, main.length - start);
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
|
||||
metas: {},
|
||||
};
|
||||
},
|
||||
probe: (paths) => probeLocalPaths(paths),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
|
||||
sweepStores: async () => 0,
|
||||
});
|
||||
|
||||
const first = sync.sync(session);
|
||||
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
|
||||
const second = sync.sync(session);
|
||||
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
|
||||
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
|
||||
await within(sync.remove('s1'), 1500, 'remove()');
|
||||
});
|
||||
});
|
||||
@@ -186,12 +186,16 @@ describe('GET /api/sessions/:id/edits', () => {
|
||||
['remote', { remote: { host: 'huff' } }],
|
||||
['docker', { docker: { image: 'x' } }],
|
||||
['mode', { mode: 'shell' }],
|
||||
])('answers unsupported (%s) without reading', async (reason, patch) => {
|
||||
const { app, read } = await setup();
|
||||
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
|
||||
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
|
||||
const { app, read } = await setup({ loadStore });
|
||||
Object.assign(session, patch);
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
|
||||
expect(data).toMatchObject({ state: 'unsupported', reason });
|
||||
for (const url of [URL, `${URL}?sync=1`]) {
|
||||
const data = (await app.inject({ method: 'GET', url })).json().data;
|
||||
expect(data).toEqual({ state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] });
|
||||
}
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(loadStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
|
||||
Reference in new issue
Block a user