mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
fix(edit-history): review round 1 — record-bound deletion, load-time bounds, older reads per source
- The history is deleted when the session's persisted record goes (forgetEditHistory): kill via cleanupSession (a pinned record is only demoted and keeps it), the persisted-only DELETE, and stale cleanup at startup. After remove() neither a sync in flight nor a later request writes the file again; writes are serialized. - load() re-bounds a damaged file: cursors, pending Bash calls (MAX_PENDING_BASH) and subagent labels (MAX_AGENTS, oldest unused dropped first). - Load older reads one block of every transcript, budget split between them, and only the files it asked for; a block short of budget reads its end. A line longer than the budget is skipped going back as well (floorMidLine). - Concurrent first callers share one store; a forward request folded with an older one still reads forward. - Images outside the workspace get no thumbnail (one attachment id per render); a click opens the shared preview, which registers once with notify: false. - Docs: per-edit diff cap, deletion rule, load-time bounds, thumbnails, follow triggers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
95e2272f89
commit
dcdeaab98d
17 files changed
+508
-158
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 a `transcript:complete` (debounced). 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`/`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), deleted with the session, blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only (workspace `file-raw`; outside it the attachment route with `notify: false` and its extension allowlist); 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; `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 a `transcript:complete` (debounced). 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`) 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; a pinned killed session keeps it), 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).
|
||||
|
||||
**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)
|
||||
|
||||
|
||||
@@ -1089,11 +1089,11 @@ What the Edit history panel reads (Settings → Header & Panels → Header, per-
|
||||
|
||||
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read; without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
|
||||
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each hunk is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
|
||||
|
||||
`POST /api/sessions/:id/edits/older` reads the next older block of the transcripts and answers like the list.
|
||||
`POST /api/sessions/:id/edits/older` reads the next older block of every transcript (main and subagents share the read budget) and answers like the list.
|
||||
|
||||
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted with the session and never fed into `/api/v1/search`. No route here serves a file: the panel shows images through `file-raw` and the attachment routes.
|
||||
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
|
||||
@@ -726,15 +726,15 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
|
||||
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (session deleted: `cleanupSession` with `killMux`) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile) or a `transcript:complete`, both events the existing `TranscriptWatcher` already broadcasts. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile) or a `transcript:complete`, both events the existing `TranscriptWatcher` already broadcasts. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
|
||||
|
||||
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume) is read from its end with fresh cursors; the stored entries stay.
|
||||
|
||||
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first); a first read starts that far before the end, and "Load older" reads one older block. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run.
|
||||
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first); a first read starts that far before the end, and "Load older" reads one older block of every transcript, the budget split between them. A line longer than a read's budget is skipped, going forward and going back. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run.
|
||||
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (directory 0700, so an existing store never keeps wider bits) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text and paths), oldest dropped, also on load. Each hunk is capped at `MAX_HUNK_BYTES` (64 KiB), a pending Bash command at 8 KiB and agent meta strings at 1 KiB. The store is deleted with the session, blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text and paths), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8 KiB each and `MAX_AGENTS` (501) subagent labels of at most 1 KiB per string, and `load()` drops whatever in a damaged file breaks those bounds. Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, and for every record `cleanupStaleSessions` drops at startup. After `remove()` nothing writes the file again, neither a sync in flight nor a later request (the session answers `unavailable` / `removed`). The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
|
||||
|
||||
⚠️ **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 through the confined `file-raw` route inside the workspace, and outside it through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply) plus the attachment `/raw` route, one registration per path. SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
|
||||
⚠️ **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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ header next to Attachments. It is not shown on phones.
|
||||
|
||||
Click it for a draggable window listing the active session's edits, newest first:
|
||||
|
||||
- One row per Edit, Write or MultiEdit, with `new` for a file the agent created, `edit` otherwise,
|
||||
- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
|
||||
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
|
||||
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
|
||||
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
|
||||
@@ -271,16 +271,19 @@ Click it for a draggable window listing the active session's edits, newest first
|
||||
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
|
||||
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp` or `.avif` path named in the command or its output
|
||||
counts when the file's modification time falls within that command's run. Images the agent sent
|
||||
you count as well. Click one to open it in the preview.
|
||||
- **Load older** reads further back in the conversation.
|
||||
you count as well. Images outside the workspace show no thumbnail. Click one to open it in the
|
||||
preview.
|
||||
- **Load older** reads further back in the conversation, in the main agent's transcript and the
|
||||
subagents' alike.
|
||||
|
||||
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
|
||||
|
||||
How it reads:
|
||||
|
||||
- **Only while you look.** The history is read from the session's Claude transcript when you open
|
||||
the window, and again after each edit while it stays open on that session. A closed window reads
|
||||
nothing, and Codeman does no work in the background for sessions nobody looks at.
|
||||
the window, and again after each tool call that can change files and at the end of each turn,
|
||||
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
|
||||
the background for sessions nobody looks at.
|
||||
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
|
||||
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
|
||||
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
|
||||
@@ -290,10 +293,12 @@ How it reads:
|
||||
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
|
||||
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
|
||||
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
|
||||
are dropped), never part of search, blocked from every file route, and deleted with the session.
|
||||
are dropped), never part of search, blocked from every file route, and deleted when you delete
|
||||
the session. A pinned session you kill keeps its record and its history until you unpin and
|
||||
delete it.
|
||||
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
|
||||
Viewer's route; one outside it is registered like a clicked path (no notification, same
|
||||
file-type allowlist and sensitive-file blocklist). No new route serves a file.
|
||||
Viewer's route. An image outside it opens like any clicked path outside the workspace (no
|
||||
notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
|
||||
|
||||
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
|
||||
`POST /api/sessions/:id/edits/older` (see the
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* 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.
|
||||
* 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`.
|
||||
*
|
||||
@@ -54,6 +56,11 @@ export interface EditSources {
|
||||
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. */
|
||||
@@ -135,7 +142,8 @@ export async function resolveClaudeTranscript(
|
||||
export async function readClaudeEditSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budgetBytes: number
|
||||
budgetBytes: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> {
|
||||
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
|
||||
const transcriptId = basename(mainPath, '.jsonl');
|
||||
@@ -170,11 +178,17 @@ export async function readClaudeEditSources(
|
||||
continue;
|
||||
}
|
||||
const req = byPath.get(src.relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req) {
|
||||
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(req.len ?? remaining, remaining, size - start);
|
||||
len = Math.min(remaining, size - start);
|
||||
} else {
|
||||
len = Math.min(size, remaining);
|
||||
start = size - len;
|
||||
|
||||
+145
-37
@@ -14,16 +14,19 @@
|
||||
*
|
||||
* A first read of a file starts `budget` bytes before its end (transcripts reach 100 MB+),
|
||||
* drops the cut first line and leaves `floor` there. A line longer than the budget is
|
||||
* skipped instead of blocking the cursor.
|
||||
* skipped instead of blocking the cursor, reading forward (`midLine`) and backward
|
||||
* (`floorMidLine`).
|
||||
*
|
||||
* A new conversation (`/clear`, resume) arrives as a new `transcriptId`: cursors and
|
||||
* pending Bash calls start over, entries stay. Entries are capped at {@link MAX_ENTRIES}
|
||||
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped.
|
||||
* and {@link MAX_STORE_BYTES} of diff text; the oldest by time are dropped. Besides them
|
||||
* the file holds the cursors, at most {@link MAX_PENDING_BASH} unanswered Bash calls and at
|
||||
* most {@link MAX_AGENTS} subagent labels.
|
||||
*
|
||||
* ⚠️ The file holds secrets: a diff carries whatever the agent wrote, keys and `.env`
|
||||
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename (in a 0700
|
||||
* dir), bounded, deleted with the session (`remove()`), never fed to `/api/search`, and
|
||||
* blocked from the file routes by `isSensitivePath` (`web/sensitive-path.ts`).
|
||||
* contents included. So, like `intents.json`, it is written 0600 via tmp+rename, bounded,
|
||||
* deleted with the session (`remove()`), never fed to `/api/search`, and blocked from the
|
||||
* file routes by `isSensitivePath` (`web/sensitive-path.ts`).
|
||||
*
|
||||
* Persisted as JSON under `dataPath('edit-history', '<sessionId>.json')`, written debounced.
|
||||
*
|
||||
@@ -37,6 +40,8 @@ import { join } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import {
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
parseClaudeEditLines,
|
||||
type EditRecord,
|
||||
type EditTool,
|
||||
@@ -49,6 +54,11 @@ export const MAX_ENTRIES = 500;
|
||||
export const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
/** Longest string kept from a subagent's `meta.json` field. */
|
||||
export const MAX_AGENT_META_CHARS = 1024;
|
||||
/**
|
||||
* Subagent labels kept; past it the oldest one no entry uses is dropped. One more than
|
||||
* {@link MAX_ENTRIES}, so besides the newest label there always is one.
|
||||
*/
|
||||
export const MAX_AGENTS = MAX_ENTRIES + 1;
|
||||
/** Bytes read per file and pass (first read, forward step, "older" step). */
|
||||
export const READ_BUDGET_BYTES = 8 * 1024 * 1024;
|
||||
export const MAIN_SOURCE = 'main';
|
||||
@@ -68,6 +78,8 @@ export interface SourceCursor {
|
||||
floor: number;
|
||||
/** The next forward read starts inside a line longer than the budget; skip to its end. */
|
||||
midLine?: boolean;
|
||||
/** `floor` lies inside a line longer than the budget; the next older read drops its tail. */
|
||||
floorMidLine?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMeta {
|
||||
@@ -138,12 +150,21 @@ function metaString(v: unknown): string | undefined {
|
||||
|
||||
/** Parse an `agent-<id>.meta.json` text; null when it is not an object. */
|
||||
export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
return agentMetaFrom(JSON.parse(text));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isOffset(v: unknown): v is number {
|
||||
return Number.isSafeInteger(v) && (v as number) >= 0;
|
||||
}
|
||||
|
||||
// The loaders below take the persisted file as untrusted: a hand-edited or damaged store
|
||||
// loses the broken parts instead of failing every request or stalling a cursor.
|
||||
|
||||
function agentMetaFrom(raw: unknown): AgentMeta | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const meta: AgentMeta = {
|
||||
description: metaString(raw.description) ?? '',
|
||||
@@ -156,6 +177,36 @@ export function parseAgentMeta(text: string): AgentMeta | null {
|
||||
return meta;
|
||||
}
|
||||
|
||||
function cursorFrom(raw: unknown): SourceCursor | null {
|
||||
if (!isObj(raw) || !isOffset(raw.offset) || !isOffset(raw.floor) || raw.floor > raw.offset) return null;
|
||||
const cursor: SourceCursor = { offset: raw.offset, floor: raw.floor };
|
||||
if (raw.midLine === true) cursor.midLine = true;
|
||||
if (raw.floorMidLine === true) cursor.floorMidLine = true;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function pendingBashFrom(raw: unknown): PendingBash | null {
|
||||
if (!isObj(raw)) return null;
|
||||
const { id, ts, command, cwd, agentId } = raw;
|
||||
if (typeof id !== 'string' || typeof ts !== 'number' || typeof command !== 'string' || typeof cwd !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const pending: PendingBash = { id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
|
||||
if (typeof agentId === 'string') pending.agentId = agentId;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function isEntry(raw: unknown): raw is EditHistoryEntry {
|
||||
return (
|
||||
isObj(raw) &&
|
||||
typeof raw.id === 'string' &&
|
||||
typeof raw.path === 'string' &&
|
||||
typeof raw.ts === 'number' &&
|
||||
Array.isArray(raw.hunks) &&
|
||||
raw.hunks.every((h) => isObj(h) && Array.isArray(h.lines))
|
||||
);
|
||||
}
|
||||
|
||||
export class EditHistoryStore {
|
||||
readonly sessionId: string;
|
||||
private readonly file: string;
|
||||
@@ -168,6 +219,8 @@ export class EditHistoryStore {
|
||||
/** Sum of {@link entryBytes} over `entries`. */
|
||||
private bytes = 0;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
/** The last queued write; `remove()` waits for it. */
|
||||
private writing: Promise<void> | null = null;
|
||||
private removed = false;
|
||||
|
||||
constructor(sessionId: string, opts: EditHistoryStoreOptions = {}) {
|
||||
@@ -189,17 +242,20 @@ export class EditHistoryStore {
|
||||
if (data.version !== FILE_VERSION) return store;
|
||||
const f = data as unknown as StoreFile;
|
||||
store.transcriptId = typeof f.transcriptId === 'string' ? f.transcriptId : null;
|
||||
for (const [k, c] of Object.entries(isObj(f.cursors) ? f.cursors : {})) {
|
||||
if (isObj(c) && typeof c.offset === 'number' && typeof c.floor === 'number') store.cursors.set(k, c);
|
||||
for (const [k, raw] of Object.entries(isObj(f.cursors) ? f.cursors : {})) {
|
||||
const cursor = cursorFrom(raw);
|
||||
if (cursor) store.cursors.set(k, cursor);
|
||||
}
|
||||
if (Array.isArray(f.pendingBash)) store.pendingBash = f.pendingBash;
|
||||
for (const [k, a] of Object.entries(isObj(f.agents) ? f.agents : {})) store.agents.set(k, a);
|
||||
if (Array.isArray(f.entries)) {
|
||||
const loaded = f.entries.filter(
|
||||
(e): e is EditHistoryEntry =>
|
||||
isObj(e) && typeof e.id === 'string' && typeof e.path === 'string' && Array.isArray(e.hunks)
|
||||
);
|
||||
store.addRecords(loaded);
|
||||
if (Array.isArray(f.pendingBash)) {
|
||||
store.pendingBash = f.pendingBash
|
||||
.map(pendingBashFrom)
|
||||
.filter((p): p is PendingBash => p !== null)
|
||||
.slice(-MAX_PENDING_BASH);
|
||||
}
|
||||
if (Array.isArray(f.entries)) store.addRecords(f.entries.filter(isEntry));
|
||||
for (const [k, raw] of Object.entries(isObj(f.agents) ? f.agents : {})) {
|
||||
const meta = agentMetaFrom(raw);
|
||||
if (meta) store.putAgent(k, meta);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
@@ -243,9 +299,9 @@ export class EditHistoryStore {
|
||||
|
||||
/** Record a subagent's `meta.json`; ignored when it does not parse. */
|
||||
setAgentMeta(agentId: string, metaText: string): void {
|
||||
const meta = parseAgentMeta(metaText);
|
||||
const meta = this.removed ? null : parseAgentMeta(metaText);
|
||||
if (!meta) return;
|
||||
this.agents.set(agentId, meta);
|
||||
this.putAgent(agentId, meta);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
@@ -284,6 +340,7 @@ export class EditHistoryStore {
|
||||
|
||||
/** Apply a forward chunk read at `nextRange`. Returns new entries and Bash image candidates. */
|
||||
apply(input: ApplyInput): ApplyResult {
|
||||
if (this.removed) return { added: 0, candidates: [] };
|
||||
const { transcriptId, source, startOffset, chunk, size } = input;
|
||||
if (transcriptId !== this.transcriptId) {
|
||||
this.transcriptId = transcriptId;
|
||||
@@ -303,8 +360,9 @@ export class EditHistoryStore {
|
||||
if (cursor ? cursor.midLine : startOffset > 0) {
|
||||
const nl = chunk.indexOf(NL);
|
||||
if (nl === -1) {
|
||||
// Still inside that line: move past it unless it is the line being written.
|
||||
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true);
|
||||
// Still inside that line: move past it unless it is the line being written. A first
|
||||
// read began inside it, so the floor does too.
|
||||
if (!atEof) this.setCursor(source, cursor, startOffset + chunk.length, startOffset, true, true);
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
from = nl + 1;
|
||||
@@ -328,22 +386,35 @@ export class EditHistoryStore {
|
||||
return { added, candidates: parsed.candidates };
|
||||
}
|
||||
|
||||
/** Apply a chunk read at `olderRange`: it ends at the floor, a line boundary. */
|
||||
/**
|
||||
* Apply a chunk read at `olderRange`: it ends at the floor, which is a line start, or with
|
||||
* `floorMidLine` lies inside a line longer than the budget (its part in this chunk is dropped).
|
||||
*/
|
||||
applyOlder(input: ApplyInput): ApplyResult {
|
||||
const { transcriptId, source, startOffset, chunk } = input;
|
||||
const cursor = this.cursors.get(source);
|
||||
const cursor = this.removed ? undefined : this.cursors.get(source);
|
||||
if (transcriptId !== this.transcriptId || !cursor || startOffset + chunk.length !== cursor.floor) {
|
||||
return { added: 0, candidates: [] };
|
||||
}
|
||||
const end = cursor.floorMidLine ? chunk.lastIndexOf(NL) + 1 : chunk.length;
|
||||
let from = 0;
|
||||
let insideLine = false;
|
||||
if (startOffset > 0) {
|
||||
const nl = chunk.indexOf(NL);
|
||||
// No line start in the whole block: one oversized line, skip it.
|
||||
from = nl === -1 ? chunk.length : nl + 1;
|
||||
// The newline at `end - 1` closes a line that may have started before this chunk.
|
||||
const nl = chunk.subarray(0, end).indexOf(NL);
|
||||
insideLine = nl === -1 || nl === end - 1;
|
||||
from = insideLine ? end : nl + 1;
|
||||
}
|
||||
if (insideLine) {
|
||||
// No line starts in this chunk: it lies inside one line longer than the budget. Skip it.
|
||||
cursor.floor = startOffset;
|
||||
cursor.floorMidLine = true;
|
||||
} else {
|
||||
cursor.floor = startOffset + from;
|
||||
delete cursor.floorMidLine;
|
||||
}
|
||||
cursor.floor = startOffset + from;
|
||||
// Calls whose results lie in already-read newer lines cannot be paired here.
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from).toString('utf8'));
|
||||
const parsed = parseClaudeEditLines(chunk.subarray(from, end).toString('utf8'));
|
||||
const added = this.addRecords(parsed.records);
|
||||
this.scheduleSave();
|
||||
return { added, candidates: parsed.candidates };
|
||||
@@ -351,6 +422,7 @@ export class EditHistoryStore {
|
||||
|
||||
/** Add Bash images whose mtime was confirmed inside their call's window. */
|
||||
addImageEntries(images: readonly ConfirmedImage[]): number {
|
||||
if (this.removed) return 0;
|
||||
const records: EditHistoryEntry[] = images.map((img) => {
|
||||
const entry: EditHistoryEntry = {
|
||||
id: `${img.toolUseId}:${img.path}`,
|
||||
@@ -372,12 +444,39 @@ export class EditHistoryStore {
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Write now (cancels a pending debounced write). */
|
||||
/** Write now (cancels a pending debounced write). Writes run one after another. */
|
||||
async flush(): Promise<void> {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
const run = (this.writing ?? Promise.resolve()).then(() => this.write());
|
||||
this.writing = run.catch(() => undefined);
|
||||
await run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the persisted file (session deleted) and forget the content. The store takes no
|
||||
* changes afterwards, so a read still in flight neither writes nor refills it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
this.removed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
this.entries.clear();
|
||||
this.bytes = 0;
|
||||
this.cursors.clear();
|
||||
this.pendingBash = [];
|
||||
this.agents.clear();
|
||||
// A write already under way ends first (or sees `removed`), so it cannot rename its
|
||||
// tmp file over the deleted store afterwards.
|
||||
await this.writing;
|
||||
await unlink(this.file).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
if (this.removed) return;
|
||||
const data: StoreFile = {
|
||||
version: FILE_VERSION,
|
||||
@@ -394,17 +493,24 @@ export class EditHistoryStore {
|
||||
// makes it the store, so an existing store never keeps wider bits.
|
||||
const tmp = `${this.file}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
||||
if (this.removed) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await rename(tmp, this.file);
|
||||
}
|
||||
|
||||
/** Delete the persisted file (session deleted); later changes are no longer written. */
|
||||
async remove(): Promise<void> {
|
||||
this.removed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
private putAgent(agentId: string, meta: AgentMeta): void {
|
||||
this.agents.delete(agentId);
|
||||
this.agents.set(agentId, meta);
|
||||
if (this.agents.size <= MAX_AGENTS) return;
|
||||
const used = new Set<string>();
|
||||
for (const e of this.entries.values()) if (e.agentId) used.add(e.agentId);
|
||||
for (const id of this.agents.keys()) {
|
||||
if (this.agents.size <= MAX_AGENTS) break;
|
||||
// The newest label has no entries yet: they come with its transcript.
|
||||
if (id !== agentId && !used.has(id)) this.agents.delete(id);
|
||||
}
|
||||
await unlink(this.file).catch(() => undefined);
|
||||
}
|
||||
|
||||
private setCursor(
|
||||
@@ -412,10 +518,12 @@ export class EditHistoryStore {
|
||||
cursor: SourceCursor | undefined,
|
||||
offset: number,
|
||||
firstFloor: number,
|
||||
midLine: boolean
|
||||
midLine: boolean,
|
||||
firstFloorMidLine = false
|
||||
): void {
|
||||
const next: SourceCursor = { offset, floor: cursor ? cursor.floor : firstFloor };
|
||||
if (midLine) next.midLine = true;
|
||||
if (cursor ? cursor.floorMidLine : firstFloorMidLine) next.floorMidLine = true;
|
||||
this.cursors.set(source, next);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
*
|
||||
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
|
||||
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
|
||||
* looks at. One sync per session runs at a time; a request during a run is folded into one
|
||||
* more run afterwards.
|
||||
* looks at. One sync per session runs at a time; requests during a run are folded into one
|
||||
* more forward read and/or one more older read afterwards.
|
||||
*
|
||||
* GONE. `remove()` (the session's record is deleted) marks the session gone before it deletes
|
||||
* the file: a run in flight stops after its current read, its store no longer writes, and a
|
||||
* later request reads nothing (`unavailable`, reason `removed`) instead of loading a new store.
|
||||
*
|
||||
* OLDER. "Load older" splits the budget evenly over the sources with older lines and reads
|
||||
* only those blocks, so every source moves back with each click.
|
||||
*
|
||||
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read.
|
||||
@@ -20,7 +27,7 @@
|
||||
*/
|
||||
|
||||
import type { SessionRemote } from '../types/session.js';
|
||||
import type { EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
||||
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
||||
import type { ImageCandidate } from '../edit-history-parse.js';
|
||||
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
@@ -31,6 +38,8 @@ export const IMAGE_MTIME_SLACK_MS = 2000;
|
||||
export const MAX_FORWARD_PASSES = 3;
|
||||
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
|
||||
export const NO_TRANSCRIPT = 'no-transcript';
|
||||
/** `reason` once the session's history was deleted. */
|
||||
export const REMOVED = 'removed';
|
||||
|
||||
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
|
||||
|
||||
@@ -53,7 +62,12 @@ export interface EditHistorySession {
|
||||
export interface EditHistorySyncDeps {
|
||||
/** The session's main transcript path, or null when it has none yet. */
|
||||
transcriptPath(session: EditHistorySession): Promise<string | null>;
|
||||
readSources(mainPath: string, requests: readonly EditSourceRequest[], budget: number): Promise<EditSources | null>;
|
||||
readSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
||||
budget?: number;
|
||||
@@ -109,11 +123,18 @@ export function confirmImages(
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SyncWant {
|
||||
forward: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
interface SessionSlot {
|
||||
store?: EditHistoryStore;
|
||||
store?: Promise<EditHistoryStore>;
|
||||
status: EditHistoryStatus;
|
||||
running?: Promise<void>;
|
||||
again?: { older: boolean };
|
||||
again?: SyncWant;
|
||||
/** The history was deleted: nothing reads for this session any more. */
|
||||
gone?: boolean;
|
||||
}
|
||||
|
||||
export class EditHistorySync {
|
||||
@@ -124,10 +145,16 @@ export class EditHistorySync {
|
||||
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
||||
}
|
||||
|
||||
/** The session's store, loaded from disk on first use. */
|
||||
async getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
||||
getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
const slot = this.slot(sessionId);
|
||||
if (!slot.store) slot.store = await this.deps.loadStore(sessionId);
|
||||
if (!slot.store) {
|
||||
const loading = this.deps.loadStore(sessionId);
|
||||
slot.store = loading;
|
||||
loading.catch(() => {
|
||||
if (slot.store === loading) slot.store = undefined;
|
||||
});
|
||||
}
|
||||
return slot.store;
|
||||
}
|
||||
|
||||
@@ -142,18 +169,23 @@ export class EditHistorySync {
|
||||
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
|
||||
const why = unsupportedReason(session);
|
||||
const slot = this.slot(session.id);
|
||||
if (slot.gone) return this.getStatus(session.id);
|
||||
if (why) {
|
||||
slot.status = { state: 'unsupported', reason: why };
|
||||
return this.getStatus(session.id);
|
||||
}
|
||||
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
||||
if (slot.running) {
|
||||
slot.again = { older: (slot.again?.older ?? false) || !!opts.older };
|
||||
slot.again = {
|
||||
forward: (slot.again?.forward ?? false) || want.forward,
|
||||
older: (slot.again?.older ?? false) || want.older,
|
||||
};
|
||||
await slot.running;
|
||||
// The queued run starts when the current one ends; wait for it too.
|
||||
if (slot.running) await slot.running;
|
||||
return this.getStatus(session.id);
|
||||
}
|
||||
slot.running = this.runLoop(session, !!opts.older);
|
||||
slot.running = this.runLoop(session, slot, want);
|
||||
try {
|
||||
await slot.running;
|
||||
} finally {
|
||||
@@ -164,13 +196,19 @@ export class EditHistorySync {
|
||||
|
||||
/** Write every loaded store's pending changes (server shutdown). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([...this.slots.values()].map((slot) => slot.store?.flush()));
|
||||
await Promise.all([...this.slots.values()].map(async (slot) => (await slot.store)?.flush()));
|
||||
}
|
||||
|
||||
/** Drop a deleted session: forget it and delete its stored history. */
|
||||
/**
|
||||
* Delete a session's stored history (its record is gone). The session stays marked, so a
|
||||
* run in flight or a late request cannot load and write a new store for it.
|
||||
*/
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const slot = this.slot(sessionId);
|
||||
slot.gone = true;
|
||||
slot.again = undefined;
|
||||
slot.status = { state: 'unavailable', reason: REMOVED };
|
||||
const store = await this.getStore(sessionId);
|
||||
this.slots.delete(sessionId);
|
||||
await store.remove();
|
||||
}
|
||||
|
||||
@@ -183,21 +221,21 @@ export class EditHistorySync {
|
||||
return slot;
|
||||
}
|
||||
|
||||
private async runLoop(session: EditHistorySession, older: boolean): Promise<void> {
|
||||
const slot = this.slot(session.id);
|
||||
let next: boolean | null = older;
|
||||
while (next !== null) {
|
||||
await this.runOnce(session, next);
|
||||
next = slot.again ? slot.again.older : null;
|
||||
private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> {
|
||||
let next: SyncWant | undefined = want;
|
||||
while (next && !slot.gone) {
|
||||
if (next.forward) await this.runOnce(session, slot, false);
|
||||
if (next.older && !slot.gone) await this.runOnce(session, slot, true);
|
||||
next = slot.again;
|
||||
slot.again = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async runOnce(session: EditHistorySession, older: boolean): Promise<void> {
|
||||
const slot = this.slot(session.id);
|
||||
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
|
||||
const store = await this.getStore(session.id);
|
||||
let status: EditHistoryStatus;
|
||||
try {
|
||||
const path = await this.deps.transcriptPath(session);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
@@ -205,16 +243,17 @@ export class EditHistorySync {
|
||||
found = await this.readOlder(path, store);
|
||||
} else {
|
||||
found = true;
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found; pass++) {
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
||||
const r = await this.readForward(path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
}
|
||||
slot.status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
} catch (err) {
|
||||
slot.status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!slot.gone) slot.status = status;
|
||||
}
|
||||
|
||||
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
|
||||
@@ -257,13 +296,16 @@ export class EditHistorySync {
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore): Promise<boolean> {
|
||||
if (!store.backfillTruncated) return true;
|
||||
const sources = store.sourcesWithOlder();
|
||||
if (sources.length === 0) return true;
|
||||
const share = Math.max(1, Math.floor(this.budget / sources.length));
|
||||
const requests: EditSourceRequest[] = [];
|
||||
for (const relPath of store.sourcesWithOlder()) {
|
||||
const range = store.olderRange(relPath, this.budget);
|
||||
for (const relPath of sources) {
|
||||
const range = store.olderRange(relPath, share);
|
||||
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
||||
}
|
||||
if (requests.length === 0) return true;
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
const res = await this.deps.readSources(path, requests, this.budget, { onlyRequested: true });
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface SessionPort {
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
/**
|
||||
* Delete a session's stored edit history once its record is gone. `cleanupSession` does it
|
||||
* itself; this is for removing a record without a live session (DELETE route).
|
||||
*/
|
||||
forgetEditHistory(sessionId: string): void;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
*
|
||||
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
|
||||
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
|
||||
* - An image entry shows a thumbnail and opens the shared file preview (panels-ui.js
|
||||
* `openFilePreview`), as does a click on any path. Both go through the existing file routes only:
|
||||
* inside the workspace the confined `file-raw`, outside it an id minted by the attachment route
|
||||
* with `notify: false` (`_registerExternalPreview`, which enforces the attachment guard and its
|
||||
* extension allowlist). A path from the transcript is never served by a route of its own.
|
||||
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
|
||||
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
|
||||
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
|
||||
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
|
||||
* enforces the attachment guard and its extension allowlist). A path from the transcript is
|
||||
* never served by a route of its own.
|
||||
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
|
||||
*
|
||||
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
|
||||
@@ -26,7 +27,7 @@
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
|
||||
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath, _registerExternalPreview)
|
||||
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath)
|
||||
* @dependency i18n.js (window.codemanT)
|
||||
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
|
||||
*/
|
||||
@@ -302,32 +303,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Thumbnail source for an image entry, through the routes the file preview uses: inside the
|
||||
* workspace the confined `file-raw` route (a string), outside it the attachment `/raw` route by
|
||||
* an id the attachment route mints with `notify: false` (a promise, one per path and panel
|
||||
* state, resolving to the URL or null when the attachment guard refuses the file). SVGs get none
|
||||
* (both routes serve them as a download); clicking still opens the preview.
|
||||
* Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file
|
||||
* preview uses. An image outside the workspace gets none: showing it needs an attachment id
|
||||
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
|
||||
* session's real attachments out of its capped registry. Clicking it opens the file preview,
|
||||
* which registers it like any other external file. SVGs get none either (`file-raw` serves
|
||||
* them as a download).
|
||||
*/
|
||||
_editHistoryThumbSrc(path, sid, h) {
|
||||
if (!path || /\.svg$/i.test(path)) return null;
|
||||
if (!this._isExternalPreviewPath?.(path, sid)) {
|
||||
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
if (typeof this._registerExternalPreview !== 'function') return null;
|
||||
if (!h.thumbs) h.thumbs = new Map();
|
||||
if (!h.thumbs.has(path)) {
|
||||
h.thumbs.set(
|
||||
path,
|
||||
this._registerExternalPreview(path, sid).then((r) =>
|
||||
r?.attachmentId
|
||||
? this._editHistoryUrl(
|
||||
`/api/sessions/${encodeURIComponent(sid)}/attachments/${encodeURIComponent(r.attachmentId)}/raw`
|
||||
)
|
||||
: null
|
||||
)
|
||||
);
|
||||
}
|
||||
return h.thumbs.get(path);
|
||||
_editHistoryThumbSrc(path, sid) {
|
||||
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
|
||||
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
_renderEditHistoryPanel() {
|
||||
@@ -528,7 +513,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
open();
|
||||
}
|
||||
});
|
||||
const src = this._editHistoryThumbSrc(e.path, h.sessionId, h);
|
||||
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
|
||||
if (src) {
|
||||
const img = el('img', 'edit-history-thumb');
|
||||
img.loading = 'lazy';
|
||||
@@ -538,15 +523,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
open();
|
||||
});
|
||||
img.addEventListener('error', () => img.remove());
|
||||
if (typeof src === 'string') {
|
||||
img.src = src;
|
||||
} else {
|
||||
// A re-render meanwhile replaced this element; the new one reads the same promise.
|
||||
src.then((url) => {
|
||||
if (url) img.src = url;
|
||||
else img.remove();
|
||||
});
|
||||
}
|
||||
img.src = src;
|
||||
out.push(img);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -1722,7 +1722,8 @@ export function registerSessionRoutes(
|
||||
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// The edit history goes with the record (same rule as `cleanupSession`).
|
||||
if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id);
|
||||
// Mirrors the broadcast at the tail of the live-session cleanup path
|
||||
// (_doCleanupSession in server.ts) — without it, other open tabs keep
|
||||
// showing the retired row until their next unrelated fetch.
|
||||
|
||||
+14
-8
@@ -841,6 +841,7 @@ export class WebServer extends EventEmitter {
|
||||
addSession: this.registerSessionWithLayout.bind(this),
|
||||
tabLayouts: this.tabLayouts,
|
||||
cleanupSession: this.cleanupSession.bind(this),
|
||||
forgetEditHistory: this.forgetEditHistory.bind(this),
|
||||
setupSessionListeners: this.setupSessionListeners.bind(this),
|
||||
persistSessionState: this.persistSessionState.bind(this),
|
||||
persistSessionStateNow: this._persistSessionStateNow.bind(this),
|
||||
@@ -1576,13 +1577,6 @@ export class WebServer extends EventEmitter {
|
||||
// Stop transcript watcher
|
||||
this.stopTranscriptWatcher(sessionId);
|
||||
|
||||
// Edit history: a killed session takes its stored history (diffs can hold secrets) with it
|
||||
if (killMux) {
|
||||
void this.editHistorySync.remove(sessionId).catch((err) => {
|
||||
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
|
||||
});
|
||||
}
|
||||
|
||||
// Stop and remove run summary tracker
|
||||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
@@ -1703,7 +1697,8 @@ export class WebServer extends EventEmitter {
|
||||
// Only remove from state.json if we're also killing the mux session.
|
||||
// When killMux=false (server shutdown), preserve state for recovery.
|
||||
if (killMux) {
|
||||
this.store.demoteOrRemoveSession(sessionId);
|
||||
// The edit history goes with the record; a pinned session's demoted record keeps it.
|
||||
if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved') this.forgetEditHistory(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2509,10 +2504,21 @@ export class WebServer extends EventEmitter {
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
for (const s of result.cleaned) {
|
||||
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
|
||||
this.forgetEditHistory(s.id);
|
||||
}
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted
|
||||
* live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale.
|
||||
*/
|
||||
private forgetEditHistory(sessionId: string): void {
|
||||
void this.editHistorySync.remove(sessionId).catch((err) => {
|
||||
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lightweight state for SSE init - excludes full terminal buffers
|
||||
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
||||
|
||||
@@ -120,6 +120,24 @@ describe('readClaudeEditSources', () => {
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
|
||||
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
|
||||
});
|
||||
|
||||
it('with onlyRequested, reads the requested files and nothing else', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
|
||||
onlyRequested: true,
|
||||
});
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['subagents/agent-b2.jsonl', 1, 'BB'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the end of a block that does not fit the budget', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLocalPaths', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
|
||||
* Transcript bytes are built from JSONL lines shaped like real claude records.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
entryBytes,
|
||||
MAIN_SOURCE,
|
||||
MAX_AGENT_META_CHARS,
|
||||
MAX_AGENTS,
|
||||
MAX_ENTRIES,
|
||||
MAX_STORE_BYTES,
|
||||
parseAgentMeta,
|
||||
@@ -255,6 +256,23 @@ describe('EditHistoryStore: older blocks', () => {
|
||||
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget going back and reaches the lines before it', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
|
||||
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
|
||||
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
|
||||
const file = jsonl([...older, huge, ...newer]);
|
||||
const budget = 4000;
|
||||
syncForward(store, file, budget);
|
||||
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
|
||||
expect(store.list()).toHaveLength(15);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an older chunk that does not end at the floor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
|
||||
@@ -353,6 +371,17 @@ describe('EditHistoryStore: cap', () => {
|
||||
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
|
||||
expect(meta?.agentType).toBe('coding');
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
|
||||
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
|
||||
const agents = store.getAgents();
|
||||
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
|
||||
expect(agents.a0).toBeDefined(); // used by an entry
|
||||
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
|
||||
expect(agents.a1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: persistence', () => {
|
||||
@@ -404,6 +433,33 @@ describe('EditHistoryStore: persistence', () => {
|
||||
expect(loaded.get('nohunks')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
|
||||
writeFileSync(
|
||||
join(dir, 's1.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sessionId: 's1',
|
||||
transcriptId: 'conv-1',
|
||||
cursors: {
|
||||
main: { offset: 1.5, floor: 0 },
|
||||
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
|
||||
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
|
||||
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
|
||||
},
|
||||
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
|
||||
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
|
||||
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
|
||||
})
|
||||
);
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
|
||||
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
|
||||
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
|
||||
expect(loaded.list()).toEqual([]);
|
||||
// The surviving pending call still pairs with its result, and nothing throws.
|
||||
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('ignores a file of an unknown version', async () => {
|
||||
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
|
||||
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
|
||||
@@ -436,6 +492,15 @@ describe('EditHistoryStore: persistence', () => {
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await store.flush();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
expect(store.list()).toEqual([]); // the content is forgotten too
|
||||
});
|
||||
|
||||
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const writing = store.flush();
|
||||
await Promise.all([writing, store.remove()]);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults to the data directory', () => {
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import type { EditSourceRequest, EditSources, PathProbe } from '../src/edit-history-local-source.js';
|
||||
import type {
|
||||
EditSourceReadOptions,
|
||||
EditSourceRequest,
|
||||
EditSources,
|
||||
PathProbe,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
import {
|
||||
confirmImages,
|
||||
EditHistorySync,
|
||||
NO_TRANSCRIPT,
|
||||
REMOVED,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
unsupportedReason,
|
||||
@@ -76,7 +82,8 @@ class FakeHost {
|
||||
read = async (
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number
|
||||
budget: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> => {
|
||||
this.calls.push([...requests]);
|
||||
this.paths.push(mainPath);
|
||||
@@ -85,11 +92,16 @@ class FakeHost {
|
||||
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
|
||||
for (const [relPath, buf] of this.files) {
|
||||
const req = requests.find((r) => r.relPath === relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req) {
|
||||
if (req?.len !== undefined) {
|
||||
const blockEnd = Math.min(req.offset + req.len, buf.length);
|
||||
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
|
||||
start = blockEnd - len;
|
||||
} else if (req) {
|
||||
start = Math.min(req.offset, buf.length);
|
||||
len = Math.min(req.len ?? remaining, remaining, buf.length - start);
|
||||
len = Math.min(remaining, buf.length - start);
|
||||
} else {
|
||||
len = Math.min(buf.length, remaining);
|
||||
start = buf.length - len;
|
||||
@@ -115,7 +127,7 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (path, req, budget) => host.read(path, req, budget),
|
||||
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
|
||||
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
...over,
|
||||
@@ -293,6 +305,83 @@ describe('EditHistorySync', () => {
|
||||
await sync.remove('s1');
|
||||
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
|
||||
});
|
||||
|
||||
it('a deleted session stays deleted: neither a run in flight nor a later request writes it again', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
|
||||
const removing = sync.remove('s1');
|
||||
release();
|
||||
await Promise.all([...runs, removing]);
|
||||
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
const reads = host.calls.length;
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(host.calls).toHaveLength(reads);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
expect((await sync.getStore('s1')).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('concurrent first callers share one store', async () => {
|
||||
const { sync } = setup();
|
||||
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('a forward request queued together with an older one still reads forward', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
const res = await orig(...a); // the first run's bytes are taken before the append
|
||||
await gate;
|
||||
return res;
|
||||
};
|
||||
const first = sync.sync(local);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
|
||||
const queued = [sync.sync(local), sync.sync(local, { older: true })];
|
||||
release();
|
||||
await Promise.all([first, ...queued]);
|
||||
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('load older moves every source back, reading only the blocks it asked for', async () => {
|
||||
const { host, sync } = setup({ budget: 2000 });
|
||||
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
|
||||
}
|
||||
await sync.sync(local);
|
||||
await sync.sync(local); // the subagent gets its first block once main is up to date
|
||||
const store = await sync.getStore('s1');
|
||||
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
|
||||
const [main0, sub0] = floors();
|
||||
expect(main0).toBeGreaterThan(0);
|
||||
expect(sub0).toBeGreaterThan(0);
|
||||
await sync.sync(local, { older: true });
|
||||
const [main1, sub1] = floors();
|
||||
expect(main1).toBeLessThan(main0);
|
||||
expect(sub1).toBeLessThan(sub0);
|
||||
expect(
|
||||
host.calls
|
||||
.at(-1)!
|
||||
.map((r) => r.relPath)
|
||||
.sort()
|
||||
).toEqual(['main', 'subagents/agent-ab.jsonl']);
|
||||
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.list()).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmImages', () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('Edit history panel in a real browser', () => {
|
||||
let sessionA: string;
|
||||
let sessionB: string;
|
||||
const settingsPutStatuses: number[] = [];
|
||||
/** Bodies of POST /api/sessions/:id/attachments (the external thumbnail registration). */
|
||||
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
|
||||
const attachmentPosts: unknown[] = [];
|
||||
/** Every request to the mocked API: `METHOD url`. */
|
||||
const calls: string[] = [];
|
||||
@@ -253,8 +253,10 @@ describe('Edit history panel in a real browser', () => {
|
||||
|
||||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__previewed = [];
|
||||
(window as any).app.openFilePreview = (p: string, sid: string) => (window as any).__previewed.push([p, sid]);
|
||||
const w = window as any;
|
||||
w.__previewed = [];
|
||||
w.__openFilePreview = w.app.openFilePreview;
|
||||
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
|
||||
});
|
||||
await rows().nth(2).locator('.edit-history-path').click();
|
||||
await page.locator('#editHistoryBody img.edit-history-thumb').click();
|
||||
@@ -264,6 +266,10 @@ describe('Edit history panel in a real browser', () => {
|
||||
]);
|
||||
// Clicking the path never toggles the row's diff.
|
||||
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
w.app.openFilePreview = w.__openFilePreview;
|
||||
});
|
||||
});
|
||||
|
||||
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
|
||||
@@ -338,7 +344,7 @@ describe('Edit history panel in a real browser', () => {
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('an image outside the workspace gets its thumbnail through the attachment route, without a notification', async () => {
|
||||
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const outside = join(ext, 'render.png');
|
||||
state.set(sessionA, {
|
||||
@@ -349,18 +355,21 @@ describe('Edit history panel in a real browser', () => {
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb').first();
|
||||
await page.waitForFunction(() =>
|
||||
/\/attachments\/[^/]+\/raw$/.test(document.querySelector('img.edit-history-thumb')?.getAttribute('src') ?? '')
|
||||
);
|
||||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||||
expect(await thumb.getAttribute('src')).toMatch(new RegExp(`^/api/sessions/${sessionA}/attachments/[^/]+/raw$`));
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
// A re-read reuses the registration instead of minting another id.
|
||||
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
|
||||
await row.waitFor();
|
||||
// No thumbnail for it (that would mint an attachment id per render), and no registration.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
|
||||
)
|
||||
).not.toContainEqual(expect.stringContaining('render.png'));
|
||||
await reload();
|
||||
expect(attachmentPosts).toHaveLength(1);
|
||||
expect(attachmentPosts).toEqual([]);
|
||||
// A click opens the shared preview, which registers the file once, without a notification.
|
||||
await row.click();
|
||||
await page.waitForSelector('#filePreviewOverlay.visible');
|
||||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||||
await page.evaluate(() => (window as any).app.closeFilePreview());
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createMockRouteContext(options?: {
|
||||
forgetCaseDefaultGroup: vi.fn(async () => {}),
|
||||
},
|
||||
cleanupSession: vi.fn(async () => {}),
|
||||
forgetEditHistory: vi.fn(),
|
||||
setupSessionListeners: vi.fn(async () => {}),
|
||||
persistSessionState: vi.fn(),
|
||||
persistSessionStateNow: vi.fn(),
|
||||
|
||||
@@ -983,6 +983,16 @@ describe('session-routes', () => {
|
||||
// with no broadcast, so other open tabs kept showing the retired row until
|
||||
// their next unrelated fetch.
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
|
||||
// Its stored edit history (diffs can hold secrets) goes with the record.
|
||||
expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session');
|
||||
});
|
||||
|
||||
it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => {
|
||||
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never);
|
||||
vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved');
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s a persisted-only session id the state store does not recognize either', async () => {
|
||||
|
||||
Reference in new issue
Block a user