فهرست منبع

fix(worktree): type expected errors (#27296)

Shoubhit Dash 3 ماه پیش
والد
کامیت
2e7cf92c8b

+ 20 - 3
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<WorktreeApiError>("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",

+ 12 - 4
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<A, R>(self: Effect.Effect<A, Worktree.Error, R>) {
+  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
     })
 

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

+ 63 - 49
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<typeof ResetInput>
 
-export const NotGitError = NamedError.create("WorktreeNotGitError", {
+export class NotGitError extends Schema.TaggedErrorClass<NotGitError>()("WorktreeNotGitError", {
   message: Schema.String,
-})
+}) {}
 
-export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", {
-  message: Schema.String,
-})
+export class NameGenerationFailedError extends Schema.TaggedErrorClass<NameGenerationFailedError>()(
+  "WorktreeNameGenerationFailedError",
+  {
+    message: Schema.String,
+  },
+) {}
 
-export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", {
+export class CreateFailedError extends Schema.TaggedErrorClass<CreateFailedError>()("WorktreeCreateFailedError", {
   message: Schema.String,
-})
+}) {}
 
-export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", {
-  message: Schema.String,
-})
+export class StartCommandFailedError extends Schema.TaggedErrorClass<StartCommandFailedError>()(
+  "WorktreeStartCommandFailedError",
+  {
+    message: Schema.String,
+  },
+) {}
 
-export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", {
+export class RemoveFailedError extends Schema.TaggedErrorClass<RemoveFailedError>()("WorktreeRemoveFailedError", {
   message: Schema.String,
-})
+}) {}
 
-export const ResetFailedError = NamedError.create("WorktreeResetFailedError", {
+export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("WorktreeResetFailedError", {
   message: Schema.String,
-})
+}) {}
 
-export const ListFailedError = NamedError.create("WorktreeListFailedError", {
+export class ListFailedError extends Schema.TaggedErrorClass<ListFailedError>()("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<Info>
-  readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
-  readonly create: (input?: CreateInput) => Effect.Effect<Info>
-  readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
-  readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
-  readonly reset: (input: ResetInput) => Effect.Effect<boolean>
+  readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
+  readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
+  readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
+  readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
+  readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
+  readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@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(

+ 12 - 4
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")
+        }
       }),
     )
   })

+ 1 - 1
packages/opencode/test/server/httpapi-exercise/runner.ts

@@ -171,7 +171,7 @@ function withContext<A, E>(
           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)),

+ 17 - 0
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",
     () =>