feat(core): add session snapshot and revert system (#33226)

This commit is contained in:
Dax 2026-06-24 19:41:16 -04:00 committed by GitHub
commit 9bb5370205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2311 additions and 365 deletions

View file

@ -84,15 +84,20 @@ export const layer = Layer.effect(
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
const patch =
input.moveChanges && source.directory !== destination.directory
? yield* git
.patch(current.location.directory)
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: ""
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git
.applyPatch({ directory, patch })
.change.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
@ -104,7 +109,20 @@ export const layer = Layer.effect(
})
if (patch) {
yield* git.softResetChanges(current.location.directory).pipe(
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
@ -113,7 +131,7 @@ export const layer = Layer.effect(
cause: error.cause,
}),
),
)
)
}
})

View file

@ -0,0 +1,6 @@
export * as File from "./file"
import { Revert } from "@opencode-ai/schema/revert"
export const Diff = Revert.FileDiff
export type Diff = typeof Diff.Type

View file

@ -112,7 +112,7 @@ export const layer = Layer.effect(
}
if (location.vcs?.type === "git") {
const resolved = yield* git.dir(location.directory)
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,7 @@ import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
import { Snapshot } from "./snapshot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
@ -96,11 +97,13 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(snapshot),
)
// Kick off a background project copy refresh to update locations now that we
@ -116,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
todos,
questions,
model,
snapshot,
runner,
builtInTools,
referenceGuidance,

View file

@ -70,8 +70,8 @@ export const layer = Layer.effect(
)
})
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
const origin = yield* git.remote(repo)
const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
const origin = yield* git.remote.get(repo)
if (!origin) return undefined
const normalized = url(origin)
if (!normalized) return undefined
@ -102,22 +102,22 @@ export const layer = Layer.effect(
return `${host.toLowerCase()}/${pathname}`
}
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
const root = (yield* git.roots(repo))[0]
const root = Effect.fnUntraced(function* (repo: Git.Repository) {
const root = (yield* git.history.rootCommits(repo))[0]
return root ? ID.make(root) : undefined
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(repo.store)
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.directory,
vcs: { type: "git" as const, store: repo.store },
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
}
})

View file

@ -1,4 +1,3 @@
import path from "path"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Git } from "../git"
@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
const repo = (sourceDirectory: AbsolutePath) =>
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.find(options.directory)
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.find(directory)
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
const entries = yield* input.git.worktreeList(found)
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry).pipe(
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))

View file

@ -4,6 +4,7 @@ import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
import { AbsolutePath } from "./schema"
import { EffectFlock } from "./util/effect-flock"
export type Result = {
@ -142,15 +143,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
@ -158,86 +159,54 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
})
if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
if (result.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: resultMessage(result, `Failed to clone ${repository}`),
yield* git.repo
.clone({
remote: input.reference.remote,
directory: AbsolutePath.make(localPath),
branch: input.branch,
})
}
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
}
if (status === "refreshed") {
const fetch = yield* git
.fetch(localPath)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
}
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
yield* git.sync
.fetchRemotes(existing)
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
})
}
yield* git.sync
.fetchBranch(existing, { branch: requestedBranch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: errorMessage(error),
message: error.message,
}),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
}
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
)
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
message: resultMessage(reset, `Failed to reset ${repository}`),
})
}
yield* git.sync
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
head: checkout ? yield* git.history.head(checkout) : undefined,
branch: checkout ? yield* git.history.branch(checkout) : undefined,
} satisfies Result
}),
`repository-cache:${localPath}`,
@ -275,17 +244,17 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
)
}
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
const resetTarget = Effect.fnUntraced(function* (
git: Git.Interface,
repository: Git.Repository,
requestedBranch?: string,
) {
if (requestedBranch) return `origin/${requestedBranch}`
const remoteHead = yield* git.remoteHead(cwd)
if (remoteHead) return remoteHead
const currentBranch = yield* git.branch(cwd)
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
if (remoteHead) return `origin/${remoteHead}`
const currentBranch = yield* git.history.branch(repository)
if (currentBranch) return `origin/${currentBranch}`
return "HEAD"
})
function resultMessage(result: Git.Result, fallback: string) {
return result.stderr.trim() || result.text.trim() || fallback
}
export * as RepositoryCache from "./repository-cache"

View file

