mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 02:46:21 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0802c78647 | ||
|
|
b0a347173d | ||
|
|
a37cb8b9c7 |
@@ -81,9 +81,6 @@ for (const lines of [6000, 25000]) {
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1044,6 +1044,19 @@ export type SessionLogOutput =
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.revert.prepared"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
|
||||
readonly paths: ReadonlyArray<RelativePath>
|
||||
}
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
|
||||
|
||||
@@ -851,6 +851,16 @@ export type SessionUsageRecorded = {
|
||||
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
|
||||
}
|
||||
|
||||
export type SessionRevertPrepared = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.revert.prepared"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; snapshot?: string; paths: Array<string> }
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2232,6 +2242,7 @@ export type SessionEventDurable =
|
||||
| SessionRevertCommitted
|
||||
| SessionMessageContentUpdated
|
||||
| SessionUsageRecorded
|
||||
| SessionRevertPrepared
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
|
||||
@@ -282,6 +282,49 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
test("session.log decodes revert preparation and subsequent path deltas", async () => {
|
||||
const data = [
|
||||
{ sessionID: "ses_test", snapshot: "original", paths: ["first.txt"] },
|
||||
{ sessionID: "ses_test", paths: ["second.txt"] },
|
||||
]
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
data
|
||||
.map(
|
||||
(data, seq) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: `evt_prepared_${seq}`,
|
||||
created: 1,
|
||||
type: "session.revert.prepared",
|
||||
durable: { aggregateID: "ses_test", seq, version: 1 },
|
||||
data,
|
||||
})}\n\n`,
|
||||
)
|
||||
.join(""),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.session.log({ sessionID: Session.ID.make("ses_test") }).pipe(Stream.runCollect)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(
|
||||
result.map((event) => {
|
||||
if (event.type !== "session.revert.prepared") throw new Error("Expected revert preparation")
|
||||
return { snapshot: event.data.snapshot?.toString(), paths: event.data.paths.map(String) }
|
||||
}),
|
||||
).toEqual([
|
||||
{ snapshot: "original", paths: ["first.txt"] },
|
||||
{ snapshot: undefined, paths: ["second.txt"] },
|
||||
])
|
||||
})
|
||||
|
||||
test("session.log retains the typed SessionNotFoundError", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"id": "82a127a2-4a18-4bf8-91fe-498d94f7f71f",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1300,6 +1300,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "revert_pending",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260903172701_revert-recovery.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -93,4 +94,5 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260903172701_revert-recovery",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`revert_pending\` text;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -204,6 +204,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
|
||||
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
|
||||
\`revert\` text,
|
||||
\`revert_pending\` text,
|
||||
\`permission\` text,
|
||||
\`agent\` text,
|
||||
\`model\` text,
|
||||
|
||||
@@ -447,6 +447,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
|
||||
yield* adapter.appendMessage(failed)
|
||||
}),
|
||||
"session.revert.prepared": () => Effect.void,
|
||||
"session.revert.staged": () => Effect.void,
|
||||
"session.revert.cleared": () => Effect.void,
|
||||
"session.revert.committed": () => Effect.void,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
@@ -33,6 +34,7 @@ type MessageEvent = Exclude<
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
const decodePaths = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(RelativePath)))
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
@@ -631,7 +633,13 @@ const layer = Layer.effectDiscard(
|
||||
})
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: event.created })
|
||||
.set({
|
||||
time_updated: event.created,
|
||||
// New user work accepts failed preparation; synthetic notices do not.
|
||||
...(event.data.item.type === "user" || event.data.item.type === "compaction"
|
||||
? { revert_pending: null }
|
||||
: {}),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -696,6 +704,42 @@ const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.RevertEvent.Prepared, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({
|
||||
pending: SessionTable.revert_pending,
|
||||
// Keep patch payloads inside SQLite; a pending record already contains all needed metadata.
|
||||
snapshot: sql<
|
||||
string | null
|
||||
>`case when ${SessionTable.revert_pending} is null then json_extract(${SessionTable.revert}, '$.snapshot') end`,
|
||||
paths: sql<string | null>`case when ${SessionTable.revert_pending} is null then (
|
||||
select json_group_array(coalesce(json_extract(value, '$.file'), json_extract(value, '$.path')))
|
||||
from json_each(${SessionTable.revert}, '$.files')
|
||||
) end`,
|
||||
})
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const snapshot = row?.pending?.snapshot ?? row?.snapshot ?? event.data.snapshot
|
||||
if (!snapshot) return yield* Effect.die(new Error("Revert preparation requires an original snapshot"))
|
||||
return yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert_pending: {
|
||||
snapshot: Snapshot.ID.make(snapshot),
|
||||
paths: Array.from(
|
||||
new Set([...(row?.pending?.paths ?? decodePaths(row?.paths ?? "[]")), ...event.data.paths]),
|
||||
),
|
||||
},
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const revert = event.data.revert
|
||||
@@ -703,6 +747,7 @@ const layer = Layer.effectDiscard(
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
|
||||
revert_pending: null,
|
||||
time_updated: event.created,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
@@ -713,7 +758,7 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ revert: null, time_updated: event.created })
|
||||
.set({ revert: null, revert_pending: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
@@ -748,7 +793,7 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ revert: null, time_updated: event.created })
|
||||
.set({ revert: null, revert_pending: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -11,7 +11,8 @@ import { SessionEvent } from "./event.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import type { SessionStore } from "./store.js"
|
||||
|
||||
export { MessageNotFoundError }
|
||||
|
||||
@@ -28,18 +29,30 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||
const instances = yield* Instance.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
|
||||
const pending = yield* loadPending(database.db, input.session.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
|
||||
const recorded = pending?.snapshot ?? input.session.revert?.snapshot
|
||||
const original = recorded ?? (yield* snapshot.capture())
|
||||
const previous = new Set(
|
||||
pending?.paths ?? (input.session.revert?.files ?? []).map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||
if (original) {
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
|
||||
}
|
||||
if (original) for (const file of previous) restore.set(file, original)
|
||||
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||
if (restore.size && !original)
|
||||
return yield* new Snapshot.Error({
|
||||
operation: "capture",
|
||||
message: "Cannot restore files without an original snapshot",
|
||||
})
|
||||
const added = Array.from(restore.keys()).filter((file) => !previous.has(file))
|
||||
if (restore.size && (!recorded || added.length))
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID: input.session.id,
|
||||
snapshot: recorded ? undefined : original,
|
||||
paths: added,
|
||||
})
|
||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||
const paths = input.files === false ? [] : Array.from(next.keys())
|
||||
const files = original
|
||||
@@ -60,14 +73,20 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
||||
|
||||
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
|
||||
const instances = yield* Instance.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const pending = yield* loadPending(database.db, session.id)
|
||||
if (!session.revert && !pending) return
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
if (!session.revert) return
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
const original = pending?.snapshot ?? session.revert?.snapshot
|
||||
if (original)
|
||||
yield* snapshot.restore({
|
||||
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
|
||||
files: new Map(
|
||||
(pending?.paths ?? (session.revert?.files ?? []).map((file) => RelativePath.make(file.file))).map(
|
||||
(file) => [file, original] as const,
|
||||
),
|
||||
),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
|
||||
sessionID: session.id,
|
||||
@@ -75,14 +94,36 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
|
||||
}).pipe(instances.provide(session))
|
||||
})
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
|
||||
if (!session.revert) return
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID: session.id,
|
||||
to: session.revert.messageID,
|
||||
})
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (
|
||||
bus: Bus.Interface,
|
||||
store: SessionStore.Interface,
|
||||
session: SessionSchema.Info,
|
||||
) {
|
||||
if (session.revert) {
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID: session.id,
|
||||
to: session.revert.messageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!(yield* store.hasPendingRevert(session.id))) return
|
||||
// Failed preparation has no boundary to commit. Accept the current files without
|
||||
// deleting history, and retire protection before new work can change those files.
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID: session.id })
|
||||
})
|
||||
|
||||
function loadPending(db: Database.Interface["db"], sessionID: SessionSchema.ID) {
|
||||
return db
|
||||
.select({ pending: SessionTable.revert_pending })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row?.pending),
|
||||
)
|
||||
}
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
|
||||
@@ -60,6 +60,8 @@ const layer = Layer.effect(
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
// Retried admissions and explicit resumes must not overwrite a protected undo baseline.
|
||||
if (yield* store.hasPendingRevert(sessionID)) return DrainResult.Complete()
|
||||
yield* plugins.awaitActivation
|
||||
yield* settleStaleCompactions(sessionID)
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
@@ -171,7 +171,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
),
|
||||
)
|
||||
// Commit a staged revert only after preparation succeeds, before admitting new work.
|
||||
if (session.revert) yield* SessionRevert.commit(bus, session)
|
||||
if (session.revert) yield* SessionRevert.commit(bus, store, session)
|
||||
return yield* admission.admit({
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
@@ -180,7 +180,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
}).pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
|
||||
)
|
||||
if (input.resume !== false) yield* execution.wake(sessionID)
|
||||
if (input.resume !== false && !(yield* store.hasPendingRevert(sessionID))) yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
@@ -257,7 +257,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
|
||||
) {
|
||||
const session = yield* get(sessionID)
|
||||
if (session.revert) yield* SessionRevert.commit(bus, session)
|
||||
if (session.revert) yield* SessionRevert.commit(bus, store, session)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* admission
|
||||
.admitCompaction({
|
||||
@@ -268,7 +268,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
.pipe(
|
||||
Effect.catchTag("SessionInbox.LifecycleConflict", () => new CompactionConflictError({ sessionID, inputID })),
|
||||
)
|
||||
yield* execution.wake(sessionID)
|
||||
if (!(yield* store.hasPendingRevert(sessionID))) yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
})
|
||||
const wait = Effect.fn("Session.wait")(function* (sessionID: SessionSchema.ID) {
|
||||
@@ -316,7 +316,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
() => new SyntheticConflictError({ sessionID, inputID }),
|
||||
),
|
||||
)
|
||||
if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID)
|
||||
if (input.resume !== false && !(yield* get(sessionID)).revert && !(yield* store.hasPendingRevert(sessionID)))
|
||||
yield* execution.wake(sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
@@ -342,6 +343,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.clear(session).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
)
|
||||
return yield* execution.wake(sessionID)
|
||||
@@ -349,7 +351,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.commit(bus, session)
|
||||
return yield* SessionRevert.commit(bus, store, session)
|
||||
})
|
||||
const revert = { stage, clear, commit }
|
||||
const operations = {
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
||||
@@ -49,6 +51,10 @@ export const SessionTable = sqliteTable(
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
|
||||
revert_pending: text({ mode: "json" }).$type<{
|
||||
readonly snapshot: Snapshot.ID
|
||||
readonly paths: readonly RelativePath[]
|
||||
}>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
|
||||
@@ -51,6 +51,8 @@ export type MessagesInput = {
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
/** Recovery preparation is deliberately absent from public Session.Info. */
|
||||
readonly hasPendingRevert: (sessionID: Session.ID) => Effect.Effect<boolean>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
@@ -95,6 +97,17 @@ const layer = Layer.effect(
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
hasPendingRevert: Effect.fn("SessionStore.hasPendingRevert")((sessionID) =>
|
||||
db
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.revert_pending)))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row !== undefined),
|
||||
),
|
||||
),
|
||||
list: Effect.fn("SessionStore.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
@@ -705,6 +705,117 @@ describe("Session-owned handles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const operation of ["commit", "prompt", "compact"] as const) {
|
||||
it.live(`${operation} accepts failed revert preparation without changing files or deleting history`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({
|
||||
snapshot: () =>
|
||||
Layer.mock(Snapshot.Service, {
|
||||
restore: () => Effect.die("Committed preparation must not restore files"),
|
||||
}),
|
||||
})
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const boundary = yield* handle.synthetic({ text: "Retain this history", resume: false })
|
||||
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
|
||||
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
const acquisitions = fixture.locations.length
|
||||
if (operation === "commit") yield* handle.revert.commit()
|
||||
if (operation === "prompt") yield* handle.prompt({ text: "Continue from these files", resume: false })
|
||||
if (operation === "compact") yield* handle.compact({})
|
||||
expect(fixture.locations).toHaveLength(acquisitions + (operation === "prompt" ? 1 : 0))
|
||||
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(false)
|
||||
expect((yield* handle.get()).revert).toBeUndefined()
|
||||
expect(yield* fixture.store.context(sessionID)).toMatchObject([{ id: boundary.id }])
|
||||
const events = yield* fixture.db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(events.at(-1)?.type).toBe(
|
||||
operation === "commit" ? "session.revert.cleared.1" : "session.inbox.enqueued.1",
|
||||
)
|
||||
yield* handle.revert.clear()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("does not wake pending recovery when retrying an already admitted prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const input = { id: SessionMessage.ID.create(), text: "Existing input" }
|
||||
yield* handle.prompt({ ...input, resume: false })
|
||||
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
yield* handle.prompt(input)
|
||||
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not wake pending recovery when retrying an already admitted compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
const input = { id: SessionMessage.ID.create() }
|
||||
yield* handle.compact(input)
|
||||
const wakes = fixture.wakes.length
|
||||
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
yield* handle.compact(input)
|
||||
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
|
||||
expect(fixture.wakes).toHaveLength(wakes)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains failed revert preparation without waking for synthetic notices", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
yield* handle.synthetic({ text: "Background notice" })
|
||||
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains failed revert preparation when new prompt preparation is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
const handle = fixture.sessions.forSession(sessionID)
|
||||
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
const entered = yield* Deferred.make<void>()
|
||||
yield* fixture.hooks.register("session", "prompt", () =>
|
||||
Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const prompt = yield* handle.prompt({ text: "Not admitted" }).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(entered)
|
||||
yield* Fiber.interrupt(prompt)
|
||||
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("selects the destination's snapshot service after a move", () =>
|
||||
Effect.gen(function* () {
|
||||
const captures: Location.Ref[] = []
|
||||
|
||||
@@ -121,9 +121,102 @@ describe("SessionProjector", () => {
|
||||
expect(storedRevert?.files).toEqual([
|
||||
{ file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
|
||||
])
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("src/new.ts")] })
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
|
||||
snapshot: Snapshot.ID.make("tree"),
|
||||
paths: ["src/old.ts", "src/new.ts"].map((file) => RelativePath.make(file)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays preparation deltas without publishing a staged boundary or changing activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const before = yield* store.get(sessionID)
|
||||
const prepared = {
|
||||
id: Event.ID.make("evt_revert_prepared"),
|
||||
aggregateID: sessionID,
|
||||
seq: 0,
|
||||
type: Bus.versionedType(SessionEvent.RevertEvent.Prepared.type, 1),
|
||||
created: 10,
|
||||
data: { sessionID, snapshot: Snapshot.ID.make("original"), paths: [RelativePath.make("first.txt")] },
|
||||
}
|
||||
yield* bus.replay(prepared)
|
||||
yield* bus.replay(prepared)
|
||||
yield* bus.replay({
|
||||
id: Event.ID.make("evt_revert_more_paths"),
|
||||
aggregateID: sessionID,
|
||||
seq: 1,
|
||||
type: prepared.type,
|
||||
created: 20,
|
||||
data: { sessionID, paths: [RelativePath.make("second.txt")] },
|
||||
})
|
||||
expect(yield* store.get(sessionID)).toEqual(before)
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: ["first.txt", "second.txt"].map((file) => RelativePath.make(file)),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects preparation without a new or previously recorded original", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
expect(
|
||||
yield* bus
|
||||
.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("first.txt")] })
|
||||
.pipe(Effect.exit),
|
||||
).toMatchObject({ _tag: "Failure" })
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
|
||||
expect(yield* db.select().from(EventTable).all()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const type of ["user", "compaction"] as const) {
|
||||
it.effect(`replayed ${type} admission accepts failed preparation but synthetic admission does not`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
yield* bus.replay({
|
||||
id: Event.ID.make("evt_notice"),
|
||||
aggregateID: sessionID,
|
||||
seq: 1,
|
||||
type: Bus.versionedType(SessionEvent.InboxEnqueued.type, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: SessionMessage.ID.make("msg_notice"),
|
||||
item: { type: "synthetic", payload: { text: "Background notice" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
expect(yield* store.hasPendingRevert(sessionID)).toBe(true)
|
||||
yield* bus.replay({
|
||||
id: Event.ID.make("evt_accept"),
|
||||
aggregateID: sessionID,
|
||||
seq: 2,
|
||||
type: Bus.versionedType(SessionEvent.InboxEnqueued.type, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: SessionMessage.ID.make("msg_accept"),
|
||||
item: { type, payload: type === "user" ? { text: "Continue" } : {}, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
expect(yield* store.hasPendingRevert(sessionID)).toBe(false)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession({
|
||||
@@ -171,6 +264,11 @@ describe("SessionProjector", () => {
|
||||
})
|
||||
.run()
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("tree"),
|
||||
paths: [RelativePath.make("first.txt")],
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
|
||||
@@ -180,12 +278,24 @@ describe("SessionProjector", () => {
|
||||
snapshot: "tree",
|
||||
files: [],
|
||||
})
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("second.txt")] })
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
|
||||
snapshot: Snapshot.ID.make("tree"),
|
||||
paths: [RelativePath.make("second.txt")],
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
|
||||
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
revert: { messageID: boundary, files: [] },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("next-original"),
|
||||
paths: [RelativePath.make("third.txt")],
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
to: boundary,
|
||||
@@ -200,6 +310,7 @@ describe("SessionProjector", () => {
|
||||
tokens_reasoning: 2,
|
||||
tokens_cache_read: 3,
|
||||
tokens_cache_write: 1,
|
||||
revert_pending: null,
|
||||
})
|
||||
// A committed revert resets the fold cache so the next boundary establishes a new epoch.
|
||||
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "../src/bus.js"
|
||||
import { Database } from "../src/database/database.js"
|
||||
import { EventTable } from "../src/event/sql.js"
|
||||
import { Location } from "../src/location.js"
|
||||
import { Instance } from "../src/instance/service.js"
|
||||
import { AbsolutePath, RelativePath } from "../src/schema.js"
|
||||
import { SessionEvent } from "../src/session/event.js"
|
||||
import { SessionMessage } from "../src/session/message.js"
|
||||
import { SessionProjector } from "../src/session/projector.js"
|
||||
import { SessionRevert } from "../src/session/revert.js"
|
||||
import { SessionSchema } from "../src/session/schema.js"
|
||||
import { SessionStore } from "../src/session/store.js"
|
||||
import { SessionTable } from "../src/session/sql.js"
|
||||
import { Snapshot } from "../src/snapshot.js"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
// Root and Windows do not enforce this fixture's POSIX directory permissions.
|
||||
const permissionTest = process.platform === "win32" || process.getuid?.() === 0 ? it.live.skip : it.live
|
||||
const sessionID = SessionSchema.ID.make("ses_revert_recovery")
|
||||
const edited = {
|
||||
"first.txt": "edited:first\n",
|
||||
"locked/file.txt": "edited:locked\n",
|
||||
"later.txt": "edited:later\n",
|
||||
"unselected.txt": "keep this later edit\n",
|
||||
}
|
||||
|
||||
const fixture = Effect.fnUntraced(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (files: Record<string, string>) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(directory, file), text))),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "locked"), { recursive: true }))
|
||||
yield* write({
|
||||
"first.txt": "saved:first\n",
|
||||
"locked/file.txt": "saved:locked\n",
|
||||
"later.txt": "saved:later\n",
|
||||
"unselected.txt": "saved:unselected\n",
|
||||
})
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const allow = Effect.promise(() => fs.chmod(path.join(directory, "locked"), 0o755))
|
||||
yield* Effect.addFinalizer(() => allow)
|
||||
|
||||
const open = Effect.fnUntraced(function* (filename = "session.sqlite") {
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* Layer.buildWithScope(
|
||||
LayerNode.compile(
|
||||
LayerNode.group([
|
||||
Location.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
{
|
||||
replacements: [
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make(directory) })),
|
||||
Global.node.replace(
|
||||
Global.layerWith({
|
||||
home: tmp.path,
|
||||
data: path.join(tmp.path, "data"),
|
||||
cache: path.join(tmp.path, "cache"),
|
||||
config: path.join(tmp.path, "config"),
|
||||
state: path.join(tmp.path, "state"),
|
||||
}),
|
||||
),
|
||||
Database.node.replace(Database.configured({ path: path.join(tmp.path, filename) })),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
],
|
||||
},
|
||||
),
|
||||
scope,
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const location = yield* Location.Service
|
||||
const makeRevert = (overrides: Partial<Snapshot.Interface> = {}) => {
|
||||
const provide = Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(Bus.Service, bus),
|
||||
Layer.succeed(Database.Service, database),
|
||||
Layer.succeed(Instance.Service, {
|
||||
// Revert only requests Snapshot; the fixture supplies that real, scope-owned instance capability.
|
||||
provide: () =>
|
||||
Effect.provide(
|
||||
Layer.succeed(Snapshot.Service, { ...snapshot, ...overrides }) as Layer.Layer<Instance.Services>,
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {
|
||||
stage: (input: Parameters<typeof SessionRevert.stage>[0]) => SessionRevert.stage(input).pipe(provide),
|
||||
clear: (session: SessionSchema.Info) => SessionRevert.clear(session).pipe(provide),
|
||||
}
|
||||
}
|
||||
const revert = makeRevert()
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die("Missing recovery fixture session")
|
||||
return session
|
||||
})
|
||||
return {
|
||||
bus,
|
||||
db: database.db,
|
||||
snapshot,
|
||||
location,
|
||||
get,
|
||||
makeRevert,
|
||||
pending: Effect.suspend(() =>
|
||||
database.db
|
||||
.select({ pending: SessionTable.revert_pending })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row?.pending),
|
||||
),
|
||||
),
|
||||
preparations: bus.log({ aggregateID: sessionID }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((events) =>
|
||||
events.filter(Schema.is(SessionEvent.RevertEvent.Prepared)).map((event) => event.data),
|
||||
),
|
||||
),
|
||||
close: Scope.close(scope, Exit.void),
|
||||
stage: (messageID: SessionMessage.ID, files?: boolean) =>
|
||||
get().pipe(Effect.flatMap((session) => revert.stage({ session, messageID, files }))),
|
||||
clear: () => get().pipe(Effect.flatMap(revert.clear)),
|
||||
}
|
||||
}).pipe(Effect.provide(context))
|
||||
})
|
||||
const services = yield* open()
|
||||
yield* services.bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: services.location.project.id,
|
||||
location: { directory: AbsolutePath.make(directory) },
|
||||
slug: "recovery",
|
||||
version: "test",
|
||||
})
|
||||
const step = Effect.fnUntraced(function* (files: Record<string, string>) {
|
||||
const prompt = yield* services.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Edit files" })
|
||||
const before = yield* services.snapshot.capture()
|
||||
if (!before) return yield* Effect.die("Missing initial snapshot")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* services.bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: Model.ID.make("test"), providerID: Provider.ID.make("test") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* write(files)
|
||||
const after = yield* services.snapshot.capture()
|
||||
if (!after) return yield* Effect.die("Missing edited snapshot")
|
||||
yield* services.bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: after,
|
||||
files: yield* services.snapshot.files({ from: before, to: after }),
|
||||
})
|
||||
return SessionMessage.ID.fromEvent(prompt.id)
|
||||
})
|
||||
// Git visits the writable file before the locked path even without batched checkouts.
|
||||
const earlier = yield* step({ "first.txt": edited["first.txt"], "locked/file.txt": edited["locked/file.txt"] })
|
||||
const later = yield* step({ "later.txt": edited["later.txt"] })
|
||||
yield* write({ "unselected.txt": edited["unselected.txt"] })
|
||||
const original = yield* services.snapshot.capture()
|
||||
if (!original) return yield* Effect.die("Missing original snapshot")
|
||||
return {
|
||||
...services,
|
||||
earlier,
|
||||
later,
|
||||
original,
|
||||
open,
|
||||
write,
|
||||
allow,
|
||||
deny: Effect.promise(() => fs.chmod(path.join(directory, "locked"), 0o555)),
|
||||
read: Effect.promise(async () =>
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.keys(edited).map(async (file) => [file, await Bun.file(path.join(directory, file)).text()]),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
describe("SessionRevert recovery", () => {
|
||||
permissionTest("clears a failed first stage back to the original files", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
expect(yield* state.stage(state.earlier).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Snapshot.Error",
|
||||
operation: "restore",
|
||||
})
|
||||
expect((yield* state.read)["first.txt"]).toBe("saved:first\n")
|
||||
expect((yield* state.get()).revert).toBeUndefined()
|
||||
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
|
||||
yield* state.allow
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect((yield* state.get()).revert).toBeUndefined()
|
||||
expect(yield* state.pending).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("retries the same boundary without adopting partially restored files as its original", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
yield* state.allow
|
||||
const staged = yield* state.stage(state.earlier)
|
||||
expect(staged.snapshot).toBe(state.original)
|
||||
expect(yield* state.read).toEqual({
|
||||
"first.txt": "saved:first\n",
|
||||
"locked/file.txt": "saved:locked\n",
|
||||
"later.txt": "saved:later\n",
|
||||
"unselected.txt": edited["unselected.txt"],
|
||||
})
|
||||
expect(yield* state.pending).toBeNull()
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("unwinds dropped paths when retrying at a different boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
yield* state.allow
|
||||
const staged = yield* state.stage(state.later)
|
||||
expect(staged.snapshot).toBe(state.original)
|
||||
expect(staged.files?.map((file) => file.file)).toEqual(["later.txt"])
|
||||
expect(yield* state.read).toEqual({ ...edited, "later.txt": "saved:later\n" })
|
||||
// Resolved paths must not remain protected across subsequent successful stages.
|
||||
yield* state.write({ "first.txt": "new edit after moving the boundary\n" })
|
||||
yield* state.stage(state.later)
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual({ ...edited, "first.txt": "new edit after moving the boundary\n" })
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("protects only new paths when a successful stage is followed by failed restaging", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
const staged = yield* state.stage(state.later)
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
expect((yield* state.get()).revert).toEqual(staged)
|
||||
expect(yield* state.preparations).toEqual([
|
||||
{ sessionID, snapshot: state.original, paths: [RelativePath.make("later.txt")] },
|
||||
{ sessionID, paths: ["first.txt", "locked/file.txt"].map((file) => RelativePath.make(file)) },
|
||||
])
|
||||
yield* state.allow
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect(yield* state.pending).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("keeps the first original through repeated failed attempts without redundant preparation events", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
expect(yield* state.preparations).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
snapshot: state.original,
|
||||
paths: ["first.txt", "locked/file.txt", "later.txt"].map((file) => RelativePath.make(file)),
|
||||
},
|
||||
])
|
||||
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
|
||||
yield* state.allow
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("accumulates new protected paths across failures at different boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
const revert = state.makeRevert({
|
||||
diff: () => Effect.fail(new Snapshot.Error({ operation: "diff", message: "Diff unavailable" })),
|
||||
})
|
||||
yield* revert.stage({ session: yield* state.get(), messageID: state.later }).pipe(Effect.flip)
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
expect(yield* state.preparations).toEqual([
|
||||
{ sessionID, snapshot: state.original, paths: [RelativePath.make("later.txt")] },
|
||||
{ sessionID, paths: ["first.txt", "locked/file.txt"].map((file) => RelativePath.make(file)) },
|
||||
])
|
||||
yield* state.allow
|
||||
expect((yield* state.stage(state.earlier)).snapshot).toBe(state.original)
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("keeps recovery available when clearing a staged revert also fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
const staged = yield* state.stage(state.earlier)
|
||||
yield* state.deny
|
||||
expect(yield* state.clear().pipe(Effect.flip)).toMatchObject({ _tag: "Snapshot.Error", operation: "restore" })
|
||||
expect((yield* state.get()).revert).toEqual(staged)
|
||||
yield* state.allow
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("unwinds failed preparation before staging with files:false", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
yield* state.allow
|
||||
expect(yield* state.stage(state.later, false)).toEqual({
|
||||
messageID: state.later,
|
||||
snapshot: state.original,
|
||||
files: [],
|
||||
})
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect(yield* state.pending).toBeNull()
|
||||
yield* state.write({ "first.txt": "new edit after files:false\n" })
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual({ ...edited, "first.txt": "new edit after files:false\n" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not prepare or change files for a first files:false stage", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
expect((yield* state.stage(state.earlier, false)).files).toEqual([])
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect(yield* state.preparations).toEqual([])
|
||||
expect(yield* state.pending).toBeNull()
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails before changing files when capture cannot supply an original", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
const revert = state.makeRevert({ capture: () => Effect.undefined })
|
||||
expect(
|
||||
yield* revert.stage({ session: yield* state.get(), messageID: state.earlier }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Snapshot.Error", operation: "capture" })
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect(yield* state.pending).toBeNull()
|
||||
expect(yield* state.preparations).toEqual([])
|
||||
expect((yield* state.get()).revert).toBeUndefined()
|
||||
expect(
|
||||
(yield* revert.stage({ session: yield* state.get(), messageID: state.earlier, files: false })).files,
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not record preparation for a nonexistent boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
expect(yield* state.stage(SessionMessage.ID.create()).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
expect(yield* state.preparations).toEqual([])
|
||||
expect(yield* state.pending).toBeNull()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains the original when diff fails after files have been restored", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
const revert = state.makeRevert({
|
||||
diff: () => Effect.fail(new Snapshot.Error({ operation: "diff", message: "Diff unavailable" })),
|
||||
})
|
||||
expect(
|
||||
yield* revert.stage({ session: yield* state.get(), messageID: state.earlier }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Snapshot.Error", operation: "diff" })
|
||||
expect((yield* state.read)["locked/file.txt"]).toBe("saved:locked\n")
|
||||
expect((yield* state.get()).revert).toBeUndefined()
|
||||
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
|
||||
yield* state.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("recovers failed preparation after reopening the database and Snapshot services", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
yield* state.close
|
||||
yield* state.allow
|
||||
const restarted = yield* state.open()
|
||||
expect((yield* restarted.get()).revert).toBeUndefined()
|
||||
expect((yield* restarted.stage(state.later)).snapshot).toBe(state.original)
|
||||
expect(yield* state.read).toEqual({ ...edited, "later.txt": "saved:later\n" })
|
||||
yield* restarted.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
}),
|
||||
)
|
||||
|
||||
permissionTest("replays unfinished preparation into a fresh database before clearing", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* fixture()
|
||||
yield* state.deny
|
||||
yield* state.stage(state.earlier).pipe(Effect.flip)
|
||||
const pending = yield* state.pending
|
||||
const partial = yield* state.read
|
||||
const events = yield* state.db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
yield* state.close
|
||||
yield* state.allow
|
||||
const replayed = yield* state.open("replayed.sqlite")
|
||||
yield* Effect.forEach(events, (event) =>
|
||||
replayed.bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
expect(yield* state.read).toEqual(partial)
|
||||
expect(yield* replayed.pending).toEqual(pending)
|
||||
expect((yield* replayed.get()).revert).toBeUndefined()
|
||||
yield* replayed.clear()
|
||||
expect(yield* state.read).toEqual(edited)
|
||||
expect(yield* replayed.pending).toBeNull()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -32,7 +32,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
@@ -3292,6 +3292,23 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("leaves queued input untouched while undo recovery is pending", function* (s) {
|
||||
const queued = yield* s.admit("Preserve this queued input")
|
||||
yield* s.llm.push(TestLLM.stop())
|
||||
yield* s.bus.publish(SessionEvent.RevertEvent.Prepared, {
|
||||
sessionID,
|
||||
snapshot: Snapshot.ID.make("original"),
|
||||
paths: [RelativePath.make("file.txt")],
|
||||
})
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(0)
|
||||
expect(yield* s.inbox).toMatchObject([{ id: queued.id }])
|
||||
yield* s.bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("steers an active step with newly recorded prompts", function* (s) {
|
||||
yield* s.admit("Start working")
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const session = (
|
||||
tokens_cache_read: 99,
|
||||
tokens_cache_write: 99,
|
||||
revert: null,
|
||||
revert_pending: null,
|
||||
permission: null,
|
||||
agent: null,
|
||||
model: null,
|
||||
|
||||
@@ -607,6 +607,17 @@ export namespace Compaction {
|
||||
}
|
||||
|
||||
export namespace RevertEvent {
|
||||
export const Prepared = Event.durable({
|
||||
type: "session.revert.prepared",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
/** The original tree, only when no staged or pending revert already records it. */
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
/** Newly protected paths; projection retains previously protected paths. */
|
||||
paths: Schema.Array(RelativePath),
|
||||
},
|
||||
})
|
||||
export const Staged = Event.durable({
|
||||
type: "session.revert.staged",
|
||||
...options,
|
||||
@@ -671,10 +682,11 @@ export const Definitions = Event.inventory(
|
||||
MessageContentUpdated,
|
||||
)
|
||||
|
||||
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
|
||||
// Internal durable events stay out of the live public manifest, but remain available in the session log.
|
||||
export const DurableDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||
UsageRecorded,
|
||||
RevertEvent.Prepared,
|
||||
)
|
||||
export const EphemeralDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "ephemeral"),
|
||||
|
||||
@@ -21,6 +21,8 @@ import { McpEvent } from "../src/mcp-event.js"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
import { SessionID } from "../src/session-id.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { RelativePath } from "../src/schema.js"
|
||||
import { Snapshot } from "../src/snapshot.js"
|
||||
import { WorkspaceEvent } from "../src/workspace-event.js"
|
||||
|
||||
describe("public event manifest", () => {
|
||||
@@ -150,6 +152,7 @@ describe("public event manifest", () => {
|
||||
"session.compaction.ended.1",
|
||||
"session.compaction.failed.1",
|
||||
"session.revert.staged.1",
|
||||
"session.revert.prepared.1",
|
||||
"session.revert.cleared.1",
|
||||
"session.revert.committed.1",
|
||||
"worktree.resolved.1",
|
||||
@@ -158,6 +161,7 @@ describe("public event manifest", () => {
|
||||
expect(SessionEvent.DurableDefinitions).toEqual([
|
||||
...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
|
||||
SessionEvent.UsageRecorded,
|
||||
SessionEvent.RevertEvent.Prepared,
|
||||
])
|
||||
expect(SessionEvent.UsageRecorded.durability).toBe("durable")
|
||||
expect(EventManifest.Durable.get("session.usage.recorded.1")).toBe(SessionEvent.UsageRecorded)
|
||||
@@ -165,6 +169,11 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Definitions).not.toContain(SessionEvent.UsageRecorded)
|
||||
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.UsageRecorded)
|
||||
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
|
||||
expect(EventManifest.Durable.get("session.revert.prepared.1")).toBe(SessionEvent.RevertEvent.Prepared)
|
||||
expect(SessionEvent.Definitions).not.toContain(SessionEvent.RevertEvent.Prepared)
|
||||
expect(EventManifest.Definitions).not.toContain(SessionEvent.RevertEvent.Prepared)
|
||||
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.RevertEvent.Prepared)
|
||||
expect(EventManifest.Latest.has("session.revert.prepared")).toBe(false)
|
||||
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
|
||||
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
|
||||
expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral")
|
||||
@@ -174,6 +183,18 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
test("encodes revert protection deltas without repeating an existing snapshot", () => {
|
||||
const encode = Schema.encodeSync(SessionEvent.RevertEvent.Prepared.data)
|
||||
const sessionID = SessionID.make("ses_prepared")
|
||||
const paths = [RelativePath.make("src/file.ts")]
|
||||
expect(encode({ sessionID, paths, snapshot: Snapshot.ID.make("original") })).toEqual({
|
||||
sessionID,
|
||||
paths,
|
||||
snapshot: "original",
|
||||
})
|
||||
expect(encode({ sessionID, paths, snapshot: undefined })).toEqual({ sessionID, paths })
|
||||
})
|
||||
|
||||
test("uses the current Session skill event as durable version 1", () => {
|
||||
expect(EventManifest.Durable.get("session.skill.activated.1")).toBe(SessionEvent.Skill.Activated)
|
||||
expect(EventManifest.Latest.get("session.skill.activated")).toBe(SessionEvent.Skill.Activated)
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ Location services are acquired only when an operation needs them. In particular,
|
||||
|
||||
`servicesFor` selects instance services from the saved placement. Each instance constructs `SessionPrompt.Service`, whose `prepare` method turns submitted input into a user inbox item without admitting it, committing a revert, or waking execution. It captures FSUtil, PluginSupervisor, PluginHooks, Image, and Skill; readiness is checked before hooks on every call. Prompt keeps early retry reconciliation outside preparation and invokes preparation interruptibly in the current instance. Lower Session does not depend directly on Database or FSUtil; its Location requirements are SessionPrompt, SessionRevert, Shell, and the PluginSupervisor still used by manual shell startup.
|
||||
|
||||
Each instance constructs its `SessionRevert.Service` through `SessionRevert.make`, capturing Database, Bus, PluginSupervisor, and Snapshot. Stage and clear check plugin readiness on each invocation and require no service provisioning inside their implementations. Session methods select the current instance for each operation, so an ID-bound Session does not retain a previous instance's snapshots after movement. Commit uses only the host's captured Bus and does not acquire an instance.
|
||||
`SessionRevert.stage` and `SessionRevert.clear` use the host Database and Bus and acquire Snapshot through `Instance.Service` for the saved Session placement. Session methods select the current instance for each operation, so an ID-bound Session does not retain a previous instance's snapshots after movement. Commit uses the host's captured Bus and SessionStore and does not acquire an instance.
|
||||
|
||||
Before restoring files, `session.revert.prepared` records a previously unrecorded original snapshot and newly protected paths. Its private `revert_pending` projection survives failed restores or diffs; retries and clear include those paths without treating partial restoration as the original. Only successful staging changes the public revert boundary. Staged, cleared, and committed events retire pending protection. Committing failed preparation without a staged boundary clears protection without touching files or deleting history. New user and manual-compaction inbox admissions retire that protection atomically with admission; retries and synthetic notices do not accept it. Retried admissions and explicit resumes cannot run model work while preparation is pending. Synthetic notices stay admitted without waking execution, matching staged-revert behavior.
|
||||
|
||||
`SessionInbox.Service` is host-scoped. Its node depends on Database and Bus, and its layer uses `SessionInbox.make` to capture those services and construct admission and pending-input commands that take only domain inputs. Its `list` method supplies the normal pending-input read without exposing Database to Session. `Session.make` and the facade's move admission consume the registered service rather than constructing separate command objects. The service is not Session-ID scoped and does not own execution. Its commands retain the existing shared inbox serialization lock. Standalone query helpers, transaction-facing projectors, and runner delivery retain their explicit database/Bus inputs. The layer compiler is unchanged.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user