From a885be35bd5651caebe209491d320a5decace0b3 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 13 Sep 2026 18:40:41 -0400 Subject: [PATCH] feat(session): add bounded evidence sidecars --- packages/opencode/src/session/evidence.ts | 48 ++++++++ packages/opencode/src/session/receipt.ts | 68 +++++++++++ packages/opencode/src/session/session.ts | 2 + .../opencode/test/session/session.test.ts | 110 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 packages/opencode/src/session/evidence.ts diff --git a/packages/opencode/src/session/evidence.ts b/packages/opencode/src/session/evidence.ts new file mode 100644 index 000000000..58a4f1c73 --- /dev/null +++ b/packages/opencode/src/session/evidence.ts @@ -0,0 +1,48 @@ +import { Global } from "@opencode-ai/core/global" +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs" +import path from "node:path" + +/** Host-local receipt evidence. Contents never enter the session database. */ +export namespace SessionEvidence { + export type Entry = { receiptID: string; content: string } + + const root = () => path.join(Global.Path.data, "session-receipt-evidence") + const directory = (rootID: string) => path.join(root(), encodeURIComponent(rootID)) + const file = (rootID: string, operationID: string) => + path.join(directory(rootID), `${encodeURIComponent(operationID)}.json`) + + export function write(rootID: string, operationID: string, entries: ReadonlyArray, maxBytes: number) { + const bytes = entries.reduce((total, entry) => total + new TextEncoder().encode(entry.content).byteLength, 0) + if (bytes > maxBytes) return false + const target = file(rootID, operationID) + mkdirSync(path.dirname(target), { recursive: true }) + const temporary = `${target}.${crypto.randomUUID()}.tmp` + try { + writeFileSync(temporary, JSON.stringify({ version: 1, entries })) + renameSync(temporary, target) + return true + } catch (error) { + rmSync(temporary, { force: true }) + throw error + } + } + + export function exists(rootID: string, operationID: string) { + return existsSync(file(rootID, operationID)) + } + + export function removeRoot(rootID: string) { + rmSync(directory(rootID), { recursive: true, force: true }) + } + + /** Remove evidence left by interrupted publication; committed operation IDs retain their sidecars. */ + export function sweep(rootID: string, committedOperationIDs: ReadonlySet) { + const dir = directory(rootID) + if (!existsSync(dir)) return + for (const entry of readdirSync(dir)) { + const operationID = entry.endsWith(".json") ? decodeURIComponent(entry.slice(0, -".json".length)) : undefined + if (!operationID || !committedOperationIDs.has(operationID)) + rmSync(path.join(dir, entry), { recursive: true, force: true }) + } + } +} diff --git a/packages/opencode/src/session/receipt.ts b/packages/opencode/src/session/receipt.ts index c6ee5c3c7..4f694e0c4 100644 --- a/packages/opencode/src/session/receipt.ts +++ b/packages/opencode/src/session/receipt.ts @@ -7,14 +7,19 @@ import { } from "@opencode-ai/core/session/sql" import { and, asc, desc, eq, inArray } from "drizzle-orm" import { Effect } from "effect" +import { SessionEvidence } from "./evidence" import { SessionID } from "./schema" export namespace SessionReceipt { export type Budget = { maxReceipts: number maxMetadataBytes: number + maxEvidenceBytes?: number + retentionMs?: number } + export type Evidence = SessionEvidence.Entry + export type Fact = { id: string resource: string @@ -40,6 +45,7 @@ export namespace SessionReceipt { origin: string receipts: ReadonlyArray budget: Budget + evidence?: ReadonlyArray } export function reserve(database: Database.Interface, input: ReservationInput) { @@ -166,10 +172,59 @@ export namespace SessionReceipt { export function publish(database: Database.Interface, input: ReservationInput) { return Effect.gen(function* () { yield* reserve(database, input) + const lineage = yield* root(database, input.sessionID) + if (input.evidence?.length) + yield* Effect.try({ + try: () => SessionEvidence.write(lineage, input.id, input.evidence!, input.budget.maxEvidenceBytes ?? 0), + catch: (cause) => new Error(`Failed to publish receipt evidence for ${input.id}`, { cause }), + }) yield* commit(database, input) }) } + /** Removes evidence that cannot belong to a committed receipt operation. Safe to repeat after interruption. */ + export function cleanupEvidence(database: Database.Interface, rootID: SessionID) { + return Effect.gen(function* () { + const operations = yield* database.db + .select({ id: SessionReceiptOperationTable.id }) + .from(SessionReceiptOperationTable) + .where( + and(eq(SessionReceiptOperationTable.root_id, rootID), eq(SessionReceiptOperationTable.state, "committed")), + ) + .all() + yield* Effect.sync(() => SessionEvidence.sweep(rootID, new Set(operations.map((operation) => operation.id)))) + }) + } + + /** Expire root-owned evidence after its terminal retention window without changing immutable receipt facts. */ + export function expireEvidence( + database: Database.Interface, + input: { rootID: SessionID; now: number; retentionMs: number }, + ) { + return Effect.gen(function* () { + const latest = yield* database.db + .select({ timeCreated: SessionReceiptTable.time_created }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.root_id, input.rootID)) + .orderBy(desc(SessionReceiptTable.time_created)) + .get() + if (latest && latest.timeCreated + input.retentionMs <= input.now) + yield* Effect.sync(() => SessionEvidence.removeRoot(input.rootID)) + }) + } + + /** Deleting a lineage root owns deletion of all of its host-local evidence. */ + export function removeRootEvidence(database: Database.Interface, sessionID: SessionID) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + if (lineage?.rootID === sessionID) yield* Effect.sync(() => SessionEvidence.removeRoot(sessionID)) + }) + } + export function appendAssessment(database: Database.Interface, input: Assessment) { return database.db.transaction( (tx) => @@ -274,6 +329,19 @@ export namespace SessionReceipt { } } +function root(database: Database.Interface, sessionID: SessionID) { + return database.db + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe( + Effect.flatMap((lineage) => + lineage ? Effect.succeed(lineage.rootID) : Effect.fail(new Error(`Missing lineage root for ${sessionID}`)), + ), + ) +} + function metadataSize(receipts: ReadonlyArray) { return new TextEncoder().encode(JSON.stringify(receipts)).byteLength } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 6d8ac5c1d..ec43df895 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -34,6 +34,7 @@ import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ExternalDiff } from "@/session/external-diff" import { SessionLineage } from "@/session/lineage" +import { SessionReceipt } from "@/session/receipt" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -641,6 +642,7 @@ const layer: Layer.Layer< // transcript. Tombstone them before deleting session records so an // in-flight mutation cannot recreate ownership after teardown starts. yield* SessionLineage.retainBeforeDelete(database, { sessionID, title: session.title }) + yield* SessionReceipt.removeRootEvidence(database, sessionID).pipe(Effect.orDie) ExternalDiff.remove(sessionID) yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.remove(sessionID) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 17d581288..dbf392569 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -20,6 +20,7 @@ import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" import { ExternalDiff } from "@/session/external-diff" import { SessionReceipt } from "@/session/receipt" +import { SessionEvidence } from "@/session/evidence" import path from "path" import { eq } from "drizzle-orm" @@ -213,6 +214,115 @@ describe("step-finish token propagation via event", () => { }) describe("Session", () => { + it.instance("keeps receipts metadata-only when evidence exceeds its sidecar budget", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "evidence overflow root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 3 } + + yield* SessionReceipt.publish(database, { + id: "evidence_overflow_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "evidence_overflow_receipt", + resource: "file:///overflow", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + evidence: [{ receiptID: "evidence_overflow_receipt", content: "too large" }], + }) + + expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(1) + expect(SessionEvidence.exists(root.id, "evidence_overflow_operation")).toBe(false) + }), + ) + + it.instance("removes interrupted evidence sidecars without committing an invalid reference", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "interrupted evidence root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000 } + const input = { + id: "interrupted_evidence_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "interrupted_evidence_receipt", + resource: "file:///interrupted", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + } + + yield* SessionReceipt.reserve(database, input) + SessionEvidence.write( + root.id, + input.id, + [{ receiptID: "interrupted_evidence_receipt", content: "baseline" }], + 1_000, + ) + expect(SessionEvidence.exists(root.id, input.id)).toBe(true) + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + + yield* SessionReceipt.cleanupEvidence(database, root.id) + yield* SessionReceipt.cleanupEvidence(database, root.id) + expect(SessionEvidence.exists(root.id, input.id)).toBe(false) + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + }), + ) + + it.instance("expires and deletes root-owned evidence sidecars idempotently", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "retained evidence root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000, retentionMs: 10 } + + yield* SessionReceipt.publish(database, { + id: "retained_evidence_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "retained_evidence_receipt", + resource: "file:///retained", + operation: "write", + outcome: "applied", + timeCreated: 10, + }, + ], + evidence: [{ receiptID: "retained_evidence_receipt", content: "baseline" }], + }) + expect(SessionEvidence.exists(root.id, "retained_evidence_operation")).toBe(true) + + yield* SessionReceipt.expireEvidence(database, { rootID: root.id, now: 20, retentionMs: 10 }) + yield* SessionReceipt.expireEvidence(database, { rootID: root.id, now: 20, retentionMs: 10 }) + expect(SessionEvidence.exists(root.id, "retained_evidence_operation")).toBe(false) + + SessionEvidence.write( + root.id, + "deleted_evidence_operation", + [{ receiptID: "retained_evidence_receipt", content: "baseline" }], + 1_000, + ) + yield* session.remove(root.id) + yield* SessionReceipt.removeRootEvidence(database, root.id) + expect(SessionEvidence.exists(root.id, "deleted_evidence_operation")).toBe(false) + }), + ) + it.instance("reserves root-wide receipt and metadata capacity before publishing", () => Effect.gen(function* () { const session = yield* SessionNs.Service