Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/opencode/src/session/evidence.ts
Original file line number Diff line number Diff line change
@@ -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<Entry>, 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<string>) {
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 })
}
}
}
68 changes: 68 additions & 0 deletions packages/opencode/src/session/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +45,7 @@ export namespace SessionReceipt {
origin: string
receipts: ReadonlyArray<Fact>
budget: Budget
evidence?: ReadonlyArray<Evidence>
}

export function reserve(database: Database.Interface, input: ReservationInput) {
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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<SessionReceipt.Fact>) {
return new TextEncoder().encode(JSON.stringify(receipts)).byteLength
}
2 changes: 2 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions packages/opencode/test/session/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
Loading