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
33 changes: 32 additions & 1 deletion packages/opencode/src/session/evidence.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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 })
}
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/session/external-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down
136 changes: 127 additions & 9 deletions packages/opencode/src/session/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -113,7 +113,10 @@ export namespace SessionReceipt {
)
}

export function commit(database: Database.Interface, input: { id: string; receipts: ReadonlyArray<Fact> }) {
export function commit(
database: Database.Interface,
input: { id: string; receipts: ReadonlyArray<Fact>; evidenceReceiptIDs?: ReadonlySet<string> },
) {
return database.db
.transaction(
(tx) =>
Expand All @@ -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 })
Expand Down Expand Up @@ -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" })
Expand All @@ -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)) } : {}),
})
})
}

Expand Down Expand Up @@ -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 }),
)
}
})
}

Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/opencode/test/server/session-diff-scoped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
Loading
Loading