Skip to content

Commit 2e7cf92

Browse files
authored
fix(worktree): type expected errors (anomalyco#27296)
1 parent ccf93f3 commit 2e7cf92

7 files changed

Lines changed: 125 additions & 62 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ export const ToolListQuery = Schema.Struct({
5454
})
5555

5656
const WorktreeList = Schema.Array(Schema.String)
57+
const WorktreeErrorName = Schema.Union([
58+
Schema.Literal("WorktreeNotGitError"),
59+
Schema.Literal("WorktreeNameGenerationFailedError"),
60+
Schema.Literal("WorktreeCreateFailedError"),
61+
Schema.Literal("WorktreeStartCommandFailedError"),
62+
Schema.Literal("WorktreeRemoveFailedError"),
63+
Schema.Literal("WorktreeResetFailedError"),
64+
Schema.Literal("WorktreeListFailedError"),
65+
])
66+
export class WorktreeApiError extends Schema.ErrorClass<WorktreeApiError>("WorktreeError")(
67+
{
68+
name: WorktreeErrorName,
69+
data: Schema.Struct({ message: Schema.String }),
70+
},
71+
{ httpApiStatus: 400 },
72+
) {}
5773
export const SessionListQuery = Schema.Struct({
5874
...WorkspaceRoutingQueryFields,
5975
roots: Schema.optional(QueryBoolean),
@@ -141,6 +157,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
141157
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
142158
query: WorkspaceRoutingQuery,
143159
success: described(WorktreeList, "List of worktree directories"),
160+
error: WorktreeApiError,
144161
}).annotateMerge(
145162
OpenApi.annotations({
146163
identifier: "worktree.list",
@@ -152,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
152169
query: WorkspaceRoutingQuery,
153170
payload: Schema.optional(Worktree.CreateInput),
154171
success: described(Worktree.Info, "Worktree created"),
155-
error: HttpApiError.BadRequest,
172+
error: WorktreeApiError,
156173
}).annotateMerge(
157174
OpenApi.annotations({
158175
identifier: "worktree.create",
@@ -164,7 +181,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
164181
query: WorkspaceRoutingQuery,
165182
payload: Worktree.RemoveInput,
166183
success: described(Schema.Boolean, "Worktree removed"),
167-
error: HttpApiError.BadRequest,
184+
error: WorktreeApiError,
168185
}).annotateMerge(
169186
OpenApi.annotations({
170187
identifier: "worktree.remove",
@@ -176,7 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
176193
query: WorkspaceRoutingQuery,
177194
payload: Worktree.ResetInput,
178195
success: described(Schema.Boolean, "Worktree reset"),
179-
error: HttpApiError.BadRequest,
196+
error: WorktreeApiError,
180197
}).annotateMerge(
181198
OpenApi.annotations({
182199
identifier: "worktree.reset",

packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@ import { Effect, Option } from "effect"
1212
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
1313
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
1414
import { InstanceHttpApi } from "../api"
15-
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
15+
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError } from "../groups/experimental"
16+
17+
function mapWorktreeError<A, R>(self: Effect.Effect<A, Worktree.Error, R>) {
18+
return self.pipe(
19+
Effect.mapError(
20+
(error) => new WorktreeApiError({ name: error._tag, data: { message: error.message } }),
21+
),
22+
)
23+
}
1624

1725
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
1826
Effect.gen(function* () {
@@ -100,22 +108,22 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
100108
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
101109
payload: Worktree.CreateInput | undefined
102110
}) {
103-
return yield* worktreeSvc.create(ctx.payload)
111+
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload))
104112
})
105113

106114
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
107115
payload: Worktree.RemoveInput
108116
}) {
109117
const ctx = yield* InstanceState.context
110-
yield* worktreeSvc.remove(input.payload)
118+
yield* mapWorktreeError(worktreeSvc.remove(input.payload))
111119
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
112120
return true
113121
})
114122

115123
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
116124
payload: Worktree.ResetInput
117125
}) {
118-
yield* worktreeSvc.reset(ctx.payload)
126+
yield* mapWorktreeError(worktreeSvc.reset(ctx.payload))
119127
return true
120128
})
121129

packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
2929
status: iife(() => {
3030
if (error instanceof Provider.ModelNotFoundError) return 400
3131
if (error.name === "ProviderAuthValidationFailed") return 400
32-
if (error.name.startsWith("Worktree")) return 400
3332
return 500
3433
}),
3534
}),

packages/opencode/src/worktree/index.ts

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { NamedError } from "@opencode-ai/core/util/error"
21
import { Global } from "@opencode-ai/core/global"
32
import { InstanceLayer } from "@/project/instance-layer"
43
import { InstanceStore } from "@/project/instance-store"
@@ -64,33 +63,48 @@ export const ResetInput = Schema.Struct({
6463
}).annotate({ identifier: "WorktreeResetInput" })
6564
export type ResetInput = Schema.Schema.Type<typeof ResetInput>
6665

67-
export const NotGitError = NamedError.create("WorktreeNotGitError", {
66+
export class NotGitError extends Schema.TaggedErrorClass<NotGitError>()("WorktreeNotGitError", {
6867
message: Schema.String,
69-
})
68+
}) {}
7069

71-
export const NameGenerationFailedError = NamedError.create("WorktreeNameGenerationFailedError", {
72-
message: Schema.String,
73-
})
70+
export class NameGenerationFailedError extends Schema.TaggedErrorClass<NameGenerationFailedError>()(
71+
"WorktreeNameGenerationFailedError",
72+
{
73+
message: Schema.String,
74+
},
75+
) {}
7476

75-
export const CreateFailedError = NamedError.create("WorktreeCreateFailedError", {
77+
export class CreateFailedError extends Schema.TaggedErrorClass<CreateFailedError>()("WorktreeCreateFailedError", {
7678
message: Schema.String,
77-
})
79+
}) {}
7880

79-
export const StartCommandFailedError = NamedError.create("WorktreeStartCommandFailedError", {
80-
message: Schema.String,
81-
})
81+
export class StartCommandFailedError extends Schema.TaggedErrorClass<StartCommandFailedError>()(
82+
"WorktreeStartCommandFailedError",
83+
{
84+
message: Schema.String,
85+
},
86+
) {}
8287

83-
export const RemoveFailedError = NamedError.create("WorktreeRemoveFailedError", {
88+
export class RemoveFailedError extends Schema.TaggedErrorClass<RemoveFailedError>()("WorktreeRemoveFailedError", {
8489
message: Schema.String,
85-
})
90+
}) {}
8691

87-
export const ResetFailedError = NamedError.create("WorktreeResetFailedError", {
92+
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("WorktreeResetFailedError", {
8893
message: Schema.String,
89-
})
94+
}) {}
9095

91-
export const ListFailedError = NamedError.create("WorktreeListFailedError", {
96+
export class ListFailedError extends Schema.TaggedErrorClass<ListFailedError>()("WorktreeListFailedError", {
9297
message: Schema.String,
93-
})
98+
}) {}
99+
100+
export type Error =
101+
| NotGitError
102+
| NameGenerationFailedError
103+
| CreateFailedError
104+
| StartCommandFailedError
105+
| RemoveFailedError
106+
| ResetFailedError
107+
| ListFailedError
94108

95109
function slugify(input: string) {
96110
return input
@@ -121,12 +135,12 @@ function failedRemoves(...chunks: string[]) {
121135
// ---------------------------------------------------------------------------
122136

123137
export interface Interface {
124-
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info>
125-
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
126-
readonly create: (input?: CreateInput) => Effect.Effect<Info>
127-
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
128-
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
129-
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
138+
readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
139+
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
140+
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
141+
readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
142+
readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
143+
readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
130144
}
131145

132146
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
@@ -193,7 +207,7 @@ export const layer: Layer.Layer<
193207

194208
return { name, directory, ...(branch ? { branch } : {}) }
195209
}
196-
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
210+
return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
197211
})
198212

