Compare commits

...

6 Commits

Author SHA1 Message Date
Dax Raad db0207d01e fix(core): align snapshot error schemas
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-06-21 08:15:04 -04:00
Dax Raad 93141d1554 feat(session): commit message reverts 2026-06-21 08:13:44 -04:00
Dax Raad 46e77b0730 feat(session): add revert preview api 2026-06-21 08:13:44 -04:00
Dax Raad baf0acd7b5 feat(core): integrate session snapshots 2026-06-21 08:13:44 -04:00
Dax Raad 9afa0b5f8a refactor(core): generalize git service 2026-06-21 08:13:06 -04:00
Dax Raad 19d60d0bf5 feat(core): add snapshot service contract 2026-06-21 08:13:06 -04:00
52 changed files with 2002 additions and 480 deletions
+1
View File
@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
Reduce total variable count by inlining when a value is only used once.
@@ -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,
}),
),
)
)
}
})
+13
View File
@@ -0,0 +1,13 @@
export * as File from "./file"
import { Schema } from "effect"
import { NonNegativeInt, RelativePath } from "./schema"
export const Diff = Schema.Struct({
path: RelativePath,
status: Schema.Literals(["added", "modified", "deleted"]),
additions: NonNegativeInt,
deletions: NonNegativeInt,
patch: Schema.String,
}).annotate({ identifier: "File.Diff" })
export type Diff = typeof Diff.Type
+1 -1
View File
@@ -125,4 +125,4 @@ const baseLayer = Layer.effect(
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
export const locationLayer = baseLayer.pipe(Layer.provideMerge(FileSystemSearch.defaultLayer))
+2 -2
View File
@@ -119,7 +119,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(
@@ -139,4 +139,4 @@ export const layer = Layer.effect(
),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
+687 -200
View File
@@ -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<Repository>("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<OperationError>()("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<Worktree>("Git.Worktree")({
directory: AbsolutePath,
kind: Schema.Literals(["main", "linked"]),
}) {}
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
operation: Schema.Literals(["create", "remove", "list"]),
@@ -43,35 +62,107 @@ export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.Patch
}) {}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
readonly roots: (repo: Repo) => Effect.Effect<string[]>
readonly origin: (directory: string) => Effect.Effect<string | undefined>
readonly head: (directory: string) => Effect.Effect<string | undefined>
readonly dir: (directory: string) => Effect.Effect<string | undefined>
readonly branch: (directory: string) => Effect.Effect<string | undefined>
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
readonly clone: (input: {
remote: string
target: string
branch?: string
depth?: number
}) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: {
repo: Repo
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
readonly repo: {
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
readonly clone: (input: {
remote: string
directory: AbsolutePath
branch?: string
depth?: number
}) => Effect.Effect<Repository, OperationError>
readonly create: (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
seed?: Repository
}) => Effect.Effect<Repository, OperationError>
}
readonly remote: {
readonly get: (repository: Repository, name?: string) => Effect.Effect<string | undefined>
}
readonly history: {
readonly head: (repository: Repository) => Effect.Effect<string | undefined>
readonly branch: (repository: Repository) => Effect.Effect<string | undefined>
readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect<string | undefined>
readonly rootCommits: (repository: Repository) => Effect.Effect<readonly string[]>
}
readonly sync: {
readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect<void, OperationError>
readonly fetchBranch: (
repository: Repository,
input: { remote?: string; branch: string; force?: boolean },
) => Effect.Effect<void, OperationError>
readonly checkoutRemoteBranch: (
repository: Repository,
input: { remote?: string; branch: string; reset?: boolean },
) => Effect.Effect<void, OperationError>
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
}
readonly change: {
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
readonly apply: (input: {
repository: Repository
path: AbsolutePath
changes: ChangeSet
}) => Effect.Effect<void, PatchError>
readonly discard: (input: {
repository: Repository
path: AbsolutePath
index: "preserve" | "reset"
untracked: "preserve" | "remove"
}) => Effect.Effect<void, PatchError>
}
readonly worktree: {
readonly create: (input: {
repository: Repository
directory: AbsolutePath
}) => Effect.Effect<Repository, WorktreeError>
readonly remove: (input: {
repository: Repository
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, WorktreeError>
readonly list: (repository: Repository) => Effect.Effect<readonly Worktree[], WorktreeError>
}
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 tree: {
readonly capture: (input: {
repository: Repository
scopes: readonly RelativePath[]
ignores?: Repository
maximumUntrackedFileBytes?: number
}) => Effect.Effect<TreeID, OperationError>
readonly write: (repository: Repository) => Effect.Effect<TreeID, OperationError>
readonly files: (input: {
repository: Repository
from: TreeID
to: TreeID
}) => Effect.Effect<readonly RelativePath[], OperationError>
readonly diff: (input: {
repository: Repository
from: TreeID
to: TreeID
context?: number
paths?: readonly RelativePath[]
}) => Effect.Effect<readonly File.Diff[], OperationError>
readonly preview: (input: {
repository: Repository
current: TreeID
files: ReadonlyMap<RelativePath, TreeID>
context?: number
}) => Effect.Effect<readonly File.Diff[], OperationError>
readonly restore: (input: {
repository: Repository
files: ReadonlyMap<RelativePath, TreeID>
}) => Effect.Effect<void, OperationError>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
@@ -81,8 +172,11 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const proc = yield* AppProcess.Service
const locks = KeyedMutex.makeUnsafe<string>()
const locked = <A, E, R>(repository: Repository, effect: Effect.Effect<A, E, R>) =>
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 +186,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 +213,515 @@ 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<string, string> },
) {
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 repo = AbsolutePath.make(resolvePath(directory, root.text))
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
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<string>()
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 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<RelativePath, TreeID>
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<RelativePath, TreeID>
}) =>
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 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 +730,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 +738,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 +828,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 },
tree: { capture: captureTree, write: writeTree, files: treeFiles, diff: treeDiff, preview, restore },
})
}),
)
@@ -403,7 +890,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
+20 -54
View File
@@ -46,6 +46,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { Snapshot } from "./snapshot"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
@@ -54,9 +55,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
return Layer.mergeAll(
boot,
Policy.locationLayer,
Config.locationLayer,
Reference.locationLayer,
@@ -71,56 +71,22 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
const permissionsAndTools = ToolRegistry.layer.pipe(
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provide(resources),
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(mutation),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
)
// Kick off a background project copy refresh to update locations now that we
// have a location
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
return Layer.mergeAll(
boot,
services,
image,
mutation,
resources,
todos,
questions,
model,
runner,
builtInTools,
referenceGuidance,
projectCopyRefresh,
).pipe(Layer.fresh)
SystemContextBuiltIns.locationLayer,
LocationMutation.locationLayer,
PermissionV2.locationLayer,
ToolOutputStore.locationLayer,
ToolRegistry.locationLayer,
Snapshot.locationLayer,
Image.locationLayer,
FileMutation.locationLayer,
SkillGuidance.locationLayer,
ReferenceGuidance.locationLayer,
SessionTodo.locationLayer,
QuestionV2.locationLayer,
SessionRunnerModel.locationLayer,
SessionRunnerLLM.locationLayer,
BuiltInTools.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
@@ -136,7 +102,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
SessionStore.defaultLayer,
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
+1 -1
View File
@@ -152,4 +152,4 @@ export const layer = Layer.effect(
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(Layer.orDie)
+8 -8
View File
@@ -70,8 +70,8 @@ export const layer = Layer.effect(
)
})
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
const origin = yield* git.remote(repo)
const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
const origin = yield* git.remote.get(repo)
if (!origin) return undefined
const normalized = url(origin)
if (!normalized) return undefined
@@ -102,22 +102,22 @@ export const layer = Layer.effect(
return `${host.toLowerCase()}/${pathname}`
}
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
const root = (yield* git.roots(repo))[0]
const root = Effect.fnUntraced(function* (repo: Git.Repository) {
const root = (yield* git.history.rootCommits(repo))[0]
return root ? ID.make(root) : undefined
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(repo.store)
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.directory,
vcs: { type: "git" as const, store: repo.store },
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
}
})
+9 -12
View File
@@ -1,4 +1,3 @@
import path from "path"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Git } from "../git"
@@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
const repo = (sourceDirectory: AbsolutePath) =>
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.find(options.directory)
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.find(directory)
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
const entries = yield* input.git.worktreeList(found)
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry).pipe(
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
+7 -1
View File
@@ -296,5 +296,11 @@ export const layer = Layer.effect(
}),
)
export const locationLayer = layer
export const locationLayer = Layer.merge(
layer,
Layer.effectDiscard(refreshAfterBoot).pipe(
Layer.provide(layer),
Layer.provide(PluginBoot.locationLayer),
),
)
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
+2 -5
View File
@@ -4,7 +4,6 @@ import { Effect, Schema, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event"
import { SessionInput } from "../session/input"
import { SessionMessage } from "../session/message"
@@ -61,8 +60,6 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
},
) {}
export { MessageDecodeError }
export interface CreateInput {
readonly id?: ID
readonly agent?: Agent.ID
@@ -112,8 +109,8 @@ export interface Interface {
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError>
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError>
readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
}
+3 -1
View File
@@ -66,4 +66,6 @@ export const layer = Layer.effect(
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, Reference.locationLayer)),
)
+35 -66
View File
@@ -4,6 +4,7 @@ import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Repository } from "./repository"
import { AbsolutePath } from "./schema"
import { EffectFlock } from "./util/effect-flock"
export type Result = {
@@ -142,15 +143,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
@@ -158,86 +159,54 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
})
if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
if (result.exitCode !== 0) {
return yield* new CloneFailedError({
repository,
message: resultMessage(result, `Failed to clone ${repository}`),
yield* git.repo
.clone({
remote: input.reference.remote,
directory: AbsolutePath.make(localPath),
branch: input.branch,
})
}
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
}
if (status === "refreshed") {
const fetch = yield* git
.fetch(localPath)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
}
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
yield* git.sync
.fetchRemotes(existing)
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (fetchBranch.exitCode !== 0) {
return yield* new FetchFailedError({
repository,
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
})
}
yield* git.sync
.fetchBranch(existing, { branch: requestedBranch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: errorMessage(error),
message: error.message,
}),
),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
}
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
)
if (reset.exitCode !== 0) {
return yield* new ResetFailedError({
repository,
message: resultMessage(reset, `Failed to reset ${repository}`),
})
}
yield* git.sync
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
return {
repository,
host: input.reference.host,
remote: input.reference.remote,
localPath,
status,
head: yield* git.head(localPath),
branch: yield* git.branch(localPath),
head: checkout ? yield* git.history.head(checkout) : undefined,
branch: checkout ? yield* git.history.branch(checkout) : undefined,
} satisfies Result
}),
`repository-cache:${localPath}`,
@@ -275,17 +244,17 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
)
}
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
const resetTarget = Effect.fnUntraced(function* (
git: Git.Interface,
repository: Git.Repository,
requestedBranch?: string,
) {
if (requestedBranch) return `origin/${requestedBranch}`
const remoteHead = yield* git.remoteHead(cwd)
if (remoteHead) return remoteHead
const currentBranch = yield* git.branch(cwd)
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
if (remoteHead) return `origin/${remoteHead}`
const currentBranch = yield* git.history.branch(repository)
if (currentBranch) return `origin/${currentBranch}`
return "HEAD"
})
function resultMessage(result: Git.Result, fallback: string) {
return result.stderr.trim() || result.text.trim() || fallback
}
export * as RepositoryCache from "./repository-cache"
+71 -28
View File
@@ -26,9 +26,11 @@ import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { File } from "./file"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
// get project -> project.locations
//
@@ -93,14 +95,17 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
},
) {}
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
export { ContextSnapshotDecodeError } from "./session/error"
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error = NotFoundError | OperationUnavailableError | PromptConflictError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
@@ -114,14 +119,14 @@ export interface Interface {
id: SessionMessage.ID
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly message: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Message | undefined>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly events: (input: {
sessionID: SessionSchema.ID
after?: EventV2.Cursor
@@ -157,18 +162,37 @@ export interface Interface {
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: {
readonly preview: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<
readonly File.Diff[],
NotFoundError | MessageNotFoundError | Snapshot.Error
>
readonly commit: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<void, NotFoundError | MessageNotFoundError | Snapshot.Error>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
export const layer = Layer.unwrap(
Effect.promise(() => import("./location-layer")).pipe(
Effect.map(({ LocationServiceMap }) =>
Layer.effect(
Service,
Effect.gen(function* () {
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 scope = yield* Effect.scope
@@ -186,15 +210,7 @@ export const layer = Layer.effect(
)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
@@ -419,18 +435,45 @@ export const layer = Layer.effect(
}),
),
),
revert: {
preview: Effect.fn("V2Session.revert.preview")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.preview(input).pipe(
Effect.provideService(Database.Service, database),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.commit(input).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
},
})
return result
}),
return result
}),
),
),
),
)
export const defaultLayer = layer.pipe(
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
export const defaultLayer = Layer.unwrap(
Effect.promise(() => import("./location-layer")).pipe(
Effect.map(({ LocationServiceMap }) =>
layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(SessionExecution.noopLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
),
),
),
)
-6
View File
@@ -1,12 +1,6 @@
import { Schema } from "effect"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
"Session.ContextSnapshotDecodeError",
{
+12
View File
@@ -204,6 +204,7 @@ export namespace Step {
}),
}),
snapshot: Schema.String.pipe(Schema.optional),
files: RelativePath.pipe(Schema.Array, Schema.optional),
},
})
export type Ended = typeof Ended.Type
@@ -468,6 +469,16 @@ export namespace Compaction {
export type Ended = typeof Ended.Type
}
export const Reverted = EventV2.define({
type: "session.next.reverted",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
},
})
export type Reverted = typeof Reverted.Type
const DurableDefinitions = [
AgentSwitched,
ModelSwitched,
@@ -496,6 +507,7 @@ const DurableDefinitions = [
Retried,
Compaction.Started,
Compaction.Ended,
Reverted,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
+1 -10
View File
@@ -1,7 +1,6 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
@@ -53,15 +52,7 @@ const messageRows = Effect.fnUntraced(function* (
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const [epoch, compaction] = yield* Effect.all(
+7 -1
View File
@@ -214,7 +214,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) => {
@@ -382,6 +387,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.reverted": () => Effect.void,
})
})
}
+1
View File
@@ -148,6 +148,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
end: Schema.String.pipe(Schema.optional),
files: SessionEvent.Step.Ended.data.fields.files,
}).pipe(Schema.optional),
finish: Schema.String.pipe(Schema.optional),
cost: Schema.Finite.pipe(Schema.optional),
+48 -2
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, desc, eq, sql } from "drizzle-orm"
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@@ -13,7 +13,7 @@ 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"
type DatabaseService = Database.Interface["db"]
@@ -444,6 +444,52 @@ export const layer = Layer.effectDiscard(
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
})
yield* events.project(SessionEvent.Reverted, (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({ 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)
}),
)
}),
)
+75
View File
@@ -0,0 +1,75 @@
export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
"Session.MessageNotFoundError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
interface Input {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
const plan = Effect.fn("SessionRevert.plan")(function* (input: Input) {
const db = (yield* Database.Service).db
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* new MessageNotFoundError(input)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
}
return files
})
export const preview = Effect.fn("SessionRevert.preview")(function* (input: Input) {
const snapshot = yield* Snapshot.Service
return yield* snapshot.preview({ files: yield* plan(input) })
})
export const commit = Effect.fn("SessionRevert.commit")(function* (input: Input & { readonly files?: boolean }) {
const files = yield* plan(input)
const snapshot = yield* Snapshot.Service
if (input.files !== false) yield* snapshot.restore({ files })
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Reverted, {
sessionID: input.sessionID,
messageID: input.messageID,
timestamp: yield* DateTime.now,
})
})
+1 -2
View File
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import type { ContextSnapshotDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
@@ -12,7 +12,6 @@ import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| ContextSnapshotDecodeError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
+48 -1
View File
@@ -32,6 +32,8 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { Snapshot } from "../../snapshot"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
@@ -59,7 +61,8 @@ import { MAX_STEPS_PROMPT } from "./max-steps"
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` provider turn.
* - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist snapshots and changed paths at settled step boundaries.
* - [ ] Persist retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
*
* - Tool settlement and continuation
@@ -100,6 +103,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) {
@@ -229,6 +233,7 @@ export const layer = Layer.effect(
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@@ -237,6 +242,7 @@ export const layer = Layer.effect(
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
snapshot: startSnapshot,
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
@@ -333,6 +339,32 @@ 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((cause) =>
Effect.logWarning("failed to list step snapshot files", { cause }).pipe(Effect.as(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())
@@ -406,3 +438,18 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export const locationLayer = layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
AgentV2.locationLayer,
ToolRegistry.locationLayer,
SessionRunnerModel.locationLayer,
SystemContextBuiltIns.locationLayer,
SkillGuidance.locationLayer,
ReferenceGuidance.locationLayer,
Config.locationLayer,
Snapshot.locationLayer,
),
),
)
+1 -1
View File
@@ -161,4 +161,4 @@ export const locationLayer = Layer.effect(
}),
})
}),
)
).pipe(Layer.provideMerge(Layer.merge(Catalog.locationLayer, PluginBoot.locationLayer)))
@@ -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)
@@ -66,6 +67,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let providerFailed = false
let stepSettlement:
| {
readonly finish: string
readonly tokens: ReturnType<typeof tokens>
}
| undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@@ -74,6 +81,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
...input,
assistantMessageID,
timestamp: yield* timestamp,
snapshot: input.snapshot,
})
return assistantMessageID
})
@@ -375,14 +383,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
case "step-finish":
yield* flush()
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
@@ -405,6 +407,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
failUnsettledTools,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
+2 -3
View File
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { SessionHistory } from "./history"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionMessageTable, SessionTable } from "./sql"
@@ -12,11 +11,11 @@ import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[]>
readonly runnerContext: (
sessionID: SessionSchema.ID,
baselineSeq: number,
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
) => Effect.Effect<SessionMessage.Message[]>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
+1
View File
@@ -89,3 +89,4 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
export const locationLayer = layer
+1 -1
View File
@@ -157,4 +157,4 @@ export const layer = Layer.effect(
}),
)
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
export const locationLayer = layer.pipe(Layer.provideMerge(SkillDiscovery.layer))
+3 -1
View File
@@ -73,4 +73,6 @@ export const layer = Layer.effect(
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, SkillV2.locationLayer)),
)
+200 -8
View File
@@ -1,9 +1,201 @@
export namespace Snapshot {
export type FileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export * as Snapshot from "./snapshot"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { File } from "./file"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath, RelativePath } from "./schema"
import { Hash } from "./util/hash"
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
export type ID = typeof ID.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface CompareInput {
readonly from: ID
readonly to: ID
}
export interface DiffInput extends CompareInput {
readonly context?: number
}
export interface RestoreInput {
/** Paths are relative to the project root. */
readonly files: ReadonlyMap<RelativePath, ID>
}
export interface PreviewInput extends RestoreInput {
readonly context?: number
}
export interface Interface {
/** Capture the current Location-scoped state. */
readonly capture: () => Effect.Effect<ID | undefined>
readonly files: (input: CompareInput) => Effect.Effect<readonly RelativePath[], Error>
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Snapshot") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const global = yield* Global.Service
const location = yield* Location.Service
const source = yield* git.repo.discover(location.project.directory)
const worktree = source
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
: location.project.directory
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
const scope = Effect.fnUntraced(function* () {
const relative = path.relative(worktree, location.directory)
if (relative.startsWith("..") || path.isAbsolute(relative))
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
})
const repository = Effect.fnUntraced(function* () {
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
return new Git.Repository({
worktree,
gitDirectory,
commonDirectory: gitDirectory,
})
return yield* git.repo.create({
worktree,
gitDirectory,
seed: source,
}).pipe(Effect.mapError((cause) => failure("capture", cause)))
})
const enabled = Effect.fnUntraced(function* () {
if (location.vcs?.type !== "git") return false
return Config.latest(yield* config.entries(), "snapshots") !== false
})
const capture = Effect.fn("Snapshot.capture")(function* () {
if (!(yield* enabled())) return undefined
return yield* Effect.gen(function* () {
const repo = yield* repository()
return ID.make(
yield* git.tree.capture({
repository: repo,
scopes: [yield* scope()],
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}),
)
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined)),
),
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) }
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
return yield* git.tree
.files(yield* compare("files", input))
.pipe(Effect.mapError((cause) => failure("files", cause)))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
return yield* git.tree
.diff({ ...(yield* compare("diff", input)), context: input.context })
.pipe(Effect.mapError((cause) => failure("diff", cause)))
})
const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) {
const files = new Map<RelativePath, Git.TreeID>()
for (const [file, snapshot] of input.files) {
const absolute = path.resolve(worktree, file)
if (!FSUtil.contains(worktree, absolute))
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
files.set(file, Git.TreeID.make(snapshot))
}
return files
})
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
const files = yield* plan("preview", input)
const current = yield* git.tree.capture({
repository: repo,
scopes: Array.from(files.keys()),
ignores: source,
maximumUntrackedFileBytes: 2 * 1024 * 1024,
}).pipe(Effect.mapError((cause) => failure("preview", cause)))
return yield* git.tree
.preview({
repository: repo,
current,
files,
context: input.context,
})
.pipe(Effect.mapError((cause) => failure("preview", cause)))
})
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
yield* git.tree
.restore({ repository: repo, files: yield* plan("restore", input) })
.pipe(Effect.mapError((cause) => failure("restore", cause)))
})
return Service.of({ capture, files, diff, preview, restore })
}),
)
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,
}),
)
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"
}
+1
View File
@@ -187,6 +187,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
/** Runs retention scanning once globally rather than once per active Location. */
export const cleanupLayer = Layer.effectDiscard(
+25
View File
@@ -14,6 +14,16 @@ import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Config } from "../config"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { LocationMutation } from "../location-mutation"
import { FileMutation } from "../file-mutation"
import { QuestionV2 } from "../question"
import { SessionTodo } from "../session/todo"
import { Image } from "../image"
/**
* Composes only the shipped Location-scoped built-in tool transforms.
@@ -41,4 +51,19 @@ export const locationLayer = Layer.mergeAll(
WebFetchTool.layer,
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
WriteTool.layer,
).pipe(
Layer.provideMerge(
Layer.mergeAll(
ToolRegistry.locationLayer,
PermissionV2.locationLayer,
Config.locationLayer,
PluginBoot.locationLayer,
SkillV2.locationLayer,
LocationMutation.locationLayer,
FileMutation.locationLayer,
QuestionV2.locationLayer,
SessionTodo.locationLayer,
Image.locationLayer,
),
),
)
+2
View File
@@ -137,3 +137,5 @@ export const defaultLayer = layer.pipe(
Layer.provide(ApplicationTools.layer),
Layer.provide(ToolOutputStore.defaultLayer),
)
export const locationLayer = layer.pipe(Layer.provideMerge(ToolOutputStore.locationLayer))
+79 -24
View File
@@ -4,7 +4,7 @@ import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { branch, commit, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -16,14 +16,16 @@ describe("Git", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
const result = yield* git.clone({ remote: fixture.remote, target })
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
expect(result.exitCode).toBe(0)
expect(yield* git.origin(target)).toBe(fixture.remote)
expect(yield* git.head(target)).toBeString()
expect(yield* git.branch(target)).toBe("main")
expect(yield* git.remoteHead(target)).toBe("origin/main")
expect(yield* git.remote.get(repository)).toBe(fixture.remote)
expect(yield* git.history.head(repository)).toBeString()
expect(yield* git.history.branch(repository)).toBe("main")
expect(yield* git.history.defaultRemoteBranch(repository)).toBe("main")
expect(repository.worktree).toBe(target)
expect(repository.gitDirectory).toBe(AbsolutePath.make(path.join(target, ".git")))
expect(repository.commonDirectory).toBe(repository.gitDirectory)
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
}),
),
@@ -33,19 +35,19 @@ describe("Git", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const git = yield* Git.Service
const target = path.join(fixture.root, "checkout")
yield* git.clone({ remote: fixture.remote, target })
const target = AbsolutePath.make(path.join(fixture.root, "checkout"))
const repository = yield* git.repo.clone({ remote: fixture.remote, directory: target })
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
expect((yield* git.fetch(target)).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
yield* git.sync.fetchRemotes(repository)
yield* git.sync.resetHard(repository, "origin/main")
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
expect(yield* git.branch(target)).toBe("feature/docs")
yield* git.sync.fetchBranch(repository, { branch: "feature/docs" })
yield* git.sync.checkoutRemoteBranch(repository, { branch: "feature/docs" })
yield* git.sync.resetHard(repository, "origin/feature/docs")
expect(yield* git.history.branch(repository)).toBe("feature/docs")
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
}),
),
@@ -90,17 +92,70 @@ 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 })
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make("scope")] })
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")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make("scope")] })
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
RelativePath.make("scope/added.txt"),
RelativePath.make("scope/tracked.txt"),
])
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
expect(diffs.map((item) => [item.path, item.status])).toEqual([
[RelativePath.make("scope/added.txt"), "added"],
[RelativePath.make("scope/tracked.txt"), "modified"],
])
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
expect(preview).toHaveLength(1)
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
yield* git.tree.restore({ repository, files })
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
expect(yield* read(path.join(root.path, "outside.txt"))).toBe("changed outside\n")
}),
)
})
+2
View File
@@ -14,6 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -33,6 +34,7 @@ const project = Project.layer.pipe(
)
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(database),
Layer.provide(events),
Layer.provide(project),
+2
View File
@@ -4,6 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
@@ -26,6 +27,7 @@ const current = Layer.succeed(
const events = EventV2.layer.pipe(Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
@@ -7,6 +7,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -38,6 +39,7 @@ const projects = Layer.succeed(
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
+46 -1
View File
@@ -8,8 +8,9 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } 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"
@@ -44,6 +45,46 @@ const assistantRow = (
}
describe("SessionProjector", () => {
it.effect("projects a committed revert by removing the message suffix", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
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()
yield* (yield* EventV2.Service).publish(SessionEvent.Reverted, {
sessionID,
messageID: boundary,
timestamp: DateTime.makeUnsafe(3),
})
expect(
(yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.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
@@ -113,6 +154,7 @@ describe("SessionProjector", () => {
}).pipe(
Effect.provide(
SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(Project.defaultLayer),
@@ -524,6 +566,8 @@ describe("SessionProjector", () => {
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: "tree_after",
files: [RelativePath.make("src/index.ts")],
})
const rows = yield* db
@@ -540,6 +584,7 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
snapshot: { end: "tree_after", files: [RelativePath.make("src/index.ts")] },
time: { completed: DateTime.makeUnsafe(1) },
})
}),
@@ -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"
@@ -47,6 +48,7 @@ const execution = Layer.succeed(
}),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
@@ -22,10 +22,12 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@@ -86,6 +88,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
Layer.provide(Snapshot.noopLayer),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@@ -101,6 +104,7 @@ const execution = Layer.effect(
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
@@ -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" })
})
+17
View File
@@ -19,6 +19,7 @@ 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 { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
@@ -49,6 +50,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -247,6 +249,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
Layer.provide(Snapshot.noopLayer),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@@ -262,6 +265,7 @@ const execution = Layer.effect(
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
Layer.provide(LocationServiceMap.layer),
Layer.provide(events),
Layer.provide(database),
Layer.provide(store),
@@ -1788,6 +1792,19 @@ describe("SessionRunnerLLM", () => {
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(executions).toEqual(["hello"])
const { db } = yield* Database.Service
const events = yield* db
.select({ seq: EventTable.seq, type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
const success = events.find((event) => event.type === "session.next.tool.success.1")
const ended = events.find((event) => event.type === "session.next.step.ended.2")
expect(success).toBeDefined()
expect(ended).toBeDefined()
if (!success || !ended) throw new Error("Missing tool settlement events")
expect(ended.seq).toBeGreaterThan(success.seq)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" },
{
+149
View File
@@ -0,0 +1,149 @@
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]()),
),
)
})
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")))
}
@@ -962,6 +962,27 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected
.get("/api/session/{sessionID}/message/{messageID}/revert", "v2.session.revert.preview")
.at((ctx) => ({
path: route("/api/session/{sessionID}/message/{messageID}/revert", {
sessionID: "ses_httpapi_missing",
messageID: "msg_httpapi_missing",
}),
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected
.post("/api/session/{sessionID}/message/{messageID}/revert", "v2.session.revert.commit")
.at((ctx) => ({
path: route("/api/session/{sessionID}/message/{messageID}/revert", {
sessionID: "ses_httpapi_missing",
messageID: "msg_httpapi_missing",
}),
headers: { ...ctx.headers(), "content-type": "application/json" },
body: { files: false },
}))
.json(404, object, "status"),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages")
.at((ctx) => ({
+86
View File
@@ -353,6 +353,10 @@ import type {
V2SessionQuestionRejectResponses,
V2SessionQuestionReplyErrors,
V2SessionQuestionReplyResponses,
V2SessionRevertCommitErrors,
V2SessionRevertCommitResponses,
V2SessionRevertPreviewErrors,
V2SessionRevertPreviewResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
V2SkillListErrors,
@@ -5064,6 +5068,83 @@ export class Agent extends HeyApiClient {
}
}
export class Revert extends HeyApiClient {
/**
* Preview message revert
*
* Preview the filesystem changes required to revert all assistant work after a message.
*/
public preview<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
messageID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
],
},
],
)
return (options?.client ?? this.client).get<
V2SessionRevertPreviewResponses,
V2SessionRevertPreviewErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/message/{messageID}/revert",
...options,
...params,
})
}
/**
* Commit message revert
*
* Permanently remove all history after a message and optionally restore affected files.
*/
public commit<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
messageID: string
files?: boolean
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
{ in: "body", key: "files" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SessionRevertCommitResponses,
V2SessionRevertCommitErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/message/{messageID}/revert",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Permission2 extends HeyApiClient {
/**
* List session permission requests
@@ -5469,6 +5550,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 }))
+140
View File
@@ -46,6 +46,7 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventSessionNextReverted
| EventMessagePartDelta
| EventSessionDiff
| EventSessionError
@@ -960,6 +961,7 @@ export type GlobalEvent = {
}
}
snapshot?: string
files?: Array<string>
}
}
| {
@@ -1201,6 +1203,15 @@ export type GlobalEvent = {
recent: string
}
}
| {
id: string
type: "session.next.reverted"
properties: {
timestamp: number
sessionID: string
messageID: string
}
}
| {
id: string
type: "message.part.delta"
@@ -1659,6 +1670,7 @@ export type GlobalEvent = {
| SyncEventSessionNextRetried
| SyncEventSessionNextCompactionStarted
| SyncEventSessionNextCompactionEnded
| SyncEventSessionNextReverted
}
/**
@@ -2744,6 +2756,13 @@ export type ServiceUnavailableError = {
service?: string
}
export type MessageNotFoundError = {
_tag: "MessageNotFoundError"
sessionID: string
messageID: string
message: string
}
export type UnknownError1 = {
_tag: "UnknownError"
message: string
@@ -3292,6 +3311,7 @@ export type SyncEventSessionNextStepEnded = {
}
}
snapshot?: string
files?: Array<string>
}
}
}
@@ -3590,6 +3610,22 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type SyncEventSessionNextReverted = {
type: "sync"
id: string
syncEvent: {
type: "session.next.reverted.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
messageID: string
}
}
}
export type ConfigV2ReferenceGit = {
repository: string
branch?: string
@@ -3699,6 +3735,14 @@ export type SessionInputAdmitted = {
promotedSeq?: number
}
export type FileDiff = {
path: string
status: "added" | "modified" | "deleted"
additions: number
deletions: number
patch: string
}
export type SessionMessageAgentSwitched = {
id: string
metadata?: {
@@ -3891,6 +3935,7 @@ export type SessionMessageAssistant = {
snapshot?: {
start?: string
end?: string
files?: Array<string>
}
finish?: string
cost?: number
@@ -4472,6 +4517,7 @@ export type EventSessionNextStepEnded = {
}
}
snapshot?: string
files?: Array<string>
}
}
@@ -4732,6 +4778,16 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventSessionNextReverted = {
id: string
type: "session.next.reverted"
properties: {
timestamp: number
sessionID: string
messageID: string
}
}
export type EventMessagePartDelta = {
id: string
type: "message.part.delta"
@@ -9645,6 +9701,90 @@ export type V2SessionWaitResponses = {
export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses]
export type V2SessionRevertPreviewData = {
body?: never
path: {
sessionID: string
messageID: string
}
query?: never
url: "/api/session/{sessionID}/message/{messageID}/revert"
}
export type V2SessionRevertPreviewErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* MessageNotFoundError | SessionNotFoundError
*/
404: MessageNotFoundError | SessionNotFoundError
/**
* UnknownError
*/
500: UnknownError1
}
export type V2SessionRevertPreviewError = V2SessionRevertPreviewErrors[keyof V2SessionRevertPreviewErrors]
export type V2SessionRevertPreviewResponses = {
/**
* Success
*/
200: {
data: Array<FileDiff>
}
}
export type V2SessionRevertPreviewResponse = V2SessionRevertPreviewResponses[keyof V2SessionRevertPreviewResponses]
export type V2SessionRevertCommitData = {
body: {
files?: boolean
}
path: {
sessionID: string
messageID: string
}
query?: never
url: "/api/session/{sessionID}/message/{messageID}/revert"
}
export type V2SessionRevertCommitErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* MessageNotFoundError | SessionNotFoundError
*/
404: MessageNotFoundError | SessionNotFoundError
/**
* UnknownError
*/
500: UnknownError1
}
export type V2SessionRevertCommitError = V2SessionRevertCommitErrors[keyof V2SessionRevertCommitErrors]
export type V2SessionRevertCommitResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionRevertCommitResponse = V2SessionRevertCommitResponses[keyof V2SessionRevertCommitResponses]
export type V2SessionContextData = {
body?: never
path: {
+10
View File
@@ -61,6 +61,16 @@ export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoun
{ httpApiStatus: 404 },
) {}
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
"MessageNotFoundError",
{
sessionID: Schema.String,
messageID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
+33
View File
@@ -11,6 +11,7 @@ import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
@@ -19,6 +20,7 @@ import { SessionLocationMiddleware } from "../middleware/session-location"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { File } from "@opencode-ai/core/file"
const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional),
@@ -191,6 +193,37 @@ export const SessionGroup = HttpApiGroup.make("server.session")
}),
),
)
.add(
HttpApiEndpoint.get("session.revert.preview", "/api/session/:sessionID/message/:messageID/revert", {
params: { sessionID: SessionV2.ID, messageID: SessionMessage.ID },
success: Schema.Struct({ data: Schema.Array(File.Diff) }),
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.revert.preview",
summary: "Preview message revert",
description: "Preview the filesystem changes required to revert all assistant work after a message.",
}),
),
)
.add(
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/message/:messageID/revert", {
params: { sessionID: SessionV2.ID, messageID: SessionMessage.ID },
payload: Schema.Struct({ files: Schema.Boolean.pipe(Schema.optional) }),
success: HttpApiSchema.NoContent,
error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.revert.commit",
summary: "Commit message revert",
description: "Permanently remove all history after a message and optionally restore affected files.",
}),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: SessionV2.ID },
-14
View File
@@ -54,20 +54,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
const first = messages[0]
const last = messages.at(-1)
+72 -8
View File
@@ -6,6 +6,7 @@ import { SessionsCursor } from "../groups/session"
import {
ConflictError,
InvalidCursorError,
MessageNotFoundError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
@@ -174,22 +175,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.context",
"session.revert.preview",
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
data: yield* session.revert.preview(ctx.params).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
return Effect.logError("failed to preview session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
@@ -204,5 +213,60 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.revert.commit",
Effect.fn(function* (ctx) {
yield* session.revert.commit({ ...ctx.params, files: ctx.payload.files }).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 commit 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.context",
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
),
}
}),
)
}),
)