From b5bcc511433ba616dc2a4152b64e7cf436b15851 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 13 Sep 2026 19:13:18 -0400 Subject: [PATCH] feat(session): page legacy external receipts --- packages/opencode/src/session/evidence.ts | 33 ++++- .../opencode/src/session/external-diff.ts | 6 + packages/opencode/src/session/receipt.ts | 136 ++++++++++++++++-- .../test/server/session-diff-scoped.test.ts | 39 +++++ .../opencode/test/session/session.test.ts | 109 +++++++++++++- 5 files changed, 312 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/session/evidence.ts b/packages/opencode/src/session/evidence.ts index 58a4f1c73..8d5916982 100644 --- a/packages/opencode/src/session/evidence.ts +++ b/packages/opencode/src/session/evidence.ts @@ -1,5 +1,5 @@ import { Global } from "@opencode-ai/core/global" -import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" /** Host-local receipt evidence. Contents never enter the session database. */ @@ -31,6 +31,37 @@ export namespace SessionEvidence { return existsSync(file(rootID, operationID)) } + export function read(rootID: string, operationID: string): Entry[] | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(file(rootID, operationID), "utf8")) + if ( + typeof parsed !== "object" || + parsed === null || + !("version" in parsed) || + parsed.version !== 1 || + !("entries" in parsed) || + !Array.isArray(parsed.entries) || + !parsed.entries.every( + (entry: unknown): entry is Entry => + typeof entry === "object" && + entry !== null && + "receiptID" in entry && + typeof entry.receiptID === "string" && + "content" in entry && + typeof entry.content === "string", + ) + ) + return + return parsed.entries + } catch { + return + } + } + + export function has(rootID: string, operationID: string, receiptID: string) { + return read(rootID, operationID)?.some((entry) => entry.receiptID === receiptID) ?? false + } + export function removeRoot(rootID: string) { rmSync(directory(rootID), { recursive: true, force: true }) } diff --git a/packages/opencode/src/session/external-diff.ts b/packages/opencode/src/session/external-diff.ts index aa3dd0c0a..42d51d2c4 100644 --- a/packages/opencode/src/session/external-diff.ts +++ b/packages/opencode/src/session/external-diff.ts @@ -21,6 +21,8 @@ export namespace ExternalDiff { deletions: number } | { reference: string; file: string; state: "unchanged" | "unavailable" } + /** Read-only adapter for v1 external-diff persistence; it never claims ledger ownership. */ + export type CompatibilityRecord = { kind: "legacy_external"; assessment: Assessment } type Endpoint = { present: true; content: string } | { present: false } type Expected = { present: boolean; digest?: string } @@ -446,6 +448,10 @@ export namespace ExternalDiff { : [] return { version: 1, revision: revisions.get(sessionID) ?? 0, assessments } } + /** Projects persisted v1 records without attaching an operation or lineage claim. */ + export function compatibility(sessionID: string): CompatibilityRecord[] { + return assessed(sessionID).assessments.map((assessment) => ({ kind: "legacy_external", assessment })) + } /** Test-only restart seam; production restart rehydrates lazily from the manifest. */ export function resetMemoryForTest() { entries.clear() diff --git a/packages/opencode/src/session/receipt.ts b/packages/opencode/src/session/receipt.ts index 4f694e0c4..24f7e38b5 100644 --- a/packages/opencode/src/session/receipt.ts +++ b/packages/opencode/src/session/receipt.ts @@ -5,7 +5,7 @@ import { SessionReceiptOperationTable, SessionReceiptTable, } from "@opencode-ai/core/session/sql" -import { and, asc, desc, eq, inArray } from "drizzle-orm" +import { and, asc, desc, eq, gt, inArray } from "drizzle-orm" import { Effect } from "effect" import { SessionEvidence } from "./evidence" import { SessionID } from "./schema" @@ -113,7 +113,10 @@ export namespace SessionReceipt { ) } - export function commit(database: Database.Interface, input: { id: string; receipts: ReadonlyArray }) { + export function commit( + database: Database.Interface, + input: { id: string; receipts: ReadonlyArray; evidenceReceiptIDs?: ReadonlySet }, + ) { return database.db .transaction( (tx) => @@ -130,6 +133,13 @@ export namespace SessionReceipt { reservation.reserved_metadata_bytes !== metadataSize(input.receipts) ) return yield* Effect.fail(new Error(`Receipt reservation mismatch for ${input.id}`)) + if ( + input.evidenceReceiptIDs?.size && + [...input.evidenceReceiptIDs].some( + (receiptID) => !input.receipts.some((receipt) => receipt.id === receiptID), + ) + ) + return yield* Effect.fail(new Error(`Evidence receipt mismatch for ${input.id}`)) const latest = yield* tx .select({ sequence: SessionReceiptTable.creation_seq }) @@ -158,6 +168,23 @@ export namespace SessionReceipt { })), ) .run() + const evidence = input.receipts.filter((receipt) => input.evidenceReceiptIDs?.has(receipt.id)) + if (evidence.length) + yield* tx + .insert(SessionReceiptAssessmentTable) + .values( + evidence.map((receipt) => ({ + id: crypto.randomUUID(), + receipt_id: receipt.id, + root_id: reservation.root_id, + confidence: "observed", + net_state: "unknown", + evidence_state: "available", + revision: 1, + time_created: receipt.timeCreated, + })), + ) + .run() yield* tx .update(SessionReceiptOperationTable) .set({ state: "committed" }) @@ -173,12 +200,16 @@ export namespace SessionReceipt { 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) + const evidence = 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 }), + }) + : false + yield* commit(database, { + ...input, + ...(evidence ? { evidenceReceiptIDs: new Set(input.evidence!.map((entry) => entry.receiptID)) } : {}), + }) }) } @@ -208,8 +239,22 @@ export namespace SessionReceipt { .where(eq(SessionReceiptTable.root_id, input.rootID)) .orderBy(desc(SessionReceiptTable.time_created)) .get() - if (latest && latest.timeCreated + input.retentionMs <= input.now) + if (latest && latest.timeCreated + input.retentionMs <= input.now) { + const evidence = yield* database.db + .select({ receiptID: SessionReceiptAssessmentTable.receipt_id }) + .from(SessionReceiptAssessmentTable) + .where( + and( + eq(SessionReceiptAssessmentTable.root_id, input.rootID), + eq(SessionReceiptAssessmentTable.evidence_state, "available"), + ), + ) + .all() yield* Effect.sync(() => SessionEvidence.removeRoot(input.rootID)) + yield* Effect.forEach(evidence, (entry) => + assessEvidence(database, { receiptID: entry.receiptID, timeCreated: input.now }), + ) + } }) } @@ -277,6 +322,79 @@ export namespace SessionReceipt { ) } + /** Marks an evidence-bearing receipt unavailable when its host-local sidecar is absent. */ + export function assessEvidence(database: Database.Interface, input: { receiptID: string; timeCreated: number }) { + return database.db.transaction( + (tx) => + Effect.gen(function* () { + const receipt = yield* tx + .select({ rootID: SessionReceiptTable.root_id, operationID: SessionReceiptTable.operation_id }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.id, input.receiptID)) + .get() + if (!receipt) return yield* Effect.fail(new Error(`Missing receipt ${input.receiptID}`)) + if (SessionEvidence.has(receipt.rootID, receipt.operationID, input.receiptID)) return + + const latest = yield* tx + .select({ + revision: SessionReceiptAssessmentTable.revision, + evidenceState: SessionReceiptAssessmentTable.evidence_state, + }) + .from(SessionReceiptAssessmentTable) + .where(eq(SessionReceiptAssessmentTable.receipt_id, input.receiptID)) + .orderBy(desc(SessionReceiptAssessmentTable.revision)) + .get() + if (latest?.evidenceState !== "available") return + yield* tx + .insert(SessionReceiptAssessmentTable) + .values({ + id: crypto.randomUUID(), + receipt_id: input.receiptID, + root_id: receipt.rootID, + confidence: "unavailable", + net_state: "unavailable", + evidence_state: "unavailable", + revision: (latest?.revision ?? 0) + 1, + time_created: input.timeCreated, + }) + .run() + }), + { behavior: "immediate" }, + ) + } + + /** Pages immutable root-owned facts by their creation sequence. */ + export function page(database: Database.Interface, sessionID: SessionID, input: { cursor?: number; limit: number }) { + return Effect.gen(function* () { + if (!Number.isSafeInteger(input.limit) || input.limit < 1) + return yield* Effect.fail(new Error(`Invalid receipt page limit ${input.limit}`)) + if (input.cursor !== undefined && (!Number.isSafeInteger(input.cursor) || input.cursor < 0)) + return yield* Effect.fail(new Error(`Invalid receipt page cursor ${input.cursor}`)) + const rootID = yield* root(database, sessionID) + const receipts = yield* database.db + .select() + .from(SessionReceiptTable) + .where(and(eq(SessionReceiptTable.root_id, rootID), gt(SessionReceiptTable.creation_seq, input.cursor ?? 0))) + .orderBy(asc(SessionReceiptTable.creation_seq)) + .limit(input.limit + 1) + .all() + const page = receipts.slice(0, input.limit) + const next = receipts.length > input.limit ? page.at(-1)?.creation_seq : undefined + return { + rootID, + receipts: page.map((receipt) => ({ + id: receipt.id, + sequence: receipt.creation_seq, + resource: receipt.resource, + operation: receipt.operation, + outcome: receipt.outcome, + timeCreated: receipt.time_created, + })), + nextCursor: next, + } + }) + } + export function committed(database: Database.Interface, sessionID: SessionID) { return Effect.gen(function* () { const lineage = yield* database.db diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts index a50bedd26..684f85d95 100644 --- a/packages/opencode/test/server/session-diff-scoped.test.ts +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -325,6 +325,45 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "projects persisted v1 external diffs as legacy_external without ownership", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* FSUtil.Service + const session = yield* withSession({ title: "external-legacy-compatibility" }) + const sibling = path.join(path.dirname(test.directory), `external-legacy-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "before\n") + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, "after\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation })).toBe(true) + const legacyWire = ExternalDiff.assessed(session.id) + + ExternalDiff.resetMemoryForTest() + + const compatibility = ExternalDiff.compatibility(session.id) + expect(compatibility).toEqual([ + { + kind: "legacy_external", + assessment: expect.objectContaining({ + reference: reservation.endpoints[0].reference, + file: sibling, + state: "changed", + status: "modified", + }), + }, + ]) + expect(compatibility[0]).not.toHaveProperty("operationID") + expect(compatibility[0]).not.toHaveProperty("rootID") + expect(compatibility[0]).not.toHaveProperty("lineage") + expect(compatibility[0]).not.toHaveProperty("origin") + const stillLegacy = ExternalDiff.assessed(session.id) + expect(Object.keys(stillLegacy).sort()).toEqual(["assessments", "revision", "version"]) + expect(stillLegacy).toMatchObject({ version: legacyWire.version, assessments: legacyWire.assessments }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "keeps generated external patches out of unrequested detail and legacy diff responses", () => diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index dbf392569..9bcea1662 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -240,6 +240,8 @@ describe("Session", () => { expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(1) expect(SessionEvidence.exists(root.id, "evidence_overflow_operation")).toBe(false) + yield* SessionReceipt.assessEvidence(database, { receiptID: "evidence_overflow_receipt", timeCreated: 2 }) + expect(yield* SessionReceipt.assessments(database, "evidence_overflow_receipt")).toEqual([]) }), ) @@ -310,6 +312,26 @@ describe("Session", () => { 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) + const expiredAssessment = yield* SessionReceipt.assessments(database, "retained_evidence_receipt") + expect(expiredAssessment).toMatchObject([ + { + receiptID: "retained_evidence_receipt", + confidence: "observed", + netState: "unknown", + evidenceState: "available", + revision: 1, + timeCreated: 10, + }, + { + receiptID: "retained_evidence_receipt", + confidence: "unavailable", + netState: "unavailable", + evidenceState: "unavailable", + revision: 2, + timeCreated: 20, + }, + ]) + expect(expiredAssessment[0]).not.toHaveProperty("patch") SessionEvidence.write( root.id, @@ -323,6 +345,64 @@ describe("Session", () => { }), ) + it.instance("assesses missing receipt evidence as unavailable without a patch payload", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "missing evidence root" }) + yield* SessionReceipt.publish(database, { + id: "missing_evidence_operation", + sessionID: root.id, + origin: "agent", + budget: { maxReceipts: 2, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000 }, + receipts: [ + { + id: "missing_evidence_receipt", + resource: "file:///missing", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + { + id: "metadata_only_receipt", + resource: "file:///metadata-only", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + evidence: [{ receiptID: "missing_evidence_receipt", content: "baseline" }], + }) + SessionEvidence.removeRoot(root.id) + + yield* SessionReceipt.assessEvidence(database, { receiptID: "missing_evidence_receipt", timeCreated: 2 }) + yield* SessionReceipt.assessEvidence(database, { receiptID: "missing_evidence_receipt", timeCreated: 3 }) + yield* SessionReceipt.assessEvidence(database, { receiptID: "metadata_only_receipt", timeCreated: 2 }) + + const assessment = yield* SessionReceipt.assessments(database, "missing_evidence_receipt") + expect(assessment).toMatchObject([ + { + receiptID: "missing_evidence_receipt", + confidence: "observed", + netState: "unknown", + evidenceState: "available", + revision: 1, + timeCreated: 1, + }, + { + receiptID: "missing_evidence_receipt", + confidence: "unavailable", + netState: "unavailable", + evidenceState: "unavailable", + revision: 2, + timeCreated: 2, + }, + ]) + expect(assessment[0]).not.toHaveProperty("patch") + expect(yield* SessionReceipt.assessments(database, "metadata_only_receipt")).toEqual([]) + }), + ) + it.instance("reserves root-wide receipt and metadata capacity before publishing", () => Effect.gen(function* () { const session = yield* SessionNs.Service @@ -447,6 +527,7 @@ describe("Session", () => { const session = yield* SessionNs.Service const database = yield* Database.Service const root = yield* session.create({ title: "receipt root" }) + const child = yield* session.create({ parentID: root.id, title: "receipt child" }) const budget = { maxReceipts: 4, maxMetadataBytes: 100_000 } yield* SessionReceipt.publish(database, { @@ -490,7 +571,7 @@ describe("Session", () => { yield* SessionReceipt.publish(database, { id: "op_followup", - sessionID: root.id, + sessionID: child.id, origin: "agent", budget, receipts: [ @@ -539,6 +620,32 @@ describe("Session", () => { }) expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(3) + const firstPage = yield* SessionReceipt.page(database, child.id, { limit: 2 }) + expect(firstPage).toMatchObject({ + rootID: root.id, + receipts: [ + { id: "receipt_first", sequence: 1 }, + { id: "receipt_second", sequence: 2 }, + ], + nextCursor: 2, + }) + const secondPage = yield* SessionReceipt.page(database, root.id, { cursor: firstPage.nextCursor, limit: 1 }) + expect(secondPage).toMatchObject({ + rootID: root.id, + receipts: [{ id: "receipt_third", sequence: 3 }], + nextCursor: 3, + }) + const thirdPage = yield* SessionReceipt.page(database, root.id, { cursor: secondPage.nextCursor, limit: 2 }) + expect(thirdPage).toMatchObject({ + rootID: root.id, + receipts: [{ id: "receipt_after_failure", sequence: 4 }], + nextCursor: undefined, + }) + expect( + [...firstPage.receipts, ...secondPage.receipts, ...thirdPage.receipts].map((receipt) => receipt.sequence), + ).toEqual([1, 2, 3, 4]) + expect((yield* Effect.exit(SessionReceipt.page(database, child.id, { limit: 0 })))._tag).toBe("Failure") + const rewrite = yield* Effect.exit( database.db .update(SessionReceiptTable)