diff --git a/AGENTS.md b/AGENTS.md index 4c6be738db..2fe10ba44d 100644 --- a/AGENTS.md +++ b/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. diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index fa2a3cb549..02d426cd10 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/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, }), ), - ) + ) } }) diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts new file mode 100644 index 0000000000..87745c01ee --- /dev/null +++ b/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 diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index c87edb39dc..5c8930898c 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/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( diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index b7ef916330..a8f951fe06 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -1,31 +1,50 @@ export * as Git from "./git" import path from "path" +import { randomUUID } from "crypto" import { Context, Effect, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import { AbsolutePath } from "./schema" +import { AbsolutePath, RelativePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" import { LayerNode } from "./effect/layer-node" +import { File } from "./file" +import { KeyedMutex } from "./effect/keyed-mutex" -export interface Repo { - /** - * The root directory of the working tree that contains the input path. - * - * For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`. - * For `/home/me/app-feature/src/file.ts` in a linked worktree, this is - * `/home/me/app-feature`. - */ - readonly directory: AbsolutePath - /** - * The shared Git storage directory used by this repo and any linked worktrees. - * - * For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`. - * For a linked worktree at `/home/me/app-feature` whose main checkout is - * `/home/me/app`, this is usually `/home/me/app/.git`. - */ - readonly store: AbsolutePath -} +export class Repository extends Schema.Class("Git.Repository")({ + worktree: AbsolutePath, + gitDirectory: AbsolutePath, + commonDirectory: AbsolutePath, +}) {} + +export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet")) +export type ChangeSet = typeof ChangeSet.Type + +export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID")) +export type TreeID = typeof TreeID.Type + +export class OperationError extends Schema.TaggedErrorClass()("Git.OperationError", { + operation: Schema.Literals([ + "clone", + "fetch", + "checkout", + "reset", + "create", + "refresh", + "write_tree", + "list_files", + "diff", + "restore", + ]), + message: Schema.String, + directory: Schema.optional(AbsolutePath), + cause: Schema.optional(Schema.Defect()), +}) {} + +export class Worktree extends Schema.Class("Git.Worktree")({ + directory: AbsolutePath, + kind: Schema.Literals(["main", "linked"]), +}) {} export class WorktreeError extends Schema.TaggedErrorClass()("Git.WorktreeError", { operation: Schema.Literals(["create", "remove", "list"]), @@ -43,35 +62,112 @@ export class PatchError extends Schema.TaggedErrorClass()("Git.Patch }) {} export interface Interface { - readonly find: (input: AbsolutePath) => Effect.Effect - readonly remote: (repo: Repo, name?: string) => Effect.Effect - readonly roots: (repo: Repo) => Effect.Effect - readonly origin: (directory: string) => Effect.Effect - readonly head: (directory: string) => Effect.Effect - readonly dir: (directory: string) => Effect.Effect - readonly branch: (directory: string) => Effect.Effect - readonly remoteHead: (directory: string) => Effect.Effect - readonly clone: (input: { - remote: string - target: string - branch?: string - depth?: number - }) => Effect.Effect - readonly fetch: (directory: string) => Effect.Effect - readonly fetchBranch: (directory: string, branch: string) => Effect.Effect - readonly checkout: (directory: string, branch: string) => Effect.Effect - readonly reset: (directory: string, target: string) => Effect.Effect - readonly patch: (directory: AbsolutePath) => Effect.Effect - readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect - readonly resetChanges: (directory: AbsolutePath) => Effect.Effect - readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect - readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect - readonly worktreeRemove: (input: { - repo: Repo - directory: AbsolutePath - force: boolean - }) => Effect.Effect - readonly worktreeList: (repo: Repo) => Effect.Effect + readonly repo: { + readonly discover: (input: AbsolutePath) => Effect.Effect + readonly clone: (input: { + remote: string + directory: AbsolutePath + branch?: string + depth?: number + }) => Effect.Effect + readonly create: (input: { + worktree: AbsolutePath + gitDirectory: AbsolutePath + seed?: Repository + }) => Effect.Effect + } + readonly remote: { + readonly get: (repository: Repository, name?: string) => Effect.Effect + } + readonly history: { + readonly head: (repository: Repository) => Effect.Effect + readonly branch: (repository: Repository) => Effect.Effect + readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect + readonly rootCommits: (repository: Repository) => Effect.Effect + } + readonly sync: { + readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect + readonly fetchBranch: ( + repository: Repository, + input: { remote?: string; branch: string; force?: boolean }, + ) => Effect.Effect + readonly checkoutRemoteBranch: ( + repository: Repository, + input: { remote?: string; branch: string; reset?: boolean }, + ) => Effect.Effect + readonly resetHard: (repository: Repository, revision: string) => Effect.Effect + } + readonly change: { + readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect + readonly apply: (input: { + repository: Repository + path: AbsolutePath + changes: ChangeSet + }) => Effect.Effect + readonly discard: (input: { + repository: Repository + path: AbsolutePath + index: "preserve" | "reset" + untracked: "preserve" | "remove" + }) => Effect.Effect + } + readonly worktree: { + readonly create: (input: { + repository: Repository + directory: AbsolutePath + }) => Effect.Effect + readonly remove: (input: { + repository: Repository + directory: AbsolutePath + force: boolean + }) => Effect.Effect + readonly list: (repository: Repository) => Effect.Effect + } + readonly index: { + /** Refresh only the requested project-relative scope, preserving all other entries. */ + readonly refresh: (input: { + repository: Repository + scope: RelativePath + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError> + readonly ignored: (input: { + repository: Repository + paths: readonly RelativePath[] + }) => Effect.Effect, OperationError> + } + readonly tree: { + readonly capture: (input: { + repository: Repository + scopes: readonly RelativePath[] + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => Effect.Effect + readonly write: (repository: Repository) => Effect.Effect + readonly files: (input: { + repository: Repository + from: TreeID + to: TreeID + }) => Effect.Effect + readonly diff: (input: { + repository: Repository + from: TreeID + to: TreeID + context?: number + paths?: readonly RelativePath[] + }) => Effect.Effect + readonly preview: (input: { + repository: Repository + current: TreeID + files: ReadonlyMap + context?: number + }) => Effect.Effect + readonly restore: (input: { + repository: Repository + files: ReadonlyMap + }) => Effect.Effect + readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/GitV2") {} @@ -81,8 +177,11 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const proc = yield* AppProcess.Service + const locks = KeyedMutex.makeUnsafe() + const locked = (repository: Repository, effect: Effect.Effect) => + locks.withLock(repository.gitDirectory)(effect) - const find = Effect.fn("Git.find")(function* (input: AbsolutePath) { + const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) { const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe( Effect.map((matches) => matches[0]), Effect.catch(() => Effect.succeed(undefined)), @@ -92,23 +191,25 @@ export const layer = Layer.effect( const cwd = path.dirname(dotgit) const git = run(cwd, proc) const topLevel = yield* git(["rev-parse", "--show-toplevel"]) + const gitDir = yield* git(["rev-parse", "--git-dir"]) const commonDir = yield* git(["rev-parse", "--git-common-dir"]) - if (commonDir.exitCode !== 0) return undefined + if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined - return { - directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), - store: AbsolutePath.make(resolvePath(cwd, commonDir.text)), - } satisfies Repo + return new Repository({ + worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd), + gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)), + commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)), + }) }) - const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") { - const result = yield* run(repo.directory, proc)(["remote", "get-url", name]) + const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") { + const result = yield* run(repository.worktree, proc)(["remote", "get-url", name]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const roots = Effect.fn("Git.roots")(function* (repo: Repo) { - const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"]) + const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"]) if (result.exitCode !== 0) return [] return result.text .split("\n") @@ -117,116 +218,565 @@ export const layer = Layer.effect( .toSorted() }) - const origin = Effect.fn("Git.origin")(function* (directory: string) { - const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"]) + const head = Effect.fn("Git.history.head")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const head = Effect.fn("Git.head")(function* (directory: string) { - const result = yield* run(directory, proc)(["rev-parse", "HEAD"]) + const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) { + const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"]) if (result.exitCode !== 0) return undefined return result.text.trim() || undefined }) - const dir = Effect.fn("Git.dir")(function* (directory: string) { - const result = yield* run(directory, proc)(["rev-parse", "--git-dir"]) + const remoteHead = Effect.fn("Git.history.defaultRemoteBranch")(function* ( + repository: Repository, + remoteName = "origin", + ) { + const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]) if (result.exitCode !== 0) return undefined - return AbsolutePath.make(resolvePath(directory, result.text)) + return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined }) - const branch = Effect.fn("Git.branch")(function* (directory: string) { - const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"]) - if (result.exitCode !== 0) return undefined - return result.text.trim() || undefined + const operation = Effect.fnUntraced(function* ( + operation: OperationError["operation"], + directory: AbsolutePath, + args: string[], + ) { + const result = yield* execute(directory, proc)(args).pipe( + Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })), + ) + if (result.exitCode === 0) return + return yield* new OperationError({ + operation, + directory, + message: result.stderr.trim() || result.text.trim() || `Git ${operation} failed`, + }) }) - const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) { - const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"]) - if (result.exitCode !== 0) return undefined - return result.text.trim().replace(/^refs\/remotes\//, "") || undefined - }) - - const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) => - execute( - path.dirname(input.target), - proc, - )([ + const clone = Effect.fn("Git.repo.clone")(function* (input: { + remote: string + directory: AbsolutePath + branch?: string + depth?: number + }) { + yield* operation("clone", AbsolutePath.make(path.dirname(input.directory)), [ "clone", "--depth", String(input.depth ?? 100), ...(input.branch ? ["--branch", input.branch] : []), "--", input.remote, - input.target, - ]), - ) + input.directory, + ]) + const repository = yield* discover(input.directory) + if (repository) return repository + return yield* new OperationError({ + operation: "clone", + directory: input.directory, + message: "Cloned repository could not be opened", + }) + }) - const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"])) + const fetch = Effect.fn("Git.sync.fetchRemotes")(function* ( + repository: Repository, + input: { prune?: boolean } = {}, + ) { + yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])]) + }) - const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) => - execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]), - ) + const fetchBranch = Effect.fn("Git.sync.fetchBranch")(function* ( + repository: Repository, + input: { remote?: string; branch: string; force?: boolean }, + ) { + const remoteName = input.remote ?? "origin" + const spec = `refs/heads/${input.branch}:refs/remotes/${remoteName}/${input.branch}` + yield* operation("fetch", repository.worktree, ["fetch", remoteName, input.force === false ? spec : `+${spec}`]) + }) - const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) => - execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]), - ) + const checkout = Effect.fn("Git.sync.checkoutRemoteBranch")(function* ( + repository: Repository, + input: { remote?: string; branch: string; reset?: boolean }, + ) { + const remoteName = input.remote ?? "origin" + yield* operation("checkout", repository.worktree, [ + "checkout", + ...(input.reset === false ? [input.branch] : ["-B", input.branch, `${remoteName}/${input.branch}`]), + ]) + }) - const reset = Effect.fn("Git.reset")((directory: string, target: string) => - execute(directory, proc)(["reset", "--hard", target]), - ) + const reset = Effect.fn("Git.sync.resetHard")(function* ( + repository: Repository, + revision: string, + ) { + yield* operation("reset", repository.worktree, ["reset", "--hard", revision]) + }) - const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) { - const root = yield* execute( - directory, - proc, - )(["rev-parse", "--show-toplevel"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + const repositoryArgs = (repository: Repository, args: string[]) => [ + "--git-dir", + repository.gitDirectory, + "--work-tree", + repository.worktree, + ...args, + ] + + const repositoryOperation = Effect.fnUntraced(function* ( + operationName: OperationError["operation"], + repository: Repository, + args: string[], + options?: { stdin?: string; env?: Record }, + ) { + const result = yield* proc + .run( + ChildProcess.make("git", repositoryArgs(repository, args), { + cwd: repository.worktree, + env: options?.env, + extendEnv: true, + }), + { stdin: options?.stdin }, + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: operationName, + directory: repository.worktree, + message: cause.message, + cause, + }), + ), + ) + const text = result.stdout.toString("utf8") + if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") } + return yield* new OperationError({ + operation: operationName, + directory: repository.worktree, + message: result.stderr.toString("utf8").trim() || text.trim() || `Git ${operationName} failed`, + }) + }) + + const create = Effect.fn("Git.repo.create")(function* (input: { + worktree: AbsolutePath + gitDirectory: AbsolutePath + seed?: Repository + }) { + yield* fs.ensureDir(input.gitDirectory).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to create Git storage", + cause, + }), + ), ) - if (root.exitCode !== 0) { - return yield* new PatchError({ - operation: "capture", - directory, - message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root", + const repository = new Repository({ + worktree: input.worktree, + gitDirectory: input.gitDirectory, + commonDirectory: input.gitDirectory, + }) + yield* repositoryOperation("create", repository, ["init"]) + yield* Effect.forEach( + [ + ["core.autocrlf", "false"], + ["core.longpaths", "true"], + ["core.symlinks", "true"], + ["core.fsmonitor", "false"], + ["feature.manyFiles", "true"], + ["index.version", "4"], + ["index.threads", "true"], + ["core.untrackedCache", "true"], + ], + ([key, value]) => repositoryOperation("create", repository, ["config", key, value]), + { discard: true }, + ) + if (!input.seed) return repository + yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to configure shared Git objects", + cause, + }), + ), + ) + yield* fs.writeFileString( + path.join(input.gitDirectory, "objects", "info", "alternates"), + path.join(input.seed.commonDirectory, "objects") + "\n", + ).pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "create", + directory: input.gitDirectory, + message: "Failed to configure shared Git objects", + cause, + }), + ), + ) + yield* fs + .copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index")) + .pipe(Effect.catch(() => Effect.void)) + return repository + }) + + const refresh = Effect.fn("Git.index.refresh")(function* (input: { + repository: Repository + scope: RelativePath + ignores?: Repository + maximumUntrackedFileBytes?: number + }) { + const list = (args: string[]) => + repositoryOperation("refresh", input.repository, args).pipe( + Effect.map((result) => result.text.split("\0").filter(Boolean)), + ) + const [tracked, untracked] = yield* Effect.all( + [ + list(["diff-files", "--name-only", "-z", "--", input.scope]), + list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]), + ], + { concurrency: 2 }, + ) + const candidates = Array.from(new Set([...tracked, ...untracked])) + if (!candidates.length) return { skipped: [] } + const ignored = input.ignores + ? new Set( + ( + yield* repositoryOperation( + "refresh", + input.ignores, + ["check-ignore", "--no-index", "--stdin", "-z"], + { stdin: candidates.join("\0") + "\0" }, + ).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" }))) + ).text + .split("\0") + .filter(Boolean), + ) + : new Set() + const allowed = candidates.filter((item) => !ignored.has(item)) + const maximum = input.maximumUntrackedFileBytes + const skipped = maximum + ? ( + yield* Effect.forEach( + untracked.filter((item) => allowed.includes(item)), + (item) => + fs.stat(path.join(input.repository.worktree, item)).pipe( + Effect.map((info) => + info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined, + ), + Effect.catch(() => Effect.succeed(undefined)), + ), + { concurrency: 8 }, + ) + ).filter((item): item is RelativePath => item !== undefined) + : [] + const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item))) + const remove = [...ignored, ...skipped] + if (remove.length) + yield* repositoryOperation( + "refresh", + input.repository, + ["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"], + { stdin: remove.join("\0") + "\0" }, + ) + if (stage.length) + yield* repositoryOperation( + "refresh", + input.repository, + ["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"], + { stdin: stage.join("\0") + "\0" }, + ) + return { skipped } + }) + + const ignored = Effect.fn("Git.index.ignored")(function* (input: { + repository: Repository + paths: readonly RelativePath[] + }) { + if (!input.paths.length) return new Set() + const result = yield* proc + .run( + ChildProcess.make( + "git", + repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), + { cwd: input.repository.worktree, extendEnv: true }, + ), + { stdin: input.paths.join("\0") + "\0" }, + ) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "list_files", + directory: input.repository.worktree, + message: cause.message, + cause, + }), + ), + ) + if (result.exitCode !== 0 && result.exitCode !== 1) + return yield* new OperationError({ + operation: "list_files", + directory: input.repository.worktree, + message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths", }) - } - const repo = AbsolutePath.make(resolvePath(directory, root.text)) - const scope = path.relative(repo, directory).replaceAll("\\", "/") || "." + return new Set( + result.stdout + .toString("utf8") + .split("\0") + .filter(Boolean) + .map((file) => RelativePath.make(file)), + ) + }) + + const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) { + return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim()) + }) + + const captureTree = Effect.fn("Git.tree.capture")((input: { + repository: Repository + scopes: readonly RelativePath[] + ignores?: Repository + maximumUntrackedFileBytes?: number + }) => + locked( + input.repository, + Effect.gen(function* () { + yield* Effect.forEach( + input.scopes, + (scope) => refresh({ ...input, scope }), + { discard: true }, + ) + return yield* writeTree(input.repository) + }), + ), + ) + + const treeFiles = Effect.fn("Git.tree.files")(function* (input: { + repository: Repository + from: TreeID + to: TreeID + }) { + return (yield* repositoryOperation("list_files", input.repository, ["diff", "--name-only", "-z", input.from, input.to])) + .text.split("\0") + .filter(Boolean) + .map((file) => RelativePath.make(file)) + }) + + const treeDiff = Effect.fn("Git.tree.diff")(function* (input: { + repository: Repository + from: TreeID + to: TreeID + context?: number + paths?: readonly RelativePath[] + }) { + const paths = input.paths ?? (yield* treeFiles(input)) + return yield* Effect.forEach(paths, (file) => + Effect.gen(function* () { + const statusText = ( + yield* repositoryOperation("diff", input.repository, [ + "diff", + "--name-status", + "--no-renames", + input.from, + input.to, + "--", + file, + ]) + ).text.trim() + const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified" + const stats = ( + yield* repositoryOperation("diff", input.repository, [ + "diff", + "--numstat", + "--no-renames", + input.from, + input.to, + "--", + file, + ]) + ).text.split("\t") + const binary = stats[0] === "-" || stats[1] === "-" + const patch = binary + ? "" + : ( + yield* repositoryOperation("diff", input.repository, [ + "diff", + `--unified=${input.context ?? 3}`, + "--no-renames", + input.from, + input.to, + "--", + file, + ]) + ).text + return { + path: file, + status, + additions: binary ? 0 : Number(stats[0] ?? 0), + deletions: binary ? 0 : Number(stats[1] ?? 0), + patch, + } satisfies File.Diff + }), + ) + }) + + const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) { + const text = ( + yield* repositoryOperation("restore", repository, ["ls-tree", "-z", tree, "--", file]) + ).text.replace(/\0$/, "") + if (!text) return + const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/) + if (!match) return yield* new OperationError({ + operation: "restore", + directory: repository.worktree, + message: `Invalid tree entry for ${file}`, + }) + return { mode: match[1], object: match[2] } + }) + + const preview = Effect.fn("Git.tree.preview")((input: { + repository: Repository + current: TreeID + files: ReadonlyMap + context?: number + }) => + locked( + input.repository, + Effect.gen(function* () { + const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`) + const env = { GIT_INDEX_FILE: index } + return yield* Effect.gen(function* () { + yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env }) + yield* Effect.forEach( + input.files, + ([file, tree]) => + Effect.gen(function* () { + const source = yield* entry(input.repository, tree, file) + if (!source) { + yield* repositoryOperation( + "diff", + input.repository, + ["update-index", "--force-remove", "--", file], + { env }, + ) + return + } + yield* repositoryOperation( + "diff", + input.repository, + ["update-index", "--add", "--cacheinfo", source.mode, source.object, file], + { env }, + ) + }), + { discard: true }, + ) + const target = TreeID.make( + (yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(), + ) + return yield* treeDiff({ + repository: input.repository, + from: input.current, + to: target, + context: input.context, + paths: Array.from(input.files.keys()), + }) + }).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void)))) + }), + ), + ) + + const restore = Effect.fn("Git.tree.restore")((input: { + repository: Repository + files: ReadonlyMap + }) => + locked( + input.repository, + Effect.forEach( + input.files, + ([file, tree]) => + Effect.gen(function* () { + if (yield* entry(input.repository, tree, file)) { + yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file]) + return + } + yield* fs + .remove(path.join(input.repository.worktree, file), { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new OperationError({ + operation: "restore", + directory: input.repository.worktree, + message: `Failed to remove ${file}`, + cause, + }), + ), + ) + }), + { discard: true }, + ), + ), + ) + + const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) => + locked( + input.repository, + Effect.gen(function* () { + yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree]) + yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"]) + }), + ), + ) + + const capture = Effect.fn("Git.change.capture")(function* (input: { + repository: Repository + path: AbsolutePath + }) { + const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "." const tracked = yield* execute( - repo, + input.repository.worktree, proc, )(["diff", "--binary", "HEAD", "--", scope]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + Effect.mapError( + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), + ), ) if (tracked.exitCode !== 0) { return yield* new PatchError({ operation: "capture", - directory, + directory: input.path, message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes", }) } const untracked = yield* execute( - repo, + input.repository.worktree, proc, )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })), + Effect.mapError( + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), + ), ) if (untracked.exitCode !== 0) { return yield* new PatchError({ operation: "capture", - directory, + directory: input.path, message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes", }) } const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) => execute( - repo, + input.repository.worktree, proc, )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe( Effect.mapError( - (cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }), + (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }), ), Effect.flatMap((result) => // git diff --no-index returns 1 when differences were found. @@ -235,7 +785,7 @@ export const layer = Layer.effect( : Effect.fail( new PatchError({ operation: "capture", - directory, + directory: input.path, message: result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`, }), @@ -243,95 +793,78 @@ export const layer = Layer.effect( ), ), ) - return [tracked.text, ...created].filter(Boolean).join("\n") + return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n")) }) - const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) { + const apply = Effect.fn("Git.change.apply")(function* (input: { + repository: Repository + path: AbsolutePath + changes: ChangeSet + }) { const result = yield* proc .run( ChildProcess.make("git", ["apply", "-"], { - cwd: input.directory, + cwd: input.path, extendEnv: true, - stdin: Stream.make(new TextEncoder().encode(input.patch)), + stdin: Stream.make(new TextEncoder().encode(input.changes)), }), ) .pipe( Effect.mapError( (cause) => - new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }), + new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }), ), ) if (result.exitCode === 0) return return yield* new PatchError({ operation: "apply", - directory: input.directory, + directory: input.path, message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes", }) }) - const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) { - const reset = yield* execute( - directory, - proc, - )(["reset", "--hard", "HEAD"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + const discard = Effect.fn("Git.change.discard")(function* (input: { + repository: Repository + path: AbsolutePath + index: "preserve" | "reset" + untracked: "preserve" | "remove" + }) { + const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "." + const restore = yield* execute(input.repository.worktree, proc)( + input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope], + ).pipe( + Effect.mapError( + (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }), + ), ) - if (reset.exitCode !== 0) { + if (restore.exitCode !== 0) { return yield* new PatchError({ operation: "reset", - directory, - message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes", + directory: input.path, + message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes", }) } - const clean = yield* execute( - directory, - proc, - )(["clean", "-fd"]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), + if (input.untracked === "preserve") return + const clean = yield* execute(input.repository.worktree, proc)(["clean", "-fd", "--", scope]).pipe( + Effect.mapError( + (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }), + ), ) if (clean.exitCode === 0) return return yield* new PatchError({ operation: "reset", - directory, + directory: input.path, message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", }) }) - const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) { - const checkout = yield* execute( - directory, - proc, - )(["checkout", "--", "."]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), - ) - if (checkout.exitCode !== 0) { - return yield* new PatchError({ - operation: "reset", - directory, - message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes", - }) - } - const clean = yield* execute( - directory, - proc, - )(["clean", "-fd", "--", "."]).pipe( - Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })), - ) - if (clean.exitCode === 0) return - return yield* new PatchError({ - operation: "reset", - directory, - message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes", - }) - }) - - const worktree = Effect.fnUntraced(function* ( + const worktreeRun = Effect.fnUntraced(function* ( operation: "create" | "remove" | "list", - repo: Repo, + repository: Repository, args: string[], worktreeDirectory?: AbsolutePath, - cwd = repo.directory, + cwd = repository.worktree, ) { const result = yield* proc .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" })) @@ -350,52 +883,61 @@ export const layer = Layer.effect( }) }) - const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) { - yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory) + const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: { + repository: Repository + directory: AbsolutePath + }) { + yield* worktreeRun( + "create", + input.repository, + ["worktree", "add", "--detach", input.directory, "HEAD"], + input.directory, + ) + const repository = yield* discover(input.directory) + if (repository) return repository + return yield* new WorktreeError({ + operation: "create", + directory: input.directory, + message: "Created worktree could not be opened", + }) }) - const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { - repo: Repo + const worktreeRemove = Effect.fn("Git.worktree.remove")(function* (input: { + repository: Repository directory: AbsolutePath force: boolean }) { - yield* worktree( + yield* worktreeRun( "remove", - input.repo, + input.repository, ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory], input.directory, - input.repo.store, + input.repository.commonDirectory, ) }) - const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) { - return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"])) + const worktreeList = Effect.fn("Git.worktree.list")(function* (repository: Repository) { + return (yield* worktreeRun("list", repository, ["worktree", "list", "--porcelain"])) .split("\n") .filter((line) => line.startsWith("worktree ")) - .map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim()))) + .map( + (line, index) => + new Worktree({ + directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())), + kind: index === 0 ? "main" : "linked", + }), + ) }) return Service.of({ - find, - remote, - roots, - origin, - head, - dir, - branch, - remoteHead, - clone, - fetch, - fetchBranch, - checkout, - reset, - patch, - applyPatch, - resetChanges, - softResetChanges, - worktreeCreate, - worktreeRemove, - worktreeList, + repo: { discover, clone, create }, + remote: { get: remote }, + history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots }, + sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset }, + change: { capture, apply, discard }, + worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList }, + index: { refresh, ignored }, + tree: { capture: captureTree, write: writeTree, files: treeFiles, diff: treeDiff, preview, restore, checkout: checkoutTree }, }) }), ) @@ -403,7 +945,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) -export interface Result { +interface Result { readonly exitCode: number readonly text: string readonly stderr: string diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 89ca950025..bfbf5c6eed 100644 --- a/packages/core/src/location-layer.ts +++ b/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()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { @@ -96,11 +97,13 @@ export class LocationServiceMap extends LayerMap.Service()(" 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()(" todos, questions, model, + snapshot, runner, builtInTools, referenceGuidance, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c439da0fdb..e94178122c 100644 --- a/packages/core/src/project.ts +++ b/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 }, } }) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index 1199964f6c..59466b9901 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/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 }) { - 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))) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 894dc38faa..3cc3e53dd7 100644 --- a/packages/core/src/repository-cache.ts +++ b/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 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(effect: Effect.Effect, 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" diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 888052411a..4498a37392 100644 --- a/packages/core/src/session.ts +++ b/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 Effect.Effect readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect + readonly revert: { + readonly stage: (input: { + sessionID: SessionSchema.ID + messageID: SessionMessage.ID + files?: boolean + }) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect + readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect + } } export class Service extends Context.Service()("@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), diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 2308d06460..0101a34f48 100644 --- a/packages/core/src/session/info.ts +++ b/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), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 430172ca83..a97afb58b6 100644 --- a/packages/core/src/session/message-updater.ts +++ b/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, }) }) } diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4a0512d475..ce40c416e7 100644 --- a/packages/core/src/session/projector.ts +++ b/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) + }), + ) }), ) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts new file mode 100644 index 0000000000..204f9cc9d4 --- /dev/null +++ b/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()( + "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() + 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() + 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, + }) +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2cd77beb07..fc69fb394c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/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 = []) => @@ -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()) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index b412edba0d..33652a618c 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/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 } | 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, } } diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a9499554b4..264a1d2cca 100644 --- a/packages/core/src/session/sql.ts +++ b/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 @@ -37,7 +38,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), 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(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index b39c0f7f01..07b0867021 100644 --- a/packages/core/src/snapshot.ts +++ b/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()("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 +} + +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 + + /** + * List project-relative paths changed between two captured trees without + * loading file contents or generating patches. + */ + readonly files: (input: CompareInput) => Effect.Effect + + /** + * Generate structured per-file diffs between two captured trees. `context` + * controls unchanged lines around each unified diff hunk. + */ + readonly diff: (input: DiffInput) => Effect.Effect + + /** + * 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 + + /** + * 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 + + /** + * 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 +} + +export class Service extends Context.Service()("@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() + 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() + 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" } diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index e09e3e1a5d..a2437ad22e 100644 --- a/packages/core/test/git.test.ts +++ b/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 }) + 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") }), ) }) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 84efa4cbad..0a7db8826f 100644 --- a/packages/core/test/move-session.test.ts +++ b/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), diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 0f07ed5476..024b36d439 100644 --- a/packages/core/test/permission.test.ts +++ b/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), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 85c7054213..7826d6bf28 100644 --- a/packages/core/test/session-create.test.ts +++ b/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), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 277e1d52f0..321e8aca9e 100644 --- a/packages/core/test/session-projector.test.ts +++ b/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), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 1ab6c64c61..af30c4d877 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/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), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 926248c0b0..077ee48823 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/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), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index f2d18cfa2d..f96ea4dea2 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/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" }) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index afb4f9ad1f..6df50ac575 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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), diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts new file mode 100644 index 0000000000..3e7ee301dd --- /dev/null +++ b/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"))) +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 64d4111088..2d13c2e2f3 100644 --- a/packages/opencode/src/session/session.ts +++ b/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, diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index a655039a3d..da0f80cee5 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/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) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 728810e6ef..71f2b9ef43 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/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) => ({ diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index afeffe0690..63ecfad5e0 100644 --- a/packages/schema/src/index.ts +++ b/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" diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts new file mode 100644 index 0000000000..16e36e1f40 --- /dev/null +++ b/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 diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index e7ae782f25..2e71667e37 100644 --- a/packages/schema/src/session-event.ts +++ b/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")) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 3631c3072c..63cc25b43d 100644 --- a/packages/schema/src/session-message.ts +++ b/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), diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 2bed421d29..952730b5ca 100644 --- a/packages/schema/src/session.ts +++ b/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({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d501e28132..4708b584bd 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/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( + parameters: { + sessionID: string + messageID?: string + files?: boolean + }, + options?: Options, + ) { + 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( + parameters: { + sessionID: string + }, + options?: Options, + ) { + 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( + parameters: { + sessionID: string + }, + options?: Options, + ) { + 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 })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3116c295f1..2db1871772 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/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 } } | { @@ -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 + } + } + } + | { + 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 } } } @@ -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 + } + } + } +} + +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 + } } export type SessionInputAdmitted = { @@ -3945,6 +4063,7 @@ export type SessionMessageAssistant = { snapshot?: { start?: string end?: string + files?: Array } finish?: string cost?: number @@ -4641,6 +4760,7 @@ export type V2EventSessionNextStepEnded = { } } snapshot?: string + files?: Array } } @@ -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 + } + } +} + +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 } } @@ -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 + } + } +} + +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 + } + } +} + +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 = { + /** + * + */ + 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 = { + /** + * + */ + 204: void +} + +export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses] + export type V2SessionContextData = { body?: never path: { diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 2cf1eea583..3b1eced63a 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -61,6 +61,16 @@ export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "MessageNotFoundError", + { + sessionID: Schema.String, + messageID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class InvalidCursorError extends Schema.TaggedErrorClass()( "InvalidCursorError", { message: Schema.String }, diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index 6a4f4aff60..0df59e0c44 100644 --- a/packages/server/src/groups/session.ts +++ b/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 }, diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index eb5758c247..3daafcf883 100644 --- a/packages/server/src/handlers/message.ts +++ b/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 }))), ) }), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 1fe860e528..8fd414c51d 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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 }))), ) }), ),