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:
RandalixandClaude Opus 5.5 committed 2026-10-23 14:30:05 +02:00
1 parent 95e2272f89
commit dcdeaab98d
17 files changed
+508 -158

No files matched your search

+18 -4
View File
@@ -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
View File
@@ -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);
}
+71 -29
View File
@@ -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));
+5
View File
@@ -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;
+18 -41
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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.