mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39: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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user