feat(graph): two-mode session deletion — the first destructive ledger operation

DELETE /api/chats/[id]?mode=preserve-graph|delete-nodes (mode required):
- preserve-graph: only extraction bookkeeping dies (coverage rows,
  legacy checkpoints) — graph ledgers byte-untouched
- delete-nodes: the chat's ledger traces die. Node survival = surviving
  provenance point / mapping line (notes imports carry empty
  provenance) / surviving event. The creator's aliases + provenance are
  stripped from a surviving node's create line (owner ruling
  2026-09-23: name/statement/createdAt are the shared identity);
  orphan nodes, their edges, events, mappings, positions, embedding
  vectors and cluster members die; cluster labels stay (immutable)

Gates: 404 unknown, 409 turn_in_flight / jobs_running (with jobIds),
503 extraction_winding_down after a 10s abort-and-poll; the purge is
one sync block (ledgers → jobs → chat record last). rewriteLedgers is
atomic (tmp + rename). graphRevision (max mtime over graph data files)
rides the activity feed + the response as the change stamp.

Anti-resurrection: the chat pipeline re-checks chat liveness at each
merge — a run that slips past the wind-down aborts with nothing merged
or checkpointed; its partial spend dies with the chat.
This commit is contained in:
amosblomqvist
2026-09-23 02:41:04 +03:00
parent ff3700a2d0
commit a6338511d3
13 changed files with 856 additions and 5 deletions
+32
View File
@@ -1,3 +1,5 @@
import * as fs from "node:fs";
import { EMBEDDINGS_FILE, LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
import type { GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
@@ -35,3 +37,33 @@ export function graphCosts(): GraphCostSummary {
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources, naming: stores.graphStore.readNamingRuns() });
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
* across every graph data file — ledgers, positions, embeddings. Moves
* on ANY graph write (merge append, settle, embed, or a deletion's
* atomic rewrite), never moves on reads, and needs no bookkeeping of
* its own: the fs IS the counter. Consumers poll it (the activity feed
* carries it; the DELETE response returns it) to know clusters/layout
* must refetch — the "graph changed underneath me" signal. */
export function graphRevision(): number {
let revision = 0;
for (const file of [
LEDGER_FILES.nodes,
LEDGER_FILES.edges,
LEDGER_FILES.events,
LEDGER_FILES.mappings,
LEDGER_FILES.sessions,
LEDGER_FILES.coverage,
LEDGER_FILES.clusterLabels,
LEDGER_FILES.namingRuns,
POSITIONS_FILE,
EMBEDDINGS_FILE,
]) {
try {
revision = Math.max(revision, fs.statSync(file).mtimeMs);
} catch {
/* absent — nothing has ever written it */
}
}
return revision;
}