@ -29,6 +29,12 @@ import { SessionExecution } from "./session/execution"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
export const RevertState = Revert.State
export type RevertState = Revert.State
// get project -> project.locations
//
@ -94,6 +100,8 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
@ -149,18 +157,31 @@ export interface Interface {
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
export const layer = Layer.effect(
export const layer = Layer.unwrap(
Effect.promise(() => import("./location-layer")).pipe(
Effect.map(({ LocationServiceMap }) => Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const database = yield* Database.Service
const db = database.db
const events = yield* EventV2.Service
const projects = yield* ProjectV2.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@ -384,13 +405,38 @@ export const layer = Layer.effect(
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result
}),
return result
}),
),
),
),
)
export const defaultLayer = layer.pipe(
Layer.provide(Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer)))),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),

View file

@ -8,6 +8,8 @@ import { AbsolutePath, RelativePath } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessageID } from "./message-id"
import { Snapshot } from "../snapshot"
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
@ -38,6 +40,9 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert
? { ...row.revert, messageID: SessionMessageID.ID.make(row.revert.messageID) }
: undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),

View file

@ -212,7 +212,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
end: event.data.snapshot,
files: event.data.files ? Array.from(event.data.files) : undefined,
}
})
},
"session.next.step.failed": (event) => {
@ -380,6 +385,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.revert.staged": () => Effect.void,
"session.next.revert.cleared": () => Effect.void,
"session.next.revert.committed": () => Effect.void,
})
})
}

View file

@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, desc, eq, sql } from "drizzle-orm"
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@ -13,8 +13,9 @@ import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { SessionMessageID } from "./message-id"
type DatabaseService = Database.Interface["db"]
@ -66,7 +67,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert ?? null,
revert: info.revert ? { ...info.revert, messageID: SessionMessageID.ID.make(info.revert.messageID) } : null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
@ -393,6 +394,40 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db
.update(SessionTable)
.set({
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Committed, (event) =>
Effect.gen(function* () {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.messageID)))
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)
yield* db.delete(SessionMessageTable).where(and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq))).run().pipe(Effect.orDie)
yield* db.delete(SessionInputTable).where(and(eq(SessionInputTable.session_id, event.data.sessionID), or(gt(SessionInputTable.admitted_seq, boundary.seq), gt(SessionInputTable.promoted_seq, boundary.seq)))).run().pipe(Effect.orDie)
yield* db.update(SessionTable).set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)
yield* SessionContextEpoch.reset(db, event.data.sessionID)
}),
)
}),
)

View file

@ -0,0 +1,118 @@
export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
"Session.MessageNotFoundError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
interface BoundaryInput {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
const db = (yield* Database.Service).db
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* new MessageNotFoundError(input)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
}
return files
})
export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) {
const snapshot = yield* Snapshot.Service
const events = yield* EventV2.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: (yield* snapshot.capture())
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
diff: files.map((file) => file.patch).join("").trim(),
files,
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
timestamp: yield* DateTime.now,
revert,
})
return revert
})
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const snapshot = yield* Snapshot.Service
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
})
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
timestamp: yield* DateTime.now,
})
})
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
messageID: session.revert.messageID,
timestamp: yield* DateTime.now,
})
})

View file