199213
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: {
@@ -202,7 +216,7 @@ export const layer: Layer.Layer<
202216
}) {
203217
const ctx = yield* InstanceState.context
204218
if (ctx.project.vcs !== "git") {
205-
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
219+
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
206220
}
207221

208222
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
@@ -220,7 +234,7 @@ export const layer: Layer.Layer<
220234
{ cwd: ctx.worktree },
221235
)
222236
if (created.code !== 0) {
223-
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
237+
return yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
224238
}
225239

226240
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
@@ -336,7 +350,7 @@ export const layer: Layer.Layer<
336350

337351
const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
338352
if (result.code !== 0) {
339-
throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
353+
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
340354
}
341355

342356
const primary = yield* canonical(ctx.worktree)
@@ -364,27 +378,27 @@ export const layer: Layer.Layer<
364378
}
365379

366380
function cleanDirectory(target: string) {
367-
return Effect.promise(() =>
368-
import("fs/promises")
369-
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
370-
.catch((error) => {
371-
const message = errorMessage(error)
372-
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
373-
}),
374-
)
381+
return Effect.tryPromise({
382+
try: () =>
383+
import("fs/promises").then((fsp) =>
384+
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
385+
),
386+
catch: (error) =>
387+
new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }),
388+
})
375389
}
376390

377391
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
378392
const ctx = yield* InstanceState.context
379393
if (ctx.project.vcs !== "git") {
380-
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
394+
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
381395
}
382396

383397
const directory = yield* canonical(input.directory)
384398

385399
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
386400
if (list.code !== 0) {
387-
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
401+
return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
388402
}
389403

390404
const entries = parseWorktreeList(list.text)
@@ -404,14 +418,14 @@ export const layer: Layer.Layer<
404418
if (removed.code !== 0) {
405419
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
406420
if (next.code !== 0) {
407-
throw new RemoveFailedError({
421+
return yield* new RemoveFailedError({
408422
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
409423
})
410424
}
411425

412426
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
413427
if (stale?.path) {
414-
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
428+
return yield* new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
415429
}
416430
}
417431

@@ -421,7 +435,7 @@ export const layer: Layer.Layer<
421435
if (branch) {
422436
const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
423437
if (deleted.code !== 0) {
424-
throw new RemoveFailedError({
438+
return yield* new RemoveFailedError({
425439
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
426440
})
427441
}
@@ -436,7 +450,7 @@ export const layer: Layer.Layer<
436450
error: (r: GitResult) => Error,
437451
) {
438452
const result = yield* git(args, opts)
439-
if (result.code !== 0) throw error(result)
453+
if (result.code !== 0) return yield* error(result)
440454
return result
441455
})
442456

@@ -511,30 +525,30 @@ export const layer: Layer.Layer<
511525
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
512526
const ctx = yield* InstanceState.context
513527
if (ctx.project.vcs !== "git") {
514-
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
528+
return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
515529
}
516530

517531
const directory = yield* canonical(input.directory)
518532
const primary = yield* canonical(ctx.worktree)
519533
if (directory === primary) {
520-
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
534+
return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" })
521535
}
522536

523537
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
524538
if (list.code !== 0) {
525-
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
539+
return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
526540
}
527541

528542
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
529543
if (!entry?.path) {
530-
throw new ResetFailedError({ message: "Worktree not found" })
544+
return yield* new ResetFailedError({ message: "Worktree not found" })
531545
}
532546

533547
const worktreePath = entry.path
534548

535549
const base = yield* gitSvc.defaultBranch(ctx.worktree)
536550
if (!base) {
537-
throw new ResetFailedError({ message: "Default branch not found" })
551+
return yield* new ResetFailedError({ message: "Default branch not found" })
538552
}
539553

540554
const sep = base.ref.indexOf("/")
@@ -556,7 +570,7 @@ export const layer: Layer.Layer<
556570

557571
const cleanResult = yield* sweep(worktreePath)
558572
if (cleanResult.code !== 0) {
559-
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
573+
return yield* new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
560574
}
561575

562576
yield* gitExpect(
@@ -579,11 +593,11 @@ export const layer: Layer.Layer<
579593

580594
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
581595
if (status.code !== 0) {
582-
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
596+
return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
583597
}
584598

585599
if (status.text.trim()) {
586-
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
600+
return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
587601
}
588602

589603
yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(

0 commit comments

Comments
 (0)