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

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 })
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: 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")
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")
}),
)
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { SessionsCursor } from "../groups/session"
import {
ConflictError,
InvalidCursorError,
MessageNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
@ -205,6 +206,90 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.revert.stage",
Effect.fn(function* (ctx) {
return {
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.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 stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
.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) {
@ -222,14 +307,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (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 }))),
)
}),
),