@ -35,6 +35,7 @@ import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { Snapshot } from "../../snapshot"
/**
* Runs one durable coding-agent Session until it settles.
@ -100,6 +101,7 @@ export const layer = Layer.effect(
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const config = yield* Config.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
@ -205,6 +207,7 @@ export const layer = Layer.effect(
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(continueAfterCompaction(currentStep))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@ -213,6 +216,7 @@ export const layer = Layer.effect(
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
snapshot: startSnapshot,
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
@ -302,6 +306,23 @@ export const layer = Layer.effect(
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture()
const files = startSnapshot && endSnapshot
? yield* snapshots.files({ from: startSnapshot, to: endSnapshot }).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
yield* withPublication(events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
finish: stepSettlement.finish,
cost: 0,
tokens: stepSettlement.tokens,
snapshot: endSnapshot,
files,
}))
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())

View file

@ -10,6 +10,7 @@ type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly snapshot?: string
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -68,6 +69,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@ -77,6 +79,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
...input,
assistantMessageID,
timestamp: yield* timestamp,
snapshot: input.snapshot,
})
return assistantMessageID
})
@ -393,14 +396,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
case "step-finish":
yield* flush()
assistantActive = false
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
finish: event.reason,
cost: 0,
tokens: tokens(event.usage),
})
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return
case "finish":
return
@ -419,6 +416,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
}

View file

@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import type { Revert } from "@opencode-ai/schema/revert"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -37,7 +38,7 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
@ -45,7 +46,7 @@ export const SessionTable = sqliteTable(
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
revert: text({ mode: "json" }).$type<Revert.State>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{

View file

@ -1,9 +1,257 @@
export namespace Snapshot {
export type FileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export * as Snapshot from "./snapshot"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { File } from "./file"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath, RelativePath } from "./schema"
import { Hash } from "./util/hash"
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
export type ID = typeof ID.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface CompareInput {
readonly from: ID
readonly to: ID
}
export interface DiffInput extends CompareInput {
readonly context?: number
readonly paths?: readonly RelativePath[]
}
export interface RestoreInput {
/** Paths are relative to the project root. */
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface PreviewInput extends RestoreInput {
readonly context?: number
}
export interface Interface {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
* best-effort capture fails.
*/
readonly capture: () => Effect.Effect<ID | undefined>
/**
* List project-relative paths changed between two captured trees without
* loading file contents or generating patches.
*/
readonly files: (input: CompareInput) => Effect.Effect<readonly RelativePath[], Error>
/**
* Generate structured per-file diffs between two captured trees. `context`
* controls unchanged lines around each unified diff hunk.
*/
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
/**
* Preview the filesystem result of a selective restore without modifying the
* worktree. Each project-relative path maps to the tree it would be restored
* from.
*/
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
/**
* Restore selected project-relative paths from their associated trees. A path
* absent from its selected tree is removed; paths outside the map are untouched.
*/
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
/**
* Replace the snapshot index with a captured tree and check out all its entries.
* Files absent from the tree remain untouched. Prefer selective `restore` when
* only known paths should change.
*/
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Snapshot") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const source = yield* git.repo.discover(location.project.directory)
const worktree = source
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
: location.project.directory
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
const scope = Effect.fnUntraced(function* () {
const relative = path.relative(worktree, location.directory)
if (relative.startsWith("..") || path.isAbsolute(relative))
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const repository = Effect.fnUntraced(function* () {
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
return new Git.Repository({
worktree,
gitDirectory,
commonDirectory: gitDirectory,
})
return yield* git.repo.create({
worktree,
gitDirectory,
seed: source,
}).pipe(Effect.mapError((cause) => failure("capture", cause)))
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository()
return ID.make(
yield* git.tree.capture({
repository: repo,
scopes: [yield* scope()],
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined)),
),
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) }
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("files", cause)))
if (!source) return files
const ignored = yield* git.index
.ignored({ repository: source, paths: files })
.pipe(Effect.mapError((cause) => failure("files", cause)))
return files.filter((file) => !ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure("diff", cause)))
const ignored = source
? yield* git.index
.ignored({ repository: source, paths: files })
.pipe(Effect.mapError((cause) => failure("diff", cause)))
: new Set<RelativePath>()
return yield* git.tree
.diff({
...comparison,
context: input.context,
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) {
const files = new Map<RelativePath, Git.TreeID>()
for (const [file, snapshot] of input.files) {
const absolute = path.resolve(worktree, file)
if (!FSUtil.contains(worktree, absolute))
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
files.set(file, Git.TreeID.make(snapshot))
}
return files
})
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
const files = yield* plan("preview", input)
const current = yield* git.tree.capture({
repository: repo,
scopes: Array.from(files.keys()),
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}).pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* git.tree
.preview({
repository: repo,
current,
files,
context: input.context,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo, files: yield* plan("restore", input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.checkout({ repository: repo, tree: Git.TreeID.make(snapshot) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ capture, files, diff, preview, restore, checkout })
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
export const noopLayer = Layer.succeed(
Service,
Service.of({
capture: () => Effect.succeed(undefined),
files: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
preview: () => Effect.succeed([]),
restore: () => Effect.void,
checkout: () => Effect.void,
}),
)
function failure(operation: Error["operation"], cause: unknown) {
if (cause instanceof Error && cause.operation === operation) return cause
return new Error({
operation,
message: cause instanceof globalThis.Error ? cause.message : String(cause),
cause,
})
}
/** Legacy persisted session diff shape. */
export type LegacyFileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}