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
+43
View File
@@ -260,6 +260,49 @@ export const graphStore: GraphStore = {
writeEmbeddings(ledger: EmbeddingsLedger): void {
writeEmbeddingsLedger(ledger);
},
/** THE DESTRUCTIVE SEAM (wave 6 session deletion): atomic full-content
* replacement per provided ledger — write a uniquely-named temp file
* beside the ledger, then rename over it (POSIX-atomic: a crash mid-
* write leaves the original ledger and an orphan .tmp-*, never a torn
* ledger). Empty arrays write an empty file — a purged ledger reads
* as "nothing yet", same as never-emitted. */
rewriteLedgers(next: {
nodes?: GraphNode[];
edges?: GraphEdge[];
events?: LedgerEvent[];
mappings?: NodeMapping[];
coverage?: CoverageEntry[];
checkpoints?: SessionCheckpoint[];
}): void {
const files = {
nodes: LEDGER_FILES.nodes,
edges: LEDGER_FILES.edges,
events: LEDGER_FILES.events,
mappings: LEDGER_FILES.mappings,
coverage: LEDGER_FILES.coverage,
checkpoints: LEDGER_FILES.sessions,
} as const;
for (const [key, records] of Object.entries(next) as [keyof typeof files, unknown[]][]) {
const file = files[key];
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp-${crypto.randomUUID().slice(0, 8)}`;
try {
// JSONL: one record per line, trailing newline — a reader
// must see exactly what appendLine would have written.
const body = records.map((record) => JSON.stringify(record)).join("\n") + (records.length > 0 ? "\n" : "");
fs.writeFileSync(tmp, body, "utf-8");
fs.renameSync(tmp, file);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* the rename either already happened or the tmp never existed */
}
throw err;
}
}
},
};
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT