Przeglądaj źródła

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

Dax 2 miesięcy temu
rodzic
commit
9bb5370205
42 zmienionych plików z 2271 dodań i 334 usunięć
  1. 1 0
      AGENTS.md
  2. 27 9
      packages/core/src/control-plane/move-session.ts
  3. 6 0
      packages/core/src/file.ts
  4. 1 1
      packages/core/src/filesystem/watcher.ts
  5. 699 166
      packages/core/src/git.ts
  6. 4 0
      packages/core/src/location-layer.ts
  7. 8 8
      packages/core/src/project.ts
  8. 9 12
      packages/core/src/project/copy-strategies.ts
  9. 36 67
      packages/core/src/repository-cache.ts
  10. 50 4
      packages/core/src/session.ts
  11. 5 0
      packages/core/src/session/info.ts
  12. 9 1
      packages/core/src/session/message-updater.ts
  13. 38 3
      packages/core/src/session/projector.ts
  14. 118 0
      packages/core/src/session/revert.ts
  15. 21 0
      packages/core/src/session/runner/llm.ts
  16. 7 8
      packages/core/src/session/runner/publish-llm-event.ts
  17. 3 2
      packages/core/src/session/sql.ts
  18. 256 8
      packages/core/src/snapshot.ts
  19. 82 25
      packages/core/test/git.test.ts
  20. 2 0
      packages/core/test/move-session.test.ts
  21. 2 0
      packages/core/test/permission.test.ts
  22. 2 0
      packages/core/test/session-create.test.ts
  23. 21 0
      packages/core/test/session-projector.test.ts
  24. 2 0
      packages/core/test/session-prompt.test.ts
  25. 4 0
      packages/core/test/session-runner-recorded.test.ts
  26. 9 0
      packages/core/test/session-runner-tool-events.test.ts
  27. 4 0
      packages/core/test/session-runner.test.ts
  28. 185 0
      packages/core/test/snapshot.test.ts
  29. 17 2
      packages/opencode/src/session/session.ts
  30. 1 1
      packages/opencode/test/event-manifest.test.ts
  31. 22 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  32. 1 0
      packages/schema/src/index.ts
  33. 23 0
      packages/schema/src/revert.ts
  34. 22 0
      packages/schema/src/session-event.ts
  35. 2 1
      packages/schema/src/session-message.ts
  36. 2 0
      packages/schema/src/session.ts
  37. 96 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  38. 342 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  39. 10 0
      packages/server/src/errors.ts
  40. 35 0
      packages/server/src/groups/session.ts
  41. 1 8
      packages/server/src/handlers/message.ts
  42. 86 8
      packages/server/src/handlers/session.ts

+ 1 - 0
AGENTS.md

@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
 - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
 - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
 - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
+- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
 
 Reduce total variable count by inlining when a value is only used once.
 

+ 27 - 9
packages/core/src/control-plane/move-session.ts

@@ -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,
               }),
           ),
-        )
+          )
       }
     })
 

+ 6 - 0
packages/core/src/file.ts

@@ -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

+ 1 - 1
packages/core/src/filesystem/watcher.ts

@@ -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(

Plik diff jest za duży
+ 699 - 166
packages/core/src/git.ts


+ 4 - 0
packages/core/src/location-layer.ts

@@ -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,

+ 8 - 8
packages/core/src/project.ts

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

+ 9 - 12
packages/core/src/project/copy-strategies.ts

@@ -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)))

+ 36 - 67
packages/core/src/repository-cache.ts

