diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 99a8a21a9e..160bafb1e3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -54,6 +54,22 @@ export const ToolListQuery = Schema.Struct({ }) const WorktreeList = Schema.Array(Schema.String) +const WorktreeErrorName = Schema.Union([ + Schema.Literal("WorktreeNotGitError"), + Schema.Literal("WorktreeNameGenerationFailedError"), + Schema.Literal("WorktreeCreateFailedError"), + Schema.Literal("WorktreeStartCommandFailedError"), + Schema.Literal("WorktreeRemoveFailedError"), + Schema.Literal("WorktreeResetFailedError"), + Schema.Literal("WorktreeListFailedError"), +]) +export class WorktreeApiError extends Schema.ErrorClass("WorktreeError")( + { + name: WorktreeErrorName, + data: Schema.Struct({ message: Schema.String }), + }, + { httpApiStatus: 400 }, +) {} export const SessionListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), @@ -141,6 +157,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.list", @@ -152,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.create", @@ -164,7 +181,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.remove", @@ -176,7 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental") query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), - error: HttpApiError.BadRequest, + error: WorktreeApiError, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.reset", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 9cf668cebb..89942ec4d2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -12,7 +12,15 @@ import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" +import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError } from "../groups/experimental" + +function mapWorktreeError(self: Effect.Effect) { + return self.pipe( + Effect.mapError( + (error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } }), + ), + ) +} export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => Effect.gen(function* () { @@ -100,14 +108,14 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { payload: Worktree.CreateInput | undefined }) { - return yield* worktreeSvc.create(ctx.payload) + return yield* mapWorktreeError(worktreeSvc.create(ctx.payload)) }) const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { payload: Worktree.RemoveInput }) { const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) + yield* mapWorktreeError(worktreeSvc.remove(input.payload)) yield* project.removeSandbox(ctx.project.id, input.payload.directory) return true }) @@ -115,7 +123,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { payload: Worktree.ResetInput }) { - yield* worktreeSvc.reset(ctx.payload) + yield* mapWorktreeError(worktreeSvc.reset(ctx.payload)) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 087d5ee851..acc39fb1ea 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -29,7 +29,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) status: iife(() => { if (error instanceof Provider.ModelNotFoundError) return 400 if (error.name === "ProviderAuthValidationFailed") return 400 - if (error.name.startsWith("Worktree")) return 400 return 500 }), }), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 7d02189261..58651ddec3 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,4 +1,3 @@ -import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { InstanceLayer } from "@/project/instance-layer" import { InstanceStore } from "@/project/instance-store" @@ -64,33 +63,48 @@ export const ResetInput = Schema.Struct({ }).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type -export const NotGitError = NamedError.create("WorktreeNotGitError", { +export class NotGitError extends Schema.TaggedErrorClass()("WorktreeNotGitError", { message: Schema.String, -}) +}) {} -export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", { - message: Schema.String, -}) +export class NameGenerationFailedError extends Schema.TaggedErrorClass()( + "WorktreeNameGenerationFailedError", + { + message: Schema.String, + }, +) {} -export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", { +export class CreateFailedError extends Schema.TaggedErrorClass()("WorktreeCreateFailedError", { message: Schema.String, -}) +}) {} -export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", { - message: Schema.String, -}) +export class StartCommandFailedError extends Schema.TaggedErrorClass()( + "WorktreeStartCommandFailedError", + { + message: Schema.String, + }, +) {} -export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", { +export class RemoveFailedError extends Schema.TaggedErrorClass()("WorktreeRemoveFailedError", { message: Schema.String, -}) +}) {} -export const ResetFailedError = NamedError.create("WorktreeResetFailedError", { +export class ResetFailedError extends Schema.TaggedErrorClass()("WorktreeResetFailedError", { message: Schema.String, -}) +}) {} -export const ListFailedError = NamedError.create("WorktreeListFailedError", { +export class ListFailedError extends Schema.TaggedErrorClass()("WorktreeListFailedError", { message: Schema.String, -}) +}) {} + +export type Error = + | NotGitError + | NameGenerationFailedError + | CreateFailedError + | StartCommandFailedError + | RemoveFailedError + | ResetFailedError + | ListFailedError function slugify(input: string) { return input @@ -121,12 +135,12 @@ function failedRemoves(...chunks: string[]) { // --------------------------------------------------------------------------- export interface Interface { - readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect - readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect - readonly create: (input?: CreateInput) => Effect.Effect - readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> - readonly remove: (input: RemoveInput) => Effect.Effect - readonly reset: (input: ResetInput) => Effect.Effect + readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect + readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[], Error> + readonly remove: (input: RemoveInput) => Effect.Effect + readonly reset: (input: ResetInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Worktree") {} @@ -193,7 +207,7 @@ export const layer: Layer.Layer< return { name, directory, ...(branch ? { branch } : {}) } } - throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) + return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: { @@ -202,7 +216,7 @@ export const layer: Layer.Layer< }) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) @@ -220,7 +234,7 @@ export const layer: Layer.Layer< { cwd: ctx.worktree }, ) if (created.code !== 0) { - throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) + return yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) @@ -336,7 +350,7 @@ export const layer: Layer.Layer< const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (result.code !== 0) { - throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } const primary = yield* canonical(ctx.worktree) @@ -364,27 +378,27 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { - return Effect.promise(() => - import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) - .catch((error) => { - const message = errorMessage(error) - throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) - }), - ) + return Effect.tryPromise({ + try: () => + import("fs/promises").then((fsp) => + fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ), + catch: (error) => + new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }), + }) } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { - throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } const entries = parseWorktreeList(list.text) @@ -404,14 +418,14 @@ export const layer: Layer.Layer< if (removed.code !== 0) { const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (next.code !== 0) { - throw new RemoveFailedError({ + return yield* new RemoveFailedError({ message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree", }) } const stale = yield* locateWorktree(parseWorktreeList(next.text), directory) if (stale?.path) { - throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) + return yield* new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" }) } } @@ -421,7 +435,7 @@ export const layer: Layer.Layer< if (branch) { const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree }) if (deleted.code !== 0) { - throw new RemoveFailedError({ + return yield* new RemoveFailedError({ message: deleted.stderr || deleted.text || "Failed to delete worktree branch", }) } @@ -436,7 +450,7 @@ export const layer: Layer.Layer< error: (r: GitResult) => Error, ) { const result = yield* git(args, opts) - if (result.code !== 0) throw error(result) + if (result.code !== 0) return yield* error(result) return result }) @@ -511,30 +525,30 @@ export const layer: Layer.Layer< const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { - throw new NotGitError({ message: "Worktrees are only supported for git projects" }) + return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } const directory = yield* canonical(input.directory) const primary = yield* canonical(ctx.worktree) if (directory === primary) { - throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) + return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" }) } const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { - throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) + return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) if (!entry?.path) { - throw new ResetFailedError({ message: "Worktree not found" }) + return yield* new ResetFailedError({ message: "Worktree not found" }) } const worktreePath = entry.path const base = yield* gitSvc.defaultBranch(ctx.worktree) if (!base) { - throw new ResetFailedError({ message: "Default branch not found" }) + return yield* new ResetFailedError({ message: "Default branch not found" }) } const sep = base.ref.indexOf("/") @@ -556,7 +570,7 @@ export const layer: Layer.Layer< const cleanResult = yield* sweep(worktreePath) if (cleanResult.code !== 0) { - throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) + return yield* new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" }) } yield* gitExpect( @@ -579,11 +593,11 @@ export const layer: Layer.Layer< const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.code !== 0) { - throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) + return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) } if (status.text.trim()) { - throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) + return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) } yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 1de7600145..308e2f957b 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -135,13 +135,17 @@ describe("Worktree", () => { { git: true }, ) - it.instance("throws NotGitError for non-git directories", () => + it.instance("fails with NotGitError for non-git directories", () => Effect.gen(function* () { const svc = yield* Worktree.Service const exit = yield* Effect.exit(svc.makeWorktreeInfo()) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Worktree.NotGitError) + if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError") + } }), ) @@ -286,14 +290,18 @@ describe("Worktree", () => { { git: true }, ) - it.instance("throws NotGitError for non-git directories", () => + it.instance("fails with NotGitError for non-git directories", () => Effect.gen(function* () { const test = yield* TestInstance const svc = yield* Worktree.Service const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") })) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Worktree.NotGitError) + if (error instanceof Worktree.NotGitError) expect(error._tag).toBe("WorktreeNotGitError") + } }), ) }) diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 7ab5ccf990..b14647680c 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -171,7 +171,7 @@ function withContext( messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))), todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))), worktreeRemove: (directory) => run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), llmText: (value) => Effect.suspend(() => llm().text(value)), diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 11aed69cb4..2613ee3850 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -192,6 +192,23 @@ describe("experimental HttpApi", () => { }, ) + it.instance("returns declared worktree errors", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* request(ExperimentalPaths.worktree, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + expect(yield* json(response)).toEqual({ + name: "WorktreeNotGitError", + data: { message: "Worktrees are only supported for git projects" }, + }) + }), + ) + it.instance( "serves Console org switch through the default server app", () =>