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:
@@ -129,6 +129,19 @@ function renameChat(id: string, title: string): ChatRecord {
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Delete the session record file entirely (session-deletion wave 6,
|
||||
* owner 2026-09-22). False when the id is unknown — the route turns that
|
||||
* into a 404. The record's graph traces and jobs are the route's
|
||||
* choreography (core/graph-deletion.ts), not the store's. */
|
||||
function deleteChat(id: string): boolean {
|
||||
try {
|
||||
fs.unlinkSync(chatFile(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false; // never existed, already deleted, or unreadable — same answer
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the session plan tree (plan tools call this in execute). */
|
||||
function saveTree(id: string, tree: PlanTree): void {
|
||||
const record = getChat(id);
|
||||
@@ -147,6 +160,7 @@ export const chatStore: ChatStore = {
|
||||
saveTree,
|
||||
saveSettings,
|
||||
renameChat,
|
||||
deleteChat,
|
||||
appendTurnUsage,
|
||||
appendExtractionUsage,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,10 +79,28 @@ function findJobByToolCallId(chatId: string, toolCallId: string): JobRecord | nu
|
||||
return listJobs(chatId).find((j) => j.toolCallId === toolCallId) ?? null;
|
||||
}
|
||||
|
||||
/** Delete every job record file for a chat (session-deletion wave 6) —
|
||||
* running or finished, they are the chat's records and die with it.
|
||||
* Returns how many files were removed. The deletion route refuses while
|
||||
* jobs are still running, so every record here is terminal. */
|
||||
function deleteJobsForChat(chatId: string): number {
|
||||
let removed = 0;
|
||||
for (const job of listJobs(chatId)) {
|
||||
try {
|
||||
fs.unlinkSync(jobFile(job.id));
|
||||
removed++;
|
||||
} catch {
|
||||
/* already gone — the count is best-effort truth */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export const jobStore: JobStore = {
|
||||
createJob,
|
||||
getJob,
|
||||
updateJob,
|
||||
listJobs,
|
||||
findJobByToolCallId,
|
||||
deleteJobsForChat,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user