@@ -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}`),
-                      })
-                    }
-
-                    const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
+                    yield* git.sync
+                      .fetchBranch(existing, { branch: requestedBranch })
+                      .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
+
+                    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"

+ 50 - 4
packages/core/src/session.ts

@@ -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),

+ 5 - 0
packages/core/src/session/info.ts

@@ -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),

+ 9 - 1
packages/core/src/session/message-updater.ts

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

+ 38 - 3
packages/core/src/session/projector.ts

@@ -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)
+      }),
+    )
   }),
 )
 

+ 118 - 0
packages/core/src/session/revert.ts

@@ -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,
+  })
+})

+ 21 - 0
packages/core/src/session/runner/llm.ts

@@ -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())

+ 7 - 8
packages/core/src/session/runner/publish-llm-event.ts

@@ -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,
   }
 }

+ 3 - 2
packages/core/src/session/sql.ts

@@ -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<{

+ 256 - 8
packages/core/src/snapshot.ts

@@ -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"
 }

+ 82 - 25
packages/core/test/git.test.ts

@@ -4,7 +4,7 @@ import fs from "fs/promises"
 import path from "path"
 import { Effect } from "effect"
 import { Git } from "@opencode-ai/core/git"
-import { AbsolutePath } from "@opencode-ai/core/schema"
+import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { branch, commit, gitRemote } from "./fixture/git"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
@@ -16,14 +16,16 @@ describe("Git", () => {
     withRemote((fixture) =>
       Effect.gen(function* () {
         const git = yield* Git.Service
-        const target = path.join(fixture.root, "checkout")
-        const result = yield* git.clone({ remote: fixture.remote, target })
-
-        expect(result.exitCode).toBe(0)
-        expect(yield* git.origin(target)).toBe(fixture.remote)
-        expect(yield* git.head(target)).toBeString()
-        expect(yield* git.branch(target)).toBe("main")
-        expect(yield* git.remoteHead(target)).toBe("origin/main")
+        const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
+        const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
+
+        expect(yield* git.remote.get(repository)).toBe(fixture.remote)
+        expect(yield* git.history.head(repository)).toBeString()
+        expect(yield* git.history.branch(repository)).toBe("main")
+        expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
+        expect(repository.worktree).toBe(target)
+        expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
+        expect(repository.commonDirectory).toBe(repository.gitDirectory)
         expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
       }),
     ),
@@ -33,19 +35,19 @@ describe("Git", () => {
     withRemote((fixture) =>
       Effect.gen(function* () {
         const git = yield* Git.Service
-        const target = path.join(fixture.root, "checkout")
-        yield* git.clone({ remote: fixture.remote, target })
+        const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
+        const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
 
         yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
-        expect((yield* git.fetch(target)).exitCode).toBe(0)
-        expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
+        yield* git.sync.fetchRemotes(repository)
+        yield* git.sync.resetHard(repository, "origin/main")
         expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
 
         yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
-        expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
-        expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
-        expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
-        expect(yield* git.branch(target)).toBe("feature/docs")
+        yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
+        yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
+        yield* git.sync.resetHard(repository, "origin/feature/docs")
+        expect(yield* git.history.branch(repository)).toBe("feature/docs")
         expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
       }),
     ),
@@ -90,17 +92,72 @@ describe("Git worktrees", () => {
         Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
       )
       const git = yield* Git.Service
-      const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
+      const repo = yield* git.repo.discover(directory)
+      if (!repo) throw new Error("Repository not found")
 
-      yield* git.worktreeCreate({ repo, directory: worktree })
+      yield* git.worktree.create({ repository: repo, directory: worktree })
 
-      expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
-      const linked = yield* git.find(worktree)
-      expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
-      expect(linked?.store).toBe(repo.store)
+      expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(true)
+      const linked = yield* git.repo.discover(worktree)
+      expect(linked?.worktree).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
+      expect(linked?.commonDirectory).toBe(repo.commonDirectory)
+      expect(linked?.gitDirectory).not.toBe(repo.gitDirectory)
       if (!linked) throw new Error("Linked worktree not found")
-      yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
-      expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
+      yield* git.worktree.remove({ repository: linked, directory: worktree, force: false })
+      expect((yield* git.worktree.list(repo)).some((entry) => entry.directory.endsWith("-git-worktree"))).toBe(false)
+    }),
+  )
+})
+
+describe("Git trees", () => {
+  it.live("captures, compares, previews, and restores scoped trees", () =>
+    Effect.gen(function* () {
+      const root = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+      )
+      yield* Effect.promise(async () => {
+        await initRepo(root.path)
+        await fs.mkdir(path.join(root.path, "scope"))
+        await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "one\n")
+        await fs.writeFile(path.join(root.path, "outside.txt"), "outside\n")
+        await $`git add .`.cwd(root.path).quiet()
+        await $`git commit -m initial`.cwd(root.path).quiet()
+      })
+      const git = yield* Git.Service
+      const source = yield* git.repo.discover(AbsolutePath.make(root.path))
+      if (!source) throw new Error("Repository not found")
+      const storage = AbsolutePath.make(path.join(root.path, ".snapshot"))
+      const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
+      yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
+      const before = yield* git.tree.write(repository)
+
+      yield* Effect.promise(async () => {
+        await fs.writeFile(path.join(root.path, "scope", "tracked.txt"), "two\n")
+        await fs.writeFile(path.join(root.path, "scope", "added.txt"), "added\n")
+        await fs.writeFile(path.join(root.path, "outside.txt"), "changed outside\n")
+      })
+      yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
+      const after = yield* git.tree.write(repository)
+
+      expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
+        RelativePath.make("scope/added.txt"),
+        RelativePath.make("scope/tracked.txt"),
+      ])
+      const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
+      expect(diffs.map((item) => [item.path, item.status])).toEqual([
+        [RelativePath.make("scope/added.txt"), "added"],
+        [RelativePath.make("scope/tracked.txt"), "modified"],
+      ])
+
+      const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
+      const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
+      expect(preview).toHaveLength(1)
+      expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
+      yield* git.tree.restore({ repository, files })
+      expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
+      expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
+      expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
     }),
   )
 })

+ 2 - 0
packages/core/test/move-session.test.ts

@@ -14,6 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -28,6 +29,7 @@ const project = Project.layer.pipe(
   Layer.provide(ProjectDirectories.defaultLayer),
 )
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(project),

+ 2 - 0
packages/core/test/permission.test.ts

@@ -11,6 +11,7 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionStore } from "@opencode-ai/core/session/store"
@@ -23,6 +24,7 @@ const current = Layer.succeed(
   Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
 )
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),

+ 2 - 0
packages/core/test/session-create.test.ts

@@ -13,6 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -34,6 +35,7 @@ const projects = Layer.succeed(
   }),
 )
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),

+ 21 - 0
packages/core/test/session-projector.test.ts

@@ -10,6 +10,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { Prompt } from "@opencode-ai/core/session/prompt"
@@ -20,6 +21,7 @@ import { SessionInput } from "@opencode-ai/core/session/input"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
 import { testEffect } from "./lib/effect"
+import { Snapshot } from "@opencode-ai/core/snapshot"
 
 const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
 const sessionID = SessionV2.ID.make("ses_projector_test")
@@ -41,6 +43,24 @@ const assistantRow = (
 }
 
 describe("SessionProjector", () => {
+  it.effect("projects staged, cleared, and committed reverts", () =>
+    Effect.gen(function* () {
+      const db = (yield* Database.Service).db
+      yield* db.insert(ProjectTable).values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }).run()
+      yield* db.insert(SessionTable).values({ id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test" }).run()
+      const boundary = SessionMessage.ID.make("msg_boundary")
+      yield* db.insert(SessionMessageTable).values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]).run()
+      const events = yield* EventV2.Service
+      yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, timestamp: DateTime.makeUnsafe(1), revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] } })
+      expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, snapshot: "tree", files: [] })
+      yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) })
+      expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
+      yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, timestamp: DateTime.makeUnsafe(3), revert: { messageID: boundary, files: [] } })
+      yield* events.publish(SessionEvent.RevertEvent.Committed, { sessionID, messageID: boundary, timestamp: DateTime.makeUnsafe(4) })
+      expect((yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id)).toEqual([boundary])
+    }),
+  )
+
   it.effect("orders projected messages and context by durable aggregate sequence", () =>
     Effect.gen(function* () {
       const { db } = yield* Database.Service
@@ -110,6 +130,7 @@ describe("SessionProjector", () => {
     }).pipe(
       Effect.provide(
         SessionV2.layer.pipe(
+          Layer.provide(LocationServiceMap.layer),
           Layer.provide(EventV2.defaultLayer),
           Layer.provide(Database.defaultLayer),
           Layer.provide(Project.defaultLayer),

+ 2 - 0
packages/core/test/session-prompt.test.ts

@@ -9,6 +9,7 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -39,6 +40,7 @@ const execution = Layer.succeed(
   }),
 )
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),

+ 4 - 0
packages/core/test/session-runner-recorded.test.ts

@@ -12,6 +12,8 @@ import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { Snapshot } from "@opencode-ai/core/snapshot"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -71,6 +73,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
 const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
 const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
 const runner = SessionRunnerLLM.defaultLayer.pipe(
+  Layer.provide(Snapshot.noopLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
@@ -99,6 +102,7 @@ const execution = Layer.effect(
   }),
 ).pipe(Layer.provide(runner))
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),

+ 9 - 0
packages/core/test/session-runner-tool-events.test.ts

@@ -125,3 +125,12 @@ test("old success event data containing result still decodes", () => {
   })
   expect(decoded.result).toMatchObject({ type: "content" })
 })
+
+test("step finish records settlement without publishing step ended", async () => {
+  const { published, publisher } = capture()
+  await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
+  await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
+
+  expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
+  expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
+})

+ 4 - 0
packages/core/test/session-runner.test.ts

@@ -19,6 +19,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { QuestionV2 } from "@opencode-ai/core/question"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { Snapshot } from "@opencode-ai/core/snapshot"
 import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionInput } from "@opencode-ai/core/session/input"
@@ -230,6 +232,7 @@ const config = Layer.succeed(
   }),
 )
 const runner = SessionRunnerLLM.layer.pipe(
+  Layer.provide(Snapshot.noopLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
@@ -258,6 +261,7 @@ const execution = Layer.effect(
   }),
 ).pipe(Layer.provide(runner))
 const sessions = SessionV2.layer.pipe(
+  Layer.provide(LocationServiceMap.layer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(SessionStore.defaultLayer),

+ 185 - 0
packages/core/test/snapshot.test.ts

@@ -0,0 +1,185 @@
+import { $ } from "bun"
+import { describe, expect } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Effect, Layer } from "effect"
+import { Config } from "@opencode-ai/core/config"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Git } from "@opencode-ai/core/git"
+import { Global } from "@opencode-ai/core/global"
+import { Location } from "@opencode-ai/core/location"
+import { Project } from "@opencode-ai/core/project"
+import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
+import { Snapshot } from "@opencode-ai/core/snapshot"
+import { Hash } from "@opencode-ai/core/util/hash"
+import { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+describe("Snapshot", () => {
+  testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
+    Effect.acquireUseRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) =>
+        Effect.gen(function* () {
+          const project = path.join(tmp.path, "project")
+          const location = path.join(project, "scope")
+          yield* Effect.promise(async () => {
+            await fs.mkdir(location, { recursive: true })
+            await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
+            await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
+            await $`git init`.cwd(project).quiet()
+            await $`git config core.fsmonitor false`.cwd(project).quiet()
+            await $`git config commit.gpgsign false`.cwd(project).quiet()
+            await $`git config user.email test@opencode.test`.cwd(project).quiet()
+            await $`git config user.name Test`.cwd(project).quiet()
+            await $`git add .`.cwd(project).quiet()
+            await $`git commit -m initial`.cwd(project).quiet()
+          })
+
+          const layer = snapshotLayer(tmp.path, location)
+          yield* Effect.gen(function* () {
+            const snapshot = yield* Snapshot.Service
+            const before = yield* snapshot.capture()
+            expect(before).toBeDefined()
+            if (!before) return
+
+            yield* Effect.promise(async () => {
+              await fs.writeFile(path.join(location, "tracked.txt"), "two\n")
+              await fs.writeFile(path.join(location, "added.txt"), "added\n")
+              await fs.writeFile(path.join(project, "outside.txt"), "changed outside\n")
+            })
+            const after = yield* snapshot.capture()
+            expect(after).toBeDefined()
+            if (!after) return
+
+            expect(yield* snapshot.files({ from: before, to: after })).toEqual([
+              RelativePath.make("scope/added.txt"),
+              RelativePath.make("scope/tracked.txt"),
+            ])
+            const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
+            const preview = yield* snapshot.preview({ files: plan, context: 1 })
+            expect(preview).toHaveLength(1)
+            expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
+            yield* snapshot.restore({ files: plan })
+            expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
+            expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
+            expect(yield* read(path.join(project, "outside.txt"))).toBe("changed outside\n")
+          }).pipe(Effect.provide(layer))
+        }),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ),
+  )
+
+  testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
+    Effect.acquireUseRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) =>
+        Effect.gen(function* () {
+          expect(
+            yield* Effect.gen(function* () {
+              const snapshot = yield* Snapshot.Service
+              return yield* snapshot.capture()
+            }).pipe(Effect.provide(snapshotLayer(tmp.path, tmp.path))),
+          ).toBeUndefined()
+        }),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ),
+  )
+
+  testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
+    Effect.acquireUseRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) =>
+        Effect.gen(function* () {
+          const project = path.join(tmp.path, "project")
+          const linked = path.join(tmp.path, "linked")
+          yield* Effect.promise(async () => {
+            await fs.mkdir(project)
+            await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
+            await $`git init`.cwd(project).quiet()
+            await $`git config core.fsmonitor false`.cwd(project).quiet()
+            await $`git config commit.gpgsign false`.cwd(project).quiet()
+            await $`git config user.email test@opencode.test`.cwd(project).quiet()
+            await $`git config user.name Test`.cwd(project).quiet()
+            await $`git add .`.cwd(project).quiet()
+            await $`git commit -m initial`.cwd(project).quiet()
+            await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
+          })
+
+          const capture = (directory: string) =>
+            Effect.gen(function* () {
+              const snapshot = yield* Snapshot.Service
+              return yield* snapshot.capture()
+            }).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
+          expect(yield* capture(project)).toBeDefined()
+          expect(yield* capture(linked)).toBeDefined()
+
+          const projectID = yield* Effect.gen(function* () {
+            return (yield* Location.Service).project.id
+          }).pipe(
+            Effect.provide(
+              Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
+                Layer.provide(Project.defaultLayer),
+              ),
+            ),
+          )
+          expect(yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project))))).toBeDefined()
+          expect(yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked))))).toBeDefined()
+        }),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ),
+  )
+
+  testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
+    Effect.acquireUseRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) =>
+        Effect.gen(function* () {
+          const project = path.join(tmp.path, "project")
+          yield* Effect.promise(async () => {
+            await fs.mkdir(project)
+            await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
+            await $`git init`.cwd(project).quiet()
+            await $`git config core.fsmonitor false`.cwd(project).quiet()
+            await $`git config commit.gpgsign false`.cwd(project).quiet()
+            await $`git config user.email test@opencode.test`.cwd(project).quiet()
+            await $`git config user.name Test`.cwd(project).quiet()
+            await $`git add .`.cwd(project).quiet()
+            await $`git commit -m initial`.cwd(project).quiet()
+          })
+
+          yield* Effect.gen(function* () {
+            const snapshot = yield* Snapshot.Service
+            const before = yield* snapshot.capture()
+            expect(before).toBeDefined()
+            if (!before) return
+            yield* Effect.promise(async () => {
+              await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
+              await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
+            })
+            yield* snapshot.checkout(before)
+            expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
+            expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
+          }).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
+        }),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ),
+  )
+})
+
+function snapshotLayer(data: string, directory: string) {
+  const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
+    Layer.provide(Project.defaultLayer),
+  )
+  return Snapshot.layer.pipe(
+    Layer.provide(location),
+    Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
+    Layer.provide(FSUtil.defaultLayer),
+    Layer.provide(Git.defaultLayer),
+    Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
+  )
+}
+
+function read(file: string) {
+  return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
+}

+ 17 - 2
packages/opencode/src/session/session.ts

@@ -43,6 +43,7 @@ import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ModelV2 } from "@opencode-ai/core/model"
+import { SessionMessageID } from "@opencode-ai/schema/session-message-id"
 
 const runtime = makeRuntime(Database.Service, Database.defaultLayer)
 
@@ -68,7 +69,14 @@ export function fromRow(row: SessionRow): Info {
         }
       : undefined
   const share = row.share_url ? { url: row.share_url } : undefined
-  const revert = row.revert ?? undefined
+  const revert = row.revert
+    ? {
+        messageID: MessageID.make(row.revert.messageID),
+        partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined,
+        snapshot: row.revert.snapshot,
+        diff: row.revert.diff,
+      }
+    : undefined
   return {
     id: row.id,
     slug: row.slug,
@@ -136,7 +144,14 @@ export function toRow(info: Info) {
     tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
     tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read,
     tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write,
-    revert: info.revert ?? null,
+    revert: info.revert
+      ? {
+          messageID: SessionMessageID.ID.make(info.revert.messageID),
+          partID: info.revert.partID,
+          snapshot: info.revert.snapshot,
+          diff: info.revert.diff,
+        }
+      : null,
     permission: info.permission,
     time_created: info.time.created,
     time_updated: info.time.updated,

+ 1 - 1
packages/opencode/test/event-manifest.test.ts

@@ -9,7 +9,7 @@ describe("public event manifest", () => {
     expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
     expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
     expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
-    expect(EventManifest.Latest.size).toBe(85)
+    expect(EventManifest.Latest.size).toBe(88)
     expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
     expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
     expect(EventManifest.Latest.has("ide.installed")).toBe(false)

+ 22 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -980,6 +980,28 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .json(404, object, "status"),
+  http.protected
+    .post("/api/session/{sessionID}/revert/stage", "v2.session.revert.stage")
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/revert/stage", { sessionID: "ses_httpapi_missing" }),
+      headers: { ...ctx.headers(), "content-type": "application/json" },
+      body: { messageID: "msg_httpapi_missing" },
+    }))
+    .json(404, object, "status"),
+  http.protected
+    .post("/api/session/{sessionID}/revert/clear", "v2.session.revert.clear")
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/revert/clear", { sessionID: "ses_httpapi_missing" }),
+      headers: ctx.headers(),
+    }))
+    .json(404, object, "status"),
+  http.protected
+    .post("/api/session/{sessionID}/revert/commit", "v2.session.revert.commit")
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/revert/commit", { sessionID: "ses_httpapi_missing" }),
+      headers: ctx.headers(),
+    }))
+    .json(404, object, "status"),
   http.protected
     .get("/api/session/{sessionID}/message", "v2.session.messages")
     .at((ctx) => ({

+ 1 - 0
packages/schema/src/index.ts

@@ -12,6 +12,7 @@ export { Permission } from "./permission"
 export { Project } from "./project"
 export { Provider } from "./provider"
 export { Reference } from "./reference"
+export { Revert } from "./revert"
 export { Session } from "./session"
 export { SessionInput } from "./session-input"
 export { SessionMessage } from "./session-message"

+ 23 - 0
packages/schema/src/revert.ts

@@ -0,0 +1,23 @@
+export * as Revert from "./revert"
+
+import { Schema } from "effect"
+import { NonNegativeInt, RelativePath } from "./schema"
+import { SessionMessageID } from "./session-message-id"
+
+export const FileDiff = Schema.Struct({
+  path: RelativePath,
+  status: Schema.Literals(["added", "modified", "deleted"]),
+  additions: NonNegativeInt,
+  deletions: NonNegativeInt,
+  patch: Schema.String,
+}).annotate({ identifier: "File.Diff" })
+export type FileDiff = typeof FileDiff.Type
+
+export const State = Schema.Struct({
+  messageID: SessionMessageID.ID,
+  partID: Schema.String.pipe(Schema.optional),
+  snapshot: Schema.String.pipe(Schema.optional),
+  diff: Schema.String.pipe(Schema.optional),
+  files: Schema.Array(FileDiff).pipe(Schema.optional),
+})
+export type State = typeof State.Type

+ 22 - 0
packages/schema/src/session-event.ts

@@ -11,6 +11,7 @@ import { SessionID } from "./session-id"
 import { Location } from "./location"
 import { SessionMessageID } from "./session-message-id"
 import { SessionMessage } from "./session-message"
+import { Revert } from "./revert"
 
 export { FileAttachment }
 
@@ -176,6 +177,7 @@ export namespace Step {
         }),
       }),
       snapshot: Schema.String.pipe(Schema.optional),
+      files: Schema.Array(RelativePath).pipe(Schema.optional),
     },
   })
   export type Ended = typeof Ended.Type
@@ -429,6 +431,20 @@ export namespace Compaction {
   export type Ended = typeof Ended.Type
 }
 
+export namespace RevertEvent {
+  export const Staged = Event.define({
+    type: "session.next.revert.staged",
+    ...options,
+    schema: { ...Base, revert: Revert.State },
+  })
+  export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base })
+  export const Committed = Event.define({
+    type: "session.next.revert.committed",
+    ...options,
+    schema: { ...Base, messageID: SessionMessageID.ID },
+  })
+}
+
 export const DurableDefinitions = Event.inventory(
   AgentSwitched,
   ModelSwitched,
@@ -455,6 +471,9 @@ export const DurableDefinitions = Event.inventory(
   Retried,
   Compaction.Started,
   Compaction.Ended,
+  RevertEvent.Staged,
+  RevertEvent.Cleared,
+  RevertEvent.Committed,
 )
 
 export const Definitions = Event.inventory(
@@ -487,6 +506,9 @@ export const Definitions = Event.inventory(
   Compaction.Started,
   Compaction.Delta,
   Compaction.Ended,
+  RevertEvent.Staged,
+  RevertEvent.Cleared,
+  RevertEvent.Committed,
 )
 
 export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))

+ 2 - 1
packages/schema/src/session-message.ts

@@ -4,7 +4,7 @@ import { Schema } from "effect"
 import { ProviderMetadata, ToolContent } from "./llm"
 import { Model } from "./model"
 import { FileAttachment, Prompt } from "./prompt"
-import { DateTimeUtcFromMillis } from "./schema"
+import { DateTimeUtcFromMillis, RelativePath } from "./schema"
 import { SessionID } from "./session-id"
 import { SessionMessageID } from "./session-message-id"
 
@@ -163,6 +163,7 @@ export const Assistant = Schema.Struct({
   snapshot: Schema.Struct({
     start: Schema.String.pipe(Schema.optional),
     end: Schema.String.pipe(Schema.optional),
+    files: Schema.Array(RelativePath).pipe(Schema.optional),
   }).pipe(Schema.optional),
   finish: Schema.String.pipe(Schema.optional),
   cost: Schema.Finite.pipe(Schema.optional),

+ 2 - 0
packages/schema/src/session.ts

@@ -8,6 +8,7 @@ import { Project } from "./project"
 import { DateTimeUtcFromMillis, optionalOmitUndefined, RelativePath } from "./schema"
 import { SessionEvent } from "./session-event"
 import { SessionID } from "./session-id"
+import { Revert } from "./revert"
 
 export const ID = SessionID
 export type ID = SessionID
@@ -39,6 +40,7 @@ export const Info = Schema.Struct({
   title: Schema.String,
   location: Location.Ref,
   subpath: RelativePath.pipe(Schema.optional),
+  revert: Revert.State.pipe(Schema.optional),
 }).annotate({ identifier: "SessionV2.Info" })
 
 export const ListAnchor = Schema.Struct({

+ 96 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -353,6 +353,12 @@ import type {
   V2SessionQuestionRejectResponses,
   V2SessionQuestionReplyErrors,
   V2SessionQuestionReplyResponses,
+  V2SessionRevertClearErrors,
+  V2SessionRevertClearResponses,
+  V2SessionRevertCommitErrors,
+  V2SessionRevertCommitResponses,
+  V2SessionRevertStageErrors,
+  V2SessionRevertStageResponses,
   V2SessionSwitchAgentErrors,
   V2SessionSwitchAgentResponses,
   V2SessionSwitchModelErrors,
@@ -5068,6 +5074,91 @@ export class Agent extends HeyApiClient {
   }
 }
 
+export class Revert extends HeyApiClient {
+  /**
+   * Stage session revert
+   *
+   * Stage or move a reversible session boundary and optionally apply its file changes.
+   */
+  public stage<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      messageID?: string
+      files?: boolean
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "body", key: "messageID" },
+            { in: "body", key: "files" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      V2SessionRevertStageResponses,
+      V2SessionRevertStageErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/revert/stage",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * Clear staged revert
+   */
+  public clear<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).post<
+      V2SessionRevertClearResponses,
+      V2SessionRevertClearErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/revert/clear",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Commit staged revert
+   */
+  public commit<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).post<
+      V2SessionRevertCommitResponses,
+      V2SessionRevertCommitErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/revert/commit",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Permission2 extends HeyApiClient {
   /**
    * List session permission requests
@@ -5555,6 +5646,11 @@ export class Session3 extends HeyApiClient {
     })
   }
 
+  private _revert?: Revert
+  get revert(): Revert {
+    return (this._revert ??= new Revert({ client: this.client }))
+  }
+
   private _permission?: Permission2
   get permission(): Permission2 {
     return (this._permission ??= new Permission2({ client: this.client }))

+ 342 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -45,6 +45,9 @@ export type Event =
   | EventSessionNextCompactionStarted
   | EventSessionNextCompactionDelta
   | EventSessionNextCompactionEnded
+  | EventSessionNextRevertStaged
+  | EventSessionNextRevertCleared
+  | EventSessionNextRevertCommitted
   | EventMessagePartDelta
   | EventSessionDiff
   | EventSessionError
@@ -947,6 +950,7 @@ export type GlobalEvent = {
             }
           }
           snapshot?: string
+          files?: Array<string>
         }
       }
     | {
@@ -1188,6 +1192,38 @@ export type GlobalEvent = {
           recent: string
         }
       }
+    | {
+        id: string
+        type: "session.next.revert.staged"
+        properties: {
+          timestamp: number
+          sessionID: string
+          revert: {
+            messageID: string
+            partID?: string
+            snapshot?: string
+            diff?: string
+            files?: Array<FileDiff>
+          }
+        }
+      }
+    | {
+        id: string
+        type: "session.next.revert.cleared"
+        properties: {
+          timestamp: number
+          sessionID: string
+        }
+      }
+    | {
+        id: string
+        type: "session.next.revert.committed"
+        properties: {
+          timestamp: number
+          sessionID: string
+          messageID: string
+        }
+      }
     | {
         id: string
         type: "message.part.delta"
@@ -1644,6 +1680,9 @@ export type GlobalEvent = {
     | SyncEventSessionNextRetried
     | SyncEventSessionNextCompactionStarted
     | SyncEventSessionNextCompactionEnded
+    | SyncEventSessionNextRevertStaged
+    | SyncEventSessionNextRevertCleared
+    | SyncEventSessionNextRevertCommitted
 }
 
 /**
@@ -2729,6 +2768,13 @@ export type ServiceUnavailableError = {
   service?: string
 }
 
+export type MessageNotFoundError = {
+  _tag: "MessageNotFoundError"
+  sessionID: string
+  messageID: string
+  message: string
+}
+
 export type UnknownError1 = {
   _tag: "UnknownError"
   message: string
@@ -2790,6 +2836,9 @@ export type V2Event =
   | V2EventSessionNextCompactionStarted
   | V2EventSessionNextCompactionDelta
   | V2EventSessionNextCompactionEnded
+  | V2EventSessionNextRevertStaged
+  | V2EventSessionNextRevertCleared
+  | V2EventSessionNextRevertCommitted
   | V2EventMessagePartDelta
   | V2EventSessionDiff
   | V2EventSessionError
@@ -2981,6 +3030,14 @@ export type SessionNextRetryError = {
   }
 }
 
+export type FileDiff = {
+  path: string
+  status: "added" | "modified" | "deleted"
+  additions: number
+  deletions: number
+  patch: string
+}
+
 export type PermissionV2Source = {
   type: "tool"
   messageID: string
@@ -3346,6 +3403,7 @@ export type SyncEventSessionNextStepEnded = {
         }
       }
       snapshot?: string
+      files?: Array<string>
     }
   }
 }
@@ -3644,6 +3702,59 @@ export type SyncEventSessionNextCompactionEnded = {
   }
 }
 
+export type SyncEventSessionNextRevertStaged = {
+  type: "sync"
+  id: string
+  syncEvent: {
+    type: "session.next.revert.staged.1"
+    id: string
+    seq: number
+    aggregateID: string
+    data: {
+      timestamp: number
+      sessionID: string
+      revert: {
+        messageID: string
+        partID?: string
+        snapshot?: string
+        diff?: string
+        files?: Array<FileDiff>
+      }
+    }
+  }
+}
+
+export type SyncEventSessionNextRevertCleared = {
+  type: "sync"
+  id: string
+  syncEvent: {
+    type: "session.next.revert.cleared.1"
+    id: string
+    seq: number
+    aggregateID: string
+    data: {
+      timestamp: number
+      sessionID: string
+    }
+  }
+}
+
+export type SyncEventSessionNextRevertCommitted = {
+  type: "sync"
+  id: string
+  syncEvent: {
+    type: "session.next.revert.committed.1"
+    id: string
+    seq: number
+    aggregateID: string
+    data: {
+      timestamp: number
+      sessionID: string
+      messageID: string
+    }
+  }
+}
+
 export type ConfigV2ReferenceGit = {
   repository: string
   branch?: string
@@ -3741,6 +3852,13 @@ export type SessionV2Info = {
   title: string
   location: LocationRef
   subpath?: string
+  revert?: {
+    messageID: string
+    partID?: string
+    snapshot?: string
+    diff?: string
+    files?: Array<FileDiff>
+  }
 }
 
 export type SessionInputAdmitted = {
@@ -3945,6 +4063,7 @@ export type SessionMessageAssistant = {
   snapshot?: {
     start?: string
     end?: string
+    files?: Array<string>
   }
   finish?: string
   cost?: number
@@ -4641,6 +4760,7 @@ export type V2EventSessionNextStepEnded = {
       }
     }
     snapshot?: string
+    files?: Array<string>
   }
 }
 
@@ -5063,6 +5183,68 @@ export type V2EventSessionNextCompactionEnded = {
   }
 }
 
+export type V2EventSessionNextRevertStaged = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  type: "session.next.revert.staged"
+  data: {
+    timestamp: number
+    sessionID: string
+    revert: {
+      messageID: string
+      partID?: string
+      snapshot?: string
+      diff?: string
+      files?: Array<FileDiff>
+    }
+  }
+}
+
+export type V2EventSessionNextRevertCleared = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  type: "session.next.revert.cleared"
+  data: {
+    timestamp: number
+    sessionID: string
+  }
+}
+
+export type V2EventSessionNextRevertCommitted = {
+  id: string
+  metadata?: {
+    [key: string]: unknown
+  }
+  durable?: {
+    aggregateID: string
+    seq: number
+    version: number
+  }
+  location?: LocationRef
+  type: "session.next.revert.committed"
+  data: {
+    timestamp: number
+    sessionID: string
+    messageID: string
+  }
+}
+
 export type V2EventMessagePartDelta = {
   id: string
   metadata?: {
@@ -6219,6 +6401,7 @@ export type EventSessionNextStepEnded = {
       }
     }
     snapshot?: string
+    files?: Array<string>
   }
 }
 
@@ -6479,6 +6662,41 @@ export type EventSessionNextCompactionEnded = {
   }
 }
 
+export type EventSessionNextRevertStaged = {
+  id: string
+  type: "session.next.revert.staged"
+  properties: {
+    timestamp: number
+    sessionID: string
+    revert: {
+      messageID: string
+      partID?: string
+      snapshot?: string
+      diff?: string
+      files?: Array<FileDiff>
+    }
+  }
+}
+
+export type EventSessionNextRevertCleared = {
+  id: string
+  type: "session.next.revert.cleared"
+  properties: {
+    timestamp: number
+    sessionID: string
+  }
+}
+
+export type EventSessionNextRevertCommitted = {
+  id: string
+  type: "session.next.revert.committed"
+  properties: {
+    timestamp: number
+    sessionID: string
+    messageID: string
+  }
+}
+
 export type EventMessagePartDelta = {
   id: string
   type: "message.part.delta"
@@ -11504,6 +11722,130 @@ export type V2SessionWaitResponses = {
 
 export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses]
 
+export type V2SessionRevertStageData = {
+  body: {
+    messageID: string
+    files?: boolean
+  }
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/revert/stage"
+}
+
+export type V2SessionRevertStageErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * MessageNotFoundError | SessionNotFoundError
+   */
+  404: MessageNotFoundError | SessionNotFoundError
+  /**
+   * UnknownError
+   */
+  500: UnknownError1
+}
+
+export type V2SessionRevertStageError = V2SessionRevertStageErrors[keyof V2SessionRevertStageErrors]
+
+export type V2SessionRevertStageResponses = {
+  /**
+   * Success
+   */
+  200: {
+    data: {
+      messageID: string
+      partID?: string
+      snapshot?: string
+      diff?: string
+      files?: Array<FileDiff>
+    }
+  }
+}
+
+export type V2SessionRevertStageResponse = V2SessionRevertStageResponses[keyof V2SessionRevertStageResponses]
+
+export type V2SessionRevertClearData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/revert/clear"
+}
+
+export type V2SessionRevertClearErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+  /**
+   * UnknownError
+   */
+  500: UnknownError1
+}
+
+export type V2SessionRevertClearError = V2SessionRevertClearErrors[keyof V2SessionRevertClearErrors]
+
+export type V2SessionRevertClearResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2SessionRevertClearResponse = V2SessionRevertClearResponses[keyof V2SessionRevertClearResponses]
+
+export type V2SessionRevertCommitData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/revert/commit"
+}
+
+export type V2SessionRevertCommitErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+}
+
+export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors]
+
+export type V2SessionRevertCommitResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses]
+
 export type V2SessionContextData = {
   body?: never
   path: {

+ 10 - 0
packages/server/src/errors.ts

@@ -61,6 +61,16 @@ export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoun
   { httpApiStatus: 404 },
 ) {}
 
+export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
+  "MessageNotFoundError",
+  {
+    sessionID: Schema.String,
+    messageID: Schema.String,
+    message: Schema.String,
+  },
+  { httpApiStatus: 404 },
+) {}
+
 export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
   "InvalidCursorError",
   { message: Schema.String },

+ 35 - 0
packages/server/src/groups/session.ts

@@ -11,6 +11,7 @@ import {
   ConflictError,
   InvalidCursorError,
   InvalidRequestError,
+  MessageNotFoundError,
   ServiceUnavailableError,
   SessionNotFoundError,
   UnknownError,
@@ -223,6 +224,40 @@ export const SessionGroup = HttpApiGroup.make("server.session")
         }),
       ),
   )
+  .add(
+    HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
+      params: { sessionID: SessionV2.ID },
+      payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
+      success: Schema.Struct({ data: SessionV2.RevertState }),
+      error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
+    })
+      .middleware(SessionLocationMiddleware)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.revert.stage",
+          summary: "Stage session revert",
+          description: "Stage or move a reversible session boundary and optionally apply its file changes.",
+        }),
+      ),
+  )
+  .add(
+    HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
+      params: { sessionID: SessionV2.ID },
+      success: HttpApiSchema.NoContent,
+      error: [SessionNotFoundError, UnknownError],
+    })
+      .middleware(SessionLocationMiddleware)
+      .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
+  )
+  .add(
+    HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
+      params: { sessionID: SessionV2.ID },
+      success: HttpApiSchema.NoContent,
+      error: SessionNotFoundError,
+    })
+      .middleware(SessionLocationMiddleware)
+      .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" })),
+  )
   .add(
     HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
       params: { sessionID: SessionV2.ID },

+ 1 - 8
packages/server/src/handlers/message.ts

@@ -58,14 +58,7 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
               const ref = `err_${crypto.randomUUID().slice(0, 8)}`
               return Effect.logError("failed to decode session message").pipe(
                 Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
-                Effect.andThen(
-                  Effect.fail(
-                    new UnknownError({
-                      message: "Unexpected server error. Check server logs for details.",
-                      ref,
-                    }),
-                  ),
-                ),
+                Effect.andThen(Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }))),
               )
             }),
           )

+ 86 - 8
packages/server/src/handlers/session.ts

@@ -6,6 +6,7 @@ import { SessionsCursor } from "../groups/session"
 import {
   ConflictError,
   InvalidCursorError,
+  MessageNotFoundError,
   ServiceUnavailableError,
   SessionNotFoundError,
   UnknownError,
@@ -206,22 +207,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
         }),
       )
       .handle(
-        "session.context",
+        "session.revert.stage",
         Effect.fn(function* (ctx) {
           return {
-            data: yield* session.context(ctx.params.sessionID).pipe(
-              Effect.catchTag("Session.NotFoundError", (error) =>
-                Effect.fail(
+            data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
+              Effect.catchTag(
+                "Session.NotFoundError",
+                (error) =>
                   new SessionNotFoundError({
                     sessionID: error.sessionID,
                     message: `Session not found: ${error.sessionID}`,
                   }),
-                ),
               ),
-              Effect.catchTag("Session.MessageDecodeError", (error) => {
+              Effect.catchTag(
+                "Session.MessageNotFoundError",
+                (error) =>
+                  new MessageNotFoundError({
+                    sessionID: error.sessionID,
+                    messageID: error.messageID,
+                    message: `Message not found: ${error.messageID}`,
+                  }),
+              ),
+              Effect.catchTag("Snapshot.Error", (error) => {
                 const ref = `err_${crypto.randomUUID().slice(0, 8)}`
-                return Effect.logError("failed to decode session message").pipe(
-                  Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
+                return Effect.logError("failed to stage session revert", { cause: error }).pipe(
                   Effect.andThen(
                     Effect.fail(
                       new UnknownError({
@@ -236,5 +245,74 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
           }
         }),
       )
+      .handle(
+        "session.revert.clear",
+        Effect.fn(function* (ctx) {
+          yield* session.revert.clear(ctx.params.sessionID).pipe(
+            Effect.catchTag(
+              "Session.NotFoundError",
+              (error) =>
+                new SessionNotFoundError({
+                  sessionID: error.sessionID,
+                  message: `Session not found: ${error.sessionID}`,
+                }),
+            ),
+            Effect.catchTag("Snapshot.Error", (error) => {
+              const ref = `err_${crypto.randomUUID().slice(0, 8)}`
+              return Effect.logError("failed to clear session revert", { cause: error }).pipe(
+                Effect.andThen(
+                  Effect.fail(
+                    new UnknownError({
+                      message: "Unexpected server error. Check server logs for details.",
+                      ref,
+                    }),
+                  ),
+                ),
+              )
+            }),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+      .handle(
+        "session.revert.commit",
+        Effect.fn(function* (ctx) {
+          yield* session.revert.commit(ctx.params.sessionID).pipe(
+            Effect.catchTag(
+              "Session.NotFoundError",
+              (error) =>
+                new SessionNotFoundError({
+                  sessionID: error.sessionID,
+                  message: `Session not found: ${error.sessionID}`,
+                }),
+            ),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+      .handle(
+        "session.context",
+        Effect.fn(function* (ctx) {
+          return {
+            data: yield* session.context(ctx.params.sessionID).pipe(
+              Effect.catchTag("Session.NotFoundError", (error) =>
+                Effect.fail(
+                  new SessionNotFoundError({
+                    sessionID: error.sessionID,
+                    message: `Session not found: ${error.sessionID}`,
+                  }),
+                ),
+              ),
+              Effect.catchTag("Session.MessageDecodeError", (error) => {
+                const ref = `err_${crypto.randomUUID().slice(0, 8)}`
+                return Effect.logError("failed to decode session message").pipe(
+                  Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
+                  Effect.andThen(Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }))),
+                )
+              }),
+            ),
+          }
+        }),
+      )
   }),
 )

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików