diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 66e8a02..5c6f89a 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -1324,6 +1324,12 @@ export class SessionRepository { try { const placeholders = sessionIds.map(() => "?").join(", "); const scope = [input.user_id, ...sessionIds]; + const runIds = this.db.prepare(` + SELECT id FROM runs WHERE user_id = ? AND session_id IN (${placeholders}) + `).all(...scope) + .map((row) => (isRecord(row) && typeof row.id === "string" ? row.id : null)) + .filter((id): id is string => Boolean(id)); + const runPlaceholders = runIds.map(() => "?").join(", "); this.db.prepare(` DELETE FROM session_branches @@ -1361,6 +1367,17 @@ export class SessionRepository { DELETE FROM trace_sections WHERE user_id = ? AND session_id IN (${placeholders}) `).run(...scope); + if (runIds.length > 0) { + this.db.prepare(` + DELETE FROM protocol_state_snapshots + WHERE user_id = ? AND run_id IN (${runPlaceholders}) + `).run(input.user_id, ...runIds); + this.db.prepare(` + DELETE FROM protocol_event_journal + WHERE user_id = ? AND run_id IN (${runPlaceholders}) + `).run(input.user_id, ...runIds); + } + this.db.prepare(` DELETE FROM context_package_snapshots WHERE user_id = ? AND session_id IN (${placeholders}) `).run(...scope); @@ -1389,14 +1406,7 @@ export class SessionRepository { DELETE FROM run_events WHERE user_id = ? AND session_id IN (${placeholders}) `).run(...scope); - const runIds = this.db.prepare(` - SELECT id FROM runs WHERE user_id = ? AND session_id IN (${placeholders}) - `).all(...scope) - .map((row) => (isRecord(row) && typeof row.id === "string" ? row.id : null)) - .filter((id): id is string => Boolean(id)); - if (runIds.length > 0) { - const runPlaceholders = runIds.map(() => "?").join(", "); this.db.prepare(` DELETE FROM sql_audit_logs WHERE user_id = ? AND run_id IN (${runPlaceholders}) diff --git a/packages/metadata/src/session-delete.test.ts b/packages/metadata/src/session-delete.test.ts new file mode 100644 index 0000000..8159ba9 --- /dev/null +++ b/packages/metadata/src/session-delete.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createMetadataStore } from "./index.js"; + +describe("SessionRepository.delete", () => { + it("deletes persisted protocol state and journal events with the session", () => { + const root = mkdtempSync(join(tmpdir(), "session-delete-protocol-")); + const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + try { + metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + metadata.runs.create({ + user_id: "dev-user", + id: "run-1", + session_id: "session-1", + user_input: "test", + status: "completed" + }); + metadata.contextPackageSnapshots.create({ + user_id: "dev-user", + session_id: "session-1", + run_id: "run-1", + package_id: "context-1", + revision: 0, + payload: {} + }); + metadata.protocolStates.compareAndSetWithEvents({ + user_id: "dev-user", + run_id: "run-1", + segment_id: "segment-1", + expected_revision: -1, + state: { + protocolId: "general-task", + protocolVersion: "1", + runId: "run-1", + segmentId: "segment-1", + revision: 0, + phase: "work", + status: "completed", + contextPackageRef: { packageId: "context-1", revision: 0 }, + actions: [], + completionRejections: 0, + domain: {} + } + }, [{ + eventId: "event-1", + type: "protocol.run.completed", + runId: "run-1", + segmentId: "segment-1", + revision: 0 + }]); + + expect(metadata.sessions.delete({ user_id: "dev-user", session_id: "session-1" })).toEqual({ + deleted: true, + deletedSessionIds: ["session-1"] + }); + expect(metadata.sessions.list({ user_id: "dev-user" })).toHaveLength(0); + expect(metadata.protocolStates.find({ + user_id: "dev-user", + run_id: "run-1", + segment_id: "segment-1" + })).toBeUndefined(); + expect(metadata.protocolStates.pendingEvents({ user_id: "dev-user", run_id: "run-1" })).toEqual([]); + } finally { + metadata.close(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/smoke-config-api.mjs b/scripts/smoke-config-api.mjs index df84c15..1f35b7b 100644 --- a/scripts/smoke-config-api.mjs +++ b/scripts/smoke-config-api.mjs @@ -1299,6 +1299,43 @@ try { user_input: "config smoke", status: "completed" }); + metadataStore.contextPackageSnapshots.create({ + user_id: "dev-user", + session_id: "session-smoke", + run_id: "run-smoke", + package_id: "context-smoke", + revision: 0, + payload: {} + }); + metadataStore.protocolStates.compareAndSetWithEvents({ + user_id: "dev-user", + run_id: "run-smoke", + segment_id: "segment-smoke", + expected_revision: -1, + state: { + protocolId: "general-task", + protocolVersion: "1", + runId: "run-smoke", + segmentId: "segment-smoke", + revision: 0, + phase: "work", + status: "completed", + contextPackageRef: { packageId: "context-smoke", revision: 0 }, + actions: [], + completionRejections: 0, + domain: {} + } + }, [{ + eventId: "protocol-event-smoke", + type: "protocol.run.completed", + runId: "run-smoke", + segmentId: "segment-smoke", + revision: 0 + }]); + assert.equal(metadataStore.protocolStates.pendingEvents({ + user_id: "dev-user", + run_id: "run-smoke" + }).length, 1); const artifact = metadataStore.artifacts.create({ id: "artifact-smoke", user_id: "dev-user", @@ -1313,6 +1350,23 @@ try { assert.equal(download.headers.get("content-type"), "text/csv; charset=utf-8"); assert.equal((await download.text()).includes("revenue,42"), true); + const deletedSession = await requestJson("/api/v1/sessions/session-smoke", { method: "DELETE" }); + assert.equal(deletedSession.response.status, 200, JSON.stringify(deletedSession.body)); + assert.deepEqual(deletedSession.body.data, { + sessionId: "session-smoke", + deleted: true, + deletedSessionIds: ["session-smoke"] + }); + assert.equal(metadataStore.protocolStates.find({ + user_id: "dev-user", + run_id: "run-smoke", + segment_id: "segment-smoke" + }), undefined); + assert.deepEqual(metadataStore.protocolStates.pendingEvents({ + user_id: "dev-user", + run_id: "run-smoke" + }), []); + const builtinDelete = await requestJson("/api/v1/datasources/dtc-growth-demo", { method: "DELETE" }); assert.equal(builtinDelete.response.status, 200); assert.equal(builtinDelete.body.data.deleted, true); @@ -1336,7 +1390,7 @@ try { console.log( "Config API smoke OK: secrets, datasource/types/policies, chat upload, datasource upload, conversation, revision, " - + "KB, MCP, model profile, skill binding, defaults, artifact, tombstone" + + "KB, MCP, model profile, skill binding, defaults, artifact, session delete, tombstone" ); } finally { await closeHttpServer(server);