From 2d133f113313c49c9993f8a4ab0a5866cd276666 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 16:28:24 +0300 Subject: [PATCH] Single-node deletion: full death by node id (node interlude, owner-locked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remedy for undeletable nodes (preserve-graph orphans from session deletion; notes-import nodes with no chat behind them): DELETE /api/graph/nodes/[id], full-death semantics — the user pointing at the node IS the intent; references from other sessions do NOT preserve it. - core/graph-deletion.ts deleteNodeTraces: the node's every line, every edge touching it, every quiz/observation event naming it, its mapping lines, and its resting position — one synchronous atomic rewriteLedgers call; only the ledgers that changed rewrite (mtimes stay honest). Blast radius is exactly the rows naming the node id; notes source records stay (deleting a proposition is not deleting the file). Derived data self-heals on read: the birth cluster shrinks below MIN_CLUSTER_SIZE and stops rendering. - nodeDeletionImpact (GET /api/graph/nodes/[id]): the confirm dialog's pre-flight facts — edge count, quiz/observation history, referencing sessions. The snapshot's edges + nodeSessions cover part of this, but the raw event history was nowhere on the client — hence the read. - Guards (server/graph.ts deleteGraphNode), decided + documented: NO turn/jobs gates (they never write graph ledgers; the orchestration is synchronous end to end, nothing interleaves). THE EXTRACTION GATE: 409 while ANY pipeline run is registered — a mid-flight run holds pre-deletion findings and could re-reference the dying node. The run is never aborted (its spend belongs to another session — outside this deletion's blast radius; the UI retries once the dock drains. A run started after the deletion is legitimate re-learning: fresh ledgers, fresh node id, and mergeFindings' existingIds guard ignores linkTo ids that no longer exist — no dangling edges, no resurrection. Live-verified on dev data: impact read, full-death stats, honest 404 on repeat, and the file cluster fell 3→2 and unrendered itself. 859/859 tests, typecheck/lint/build green. EOF ) --- app/api/graph/nodes/[id]/route.ts | 42 +++++++ src/core/graph-deletion.ts | 122 +++++++++++++++++++- src/server/graph.ts | 63 +++++++++++ tests/graph.test.ts | 180 ++++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 app/api/graph/nodes/[id]/route.ts diff --git a/app/api/graph/nodes/[id]/route.ts b/app/api/graph/nodes/[id]/route.ts new file mode 100644 index 0000000..a32581b --- /dev/null +++ b/app/api/graph/nodes/[id]/route.ts @@ -0,0 +1,42 @@ +import { deleteGraphNode, graphNodeImpact } from "@/src/server"; + +/** + * GET /api/graph/nodes/[id] — the deletion confirm dialog's pre-flight + * read (node interlude, owner-locked full-death semantics): what deleting + * this node would take with it. Edges touching it, its quiz/observation + * history, and the sessions that reference it — the facts the user weighs + * before pointing a destructive operation at the graph. 404 when the node + * does not exist. + */ +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const impact = graphNodeImpact(id); + if (impact === null) return Response.json({ error: "No such node" }, { status: 404 }); + return Response.json(impact); +} + +/** + * DELETE /api/graph/nodes/[id] — single-node deletion, FULL DEATH (owner + * ruling 2026-09-23: the user pointing at the node is intent — references + * from other sessions do not preserve it). The node's every line, every + * edge touching it, every quiz/observation event naming it, its mapping + * lines, and its position die in one atomic ledger rewrite + * (core/graph-deletion.ts deleteNodeTraces); derived data (birth + * partition, clusters) self-heals on read. + * + * Guards (server/graph.ts deleteGraphNode): 409 while ANY extraction run + * is registered — a mid-flight run holds pre-deletion findings and could + * re-reference the dying node; the run is never aborted (its spend belongs + * to another session), the UI retries once the dock drains. No turn/jobs + * gates: they never write the graph ledgers, and this orchestration is + * synchronous end to end — nothing interleaves. The response carries what + * was removed plus the new graphRevision (the refetch signal). + */ +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const outcome = deleteGraphNode(id); + if (!outcome.ok) { + return Response.json({ error: outcome.error, code: outcome.code }, { status: outcome.status }); + } + return Response.json({ stats: outcome.stats, graphRevision: outcome.graphRevision }); +} \ No newline at end of file diff --git a/src/core/graph-deletion.ts b/src/core/graph-deletion.ts index 71fd1b9..4a85518 100644 --- a/src/core/graph-deletion.ts +++ b/src/core/graph-deletion.ts @@ -250,4 +250,124 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C } return stats; -} \ No newline at end of file +} +// ─── single-node deletion (node interlude, owner-locked full-death) ─────────── + +/** + * FULL DEATH for one node, by id (owner ruling 2026-09-23): the user + * pointing at the node IS the intent — references from other sessions do + * NOT preserve it. The remedy for undeletable nodes (preserve-graph + * orphans from session deletion; notes-import nodes with no chat behind + * them), and the deliberate inverse of the chat cascade above: that one + * deletes a SESSION and lets nodes survive on foreign references; this + * one deletes a NODE and takes its every trace — + * + * - every line of the node (create + link lines) + * - every EDGE touching it (from or to), whatever session asserted it + * - every EVENT naming it (quiz outcomes + observations), from every + * session — the evidence history dies with the thing it was about + * - every mapping line that anchored it (the audit trail; for a + * notes-born node the file IS the anchor — its mapping line is the + * only one) + * - its resting position + * + * The blast radius is exactly the rows naming the node id: other nodes' + * provenance, aliases, and sessions are untouched (full death is + * NODE-scoped, not session-scoped), and the source records of notes + * imports stay (the import's bookkeeping; deleting one proposition is not + * deleting the file). Derived data needs no pruning: the birth partition + * derives on read (the cluster shrinks below MIN_CLUSTER_SIZE and stops + * rendering — fine), and no other consumer indexes nodes by id across + * ledgers. + * + * Synchronous, one rewriteLedgers call, no awaits (the same atomicity + * argument as the chat cascade); idempotent in the wave-6 sense — a + * second run finds no lines and changes nothing. + */ +export interface NodeDeletionStats { + /** Edge assertion LINES removed (every edge touching the node). */ + edgesRemoved: number; + /** Events removed (quiz outcomes + observations naming the node). */ + eventsRemoved: number; + /** Mapping lines removed (the node's audit trail). */ + mappingsRemoved: number; +} + +/** The confirm dialog's pre-flight facts (the GET on the same node id): + * what full death would take with it. sessions is the deduped set of + * chats/notes sources that reference the node — provenance points, + * mapping lines, and events all count. */ +export interface NodeDeletionImpact { + edges: number; + quizEvents: number; + observations: number; + sessions: string[]; +} + +/** A node exists iff a ledger line carries its id. */ +function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] { + return graphStore.readNodes().filter((line) => line.id === nodeId); +} + +/** The impact facts for one node, or null when no such node exists. */ +export function nodeDeletionImpact(graphStore: GraphStore, nodeId: string): NodeDeletionImpact | null { + if (nodeLinesOf(graphStore, nodeId).length === 0) return null; + const edges = graphStore.readEdges().filter((line) => line.from === nodeId || line.to === nodeId); + const events = graphStore.readEvents().filter((event) => event.nodeId === nodeId); + const sessions = new Set(); + for (const line of nodeLinesOf(graphStore, nodeId)) { + for (const pt of line.provenance) sessions.add(pt.chatId); + } + for (const mapping of graphStore.readMappings()) { + if (mapping.graphNodeId === nodeId) sessions.add(mapping.chatId); + } + for (const event of events) sessions.add(event.chatId); + return { + edges: edges.length, + quizEvents: events.filter((event) => event.kind !== "observation").length, + observations: events.filter((event) => event.kind === "observation").length, + sessions: [...sessions].sort(), + }; +} + +/** + * Full-death one node. Returns null when no such node exists (the + * orchestration maps that to 404); rewrites only the ledgers that + * changed (unchanged files keep their mtimes — the version stamp moves + * only when something really did). + */ +export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null { + const nodeLines = nodeLinesOf(graphStore, nodeId); + if (nodeLines.length === 0) return null; + + const edgeLines = graphStore.readEdges(); + const events = graphStore.readEvents(); + const mappings = graphStore.readMappings(); + + const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId); + const outEvents = events.filter((event) => event.nodeId !== nodeId); + const outMappings = mappings.filter((m) => m.graphNodeId !== nodeId); + + // Only the touched ledgers: the node's own lines always die (its file + // must move), everything else rewrites only when rows actually drop. + const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId); + graphStore.rewriteLedgers({ + nodes: outNodes, + ...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}), + ...(outEvents.length !== events.length ? { events: outEvents } : {}), + ...(outMappings.length !== mappings.length ? { mappings: outMappings } : {}), + }); + + // The resting position dies with the node. + const positions = graphStore.readPositions(); + if (positions[nodeId] !== undefined) { + const { [nodeId]: _dead, ...rest } = positions; + graphStore.writePositions(rest); + } + + return { + edgesRemoved: edgeLines.length - outEdges.length, + eventsRemoved: events.length - outEvents.length, + mappingsRemoved: mappings.length - outMappings.length, + }; +} diff --git a/src/server/graph.ts b/src/server/graph.ts index f64ca2f..a2051e2 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -4,6 +4,13 @@ import type { GraphSnapshot } from "../core/contracts.ts"; import type { GraphCostSummary } from "../core/cost-summary.ts"; import { graphCostSummary } from "../core/cost-summary.ts"; import { graphSnapshot } from "../core/graph.ts"; +import { + deleteNodeTraces, + nodeDeletionImpact, + type NodeDeletionImpact, + type NodeDeletionStats, +} from "../core/graph-deletion.ts"; +import { extractionStatuses } from "../agent/subagents/extraction.ts"; import { stores } from "./stores.ts"; /** @@ -65,3 +72,59 @@ export function graphRevision(): number { } return revision; } + +// ─── single-node deletion (node interlude, owner-locked full-death) ────────── + +/** What deleteGraphNode did — the route maps it straight to HTTP. */ +export type NodeDeleteOutcome = + | { ok: false; status: 404; code: "not_found"; error: string } + | { ok: false; status: 409; code: "extraction_running"; error: string } + | { ok: true; stats: NodeDeletionStats; graphRevision: number }; + +/** + * Delete one node by id — FULL DEATH (core/graph-deletion.ts for the + * ledger semantics). The guard family is deliberately NOT the chat + * one, and the reasoning is worth writing down: + * + * - No turn/jobs gates. Turns and background jobs never write the + * graph ledgers; a turn's follow-up extraction registers as a run, + * and every code path here is synchronous — nothing can register + * or merge between the gate and the ledger rewrite (the event loop + * never yields inside this function). + * - THE EXTRACTION GATE — 409 while ANY pipeline run is registered, + * anywhere. A mid-flight run holds findings extracted from + * transcripts that predate the deletion; its merges could re-add + * aliases, provenance, or events to the dying node (the + * re-reference risk the dispatch flags). Unlike chat deletion we + * do NOT abort the run: it belongs to some session, and killing + * that session's spend is outside this deletion's blast radius — + * the UI retries once the dock drains. A run that starts AFTER + * the deletion is legitimate re-learning: it reads post-deletion + * ledgers, mints a fresh node for a re-taught topic, and + * mergeFindings ignores linkTo ids that no longer exist (the + * existingIds guard) — no dangling edges, no resurrection. + */ +export function deleteGraphNode(nodeId: string): NodeDeleteOutcome { + if (extractionStatuses().length > 0) { + return { + ok: false, + status: 409, + code: "extraction_running", + error: "An extraction is running — try again when it finishes", + }; + } + // THE SYNC BLOCK — no awaits below (the doc above): the gate and the + // rewrite are one atomic stretch of the event loop. + const stats = deleteNodeTraces(stores.graphStore, nodeId); + if (stats === null) { + return { ok: false, status: 404, code: "not_found", error: "No such node" }; + } + return { ok: true, stats, graphRevision: graphRevision() }; +} + +/** The confirm dialog's pre-flight read (GET /api/graph/nodes/[id]): + * what full death would take with it. 404-shaped null when the node + * does not exist — the UI shows the dialog only for nodes it can see. */ +export function graphNodeImpact(nodeId: string): NodeDeletionImpact | null { + return nodeDeletionImpact(stores.graphStore, nodeId); +} diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 647e6ee..4bf7600 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -621,6 +621,12 @@ import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-dele import { deleteChatSession } from "../src/server/chats.ts"; import { beginTurn, endTurn } from "../src/server/turn-liveness.ts"; import { jobStore } from "../src/store/jobs.ts"; +import { + deleteNodeTraces, + nodeDeletionImpact, + type NodeDeletionStats, +} from "../src/core/graph-deletion.ts"; +import { deleteGraphNode, graphRevision } from "../src/server/graph.ts"; describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () => { /** Two-session scenario: dA creates the shared node X + the orphan Y and @@ -809,3 +815,177 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" }); }); }); + +// ─── single-node deletion (node interlude, full death) ─────────────────────── + +describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlude)", () => { + /** The full-death scenario: node X is born in a notes file (the + * production orphan shape — EMPTY provenance, the mapping line is the + * anchor), re-encountered by a chat (link line: alias + provenance), + * asserted in an edge, and evaluated by two sessions. */ + function buildNodeScenario() { + // Clean slate per test: each test in this describe builds its own + // scenario from empty ledgers (the suite's isolation covers the + // whole file; this covers describe-internal stacking). + graphStore.rewriteLedgers({ nodes: [], edges: [], events: [], mappings: [] }); + graphStore.writePositions({}); + graphStore.appendNode({ + id: "n_dead", + name: "Doomed", + statement: "Doomed statement", + aliases: [], + createdAt: "2026-09-23T00:00:00.000Z", + provenance: [], + }); + // The chat's re-encounter: a link line (alias + its own provenance). + graphStore.appendNode({ + id: "n_dead", + name: "Doomed", + statement: "Doomed statement", + aliases: ["doomed, rephrased"], + createdAt: "2026-09-23T00:00:00.000Z", + provenance: [{ chatId: "chat_node_a", messageId: "m1" }], + }); + // A neighbor + two edges: only the one touching X dies. + graphStore.appendNode({ + id: "n_keep", + name: "Keeper", + statement: "Keeper statement", + aliases: [], + createdAt: "2026-09-23T00:00:00.000Z", + provenance: [{ chatId: "chat_node_a", messageId: "m2" }], + }); + graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] }); + graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] }); + graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] }); + // Events: two sessions' evidence on X, one on the neighbor. + graphStore.appendEvent({ nodeId: "n_dead", kind: "correct", chatId: "chat_node_a", at: "2026-09-23T00:01:00.000Z", ref: "q1" }); + graphStore.appendEvent({ nodeId: "n_dead", kind: "observation", chatId: "chat_node_b", at: "2026-09-23T00:02:00.000Z", note: "shaky", ref: "obs:p1" }); + graphStore.appendEvent({ nodeId: "n_keep", kind: "miss", chatId: "chat_node_a", at: "2026-09-23T00:03:00.000Z", ref: "q2" }); + // Mappings: the notes anchor + the chat's audit trail. + graphStore.appendMapping({ chatId: "src_nodefile", sourceId: "p1", graphNodeId: "n_dead" }); + graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p1", graphNodeId: "n_dead" }); + graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p2", graphNodeId: "n_keep" }); + // Resting positions for both. + graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } }); + } + + it("nodeDeletionImpact: the confirm dialog's pre-flight facts", () => { + buildNodeScenario(); + assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), { + edges: 2, + quizEvents: 1, + observations: 1, + // The file anchor, the re-encounter, and the second session's + // observation — every referencing session, deduped and sorted. + sessions: ["chat_node_a", "chat_node_b", "src_nodefile"], + }); + // Unknown node: null (the route's 404). + assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null); + }); + + it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => { + buildNodeScenario(); + const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep")); + const stats = deleteNodeTraces(graphStore, "n_dead"); + assert.deepEqual(stats, { edgesRemoved: 2, eventsRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats); + + // The node's EVERY line is gone; the neighbor is byte-identical + // (full death is node-scoped — other nodes' sessions keep their + // aliases and provenance). + assert.equal(graphStore.readNodes().some((n) => n.id === "n_dead"), false); + assert.equal( + JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep")), + keepLineBefore, + "the untouched node is byte-identical", + ); + // Only the edge to nowhere rode along; the foreign edge X→Z stays. + assert.deepEqual( + graphStore.readEdges().map((e) => [e.from, e.to]), + [["n_keep", "n_other"]], + ); + // The evidence died with the thing it was about; the neighbor's stays. + assert.deepEqual( + graphStore.readEvents().map((e) => [e.nodeId, e.kind]), + [["n_keep", "miss"]], + ); + // The audit trail: X's mappings gone, the neighbor's stay. + assert.deepEqual(graphStore.readMappings().map((m) => m.graphNodeId), ["n_keep"]); + // The resting position died with the node. + const positions = graphStore.readPositions(); + assert.equal(positions["n_dead"], undefined); + assert.deepEqual(positions["n_keep"], { x: 2, y: 2 }); + // Atomic rewrites left no temp files. + assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0); + // Idempotent: a second run finds no node — null. + assert.equal(deleteNodeTraces(graphStore, "n_dead"), null); + }); + + it("deleteNodeTraces: a node nothing references still dies, and only its file moves", () => { + graphStore.appendNode({ + id: "n_solo", + name: "Solo", + statement: "Solo statement", + aliases: [], + createdAt: "2026-09-23T00:00:00.000Z", + provenance: [{ chatId: "chat_node_solo", messageId: "m1" }], + }); + const stats = deleteNodeTraces(graphStore, "n_solo"); + assert.deepEqual(stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats); + assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false); + }); + + it("deleteGraphNode: the orchestration — extraction gate, 404, success", () => { + // 404 — unknown node. + assert.deepEqual(deleteGraphNode("n_missing_node"), { + ok: false, + status: 404, + code: "not_found", + error: "No such node", + }); + + // 409 — ANY registered extraction run: a mid-flight run could + // re-reference the dying node; the run is never aborted (its spend + // belongs to another session), the caller retries. The registry is + // the pipeline's globalThis store — a fake entry is the honest way + // to pin the gate without staging a real run. + const g = globalThis as { __lhExtractionRuns?: unknown }; + const realRuns = g.__lhExtractionRuns; + g.__lhExtractionRuns = { + running: new Map([ + ["chat_busy", { chatId: "chat_busy", kind: "chat", phase: "extracting", startedAt: 1, usage: {}, controller: new AbortController() }], + ]), + inFlight: new Set(), + lastStartedAt: 0, + }; + try { + assert.deepEqual(deleteGraphNode("n_gate_probe"), { + ok: false, + status: 409, + code: "extraction_running", + error: "An extraction is running — try again when it finishes", + }); + } finally { + g.__lhExtractionRuns = realRuns; + } + + // Success — the node dies, the version stamp moves. + graphStore.appendNode({ + id: "n_orch", + name: "Orchestrated", + statement: "Orchestrated statement", + aliases: [], + createdAt: "2026-09-23T00:00:00.000Z", + provenance: [{ chatId: "chat_node_c", messageId: "m1" }], + }); + const revisionBefore = graphRevision(); + const outcome = deleteGraphNode("n_orch"); + assert.ok(outcome.ok); + assert.deepEqual(outcome.stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 }); + assert.ok(outcome.graphRevision > 0); + assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved"); + assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false); + // And a second call is an honest 404. + assert.deepEqual(deleteGraphNode("n_orch"), { ok: false, status: 404, code: "not_found", error: "No such node" }); + }); +});