fix(workspaces): surface real error messages on failed workspace operations (#29167)

Signed-off-by: James Murdza <james@jamesmurdza.com>
This commit is contained in:
James Murdza
2026-05-29 14:00:23 -07:00
committed by GitHub
parent 7342e94094
commit 9d5f3c1833
5 changed files with 84 additions and 27 deletions

View File

@@ -53,13 +53,22 @@ export function DialogSessionList() {
const workspaceID = await (async () => { const workspaceID = await (async () => {
if (selection.type === "none") return null if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID if (selection.type === "existing") return selection.workspaceID
const result = await sdk.client.experimental.workspace let result
.create({ type: selection.workspaceType, branch: null }) try {
.catch(() => undefined) result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data const workspace = result?.data
if (!workspace) { if (!workspace) {
toast.show({ toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error", variant: "error",
}) })
return return

View File

@@ -61,15 +61,17 @@ async function loadWorkspaceAdapters(input: {
const dir = input.sync.path.directory || input.sdk.directory const dir = input.sync.path.directory || input.sdk.directory
const url = new URL("/experimental/workspace/adapter", input.sdk.url) const url = new URL("/experimental/workspace/adapter", input.sdk.url)
if (dir) url.searchParams.set("directory", dir) if (dir) url.searchParams.set("directory", dir)
const res = await input.sdk try {
.fetch(url) const response = await input.sdk.fetch(url)
.then((x) => x.json() as Promise<Adapter[]>) return (await response.json()) as Adapter[]
.catch(() => undefined) } catch (err) {
if (res) return res input.toast.show({
input.toast.show({ title: "Failed to load workspace adapters",
message: "Failed to load workspace adapters", message: errorMessage(err),
variant: "error", variant: "error",
}) })
return undefined
}
} }
export async function openWorkspaceSelect(input: { export async function openWorkspaceSelect(input: {
@@ -100,13 +102,21 @@ export async function warpWorkspaceSession(input: {
copyChanges: boolean copyChanges: boolean
done?: () => void done?: () => void
}): Promise<boolean> { }): Promise<boolean> {
const result = await input.sdk.client.experimental.workspace let result
.warp({ try {
result = await input.sdk.client.experimental.workspace.warp({
id: input.workspaceID, id: input.workspaceID,
sessionID: input.sessionID, sessionID: input.sessionID,
copyChanges: input.copyChanges, copyChanges: input.copyChanges,
}) })
.catch(() => undefined) } catch (err) {
input.toast.show({
title: "Failed to warp session",
message: errorMessage(err),
variant: "error",
})
return false
}
if (!result?.data) { if (!result?.data) {
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") { if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
await DialogAlert.show( await DialogAlert.show(
@@ -118,7 +128,8 @@ export async function warpWorkspaceSession(input: {
} }
input.toast.show({ input.toast.show({
message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, title: "Failed to warp session",
message: errorMessage(result?.error ?? "no response"),
variant: "error", variant: "error",
}) })
return false return false

View File

@@ -40,6 +40,7 @@ import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { errorMessage } from "@/util/error"
import { formatDuration } from "@/util/format" import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts" import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
@@ -216,14 +217,25 @@ export function Prompt(props: PromptProps) {
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) { async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true) setCreatingWorkspace(true)
const result = await sdk.client.experimental.workspace let result
.create({ type: selection.workspaceType, branch: null }) try {
.catch(() => undefined) result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
if (result == undefined || result.error || !result.data) { } catch (err) {
selectWorkspace(undefined) selectWorkspace(undefined)
setCreatingWorkspace(false) setCreatingWorkspace(false)
toast.show({ toast.show({
message: "Creating workspace failed", title: "Creating workspace failed",
message: errorMessage(err),
variant: "error",
})
return
}
if (result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error", variant: "error",
}) })
return return

View File

@@ -27,6 +27,16 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpErr
{ httpApiStatus: 400 }, { httpApiStatus: 400 },
) {} ) {}
export class ApiWorkspaceCreateError extends Schema.ErrorClass<ApiWorkspaceCreateError>("WorkspaceCreateError")(
{
name: Schema.Literal("WorkspaceCreateError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const WorkspacePaths = { export const WorkspacePaths = {
adapters: `${root}/adapter`, adapters: `${root}/adapter`,
list: root, list: root,
@@ -64,7 +74,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
payload: CreatePayload, payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"), success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest, error: [ApiWorkspaceCreateError, HttpApiError.BadRequest],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.workspace.create", identifier: "experimental.workspace.create",

View File

@@ -2,12 +2,12 @@ import { listAdapters } from "@/control-plane/adapters"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state" import * as InstanceState from "@/effect/instance-state"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Effect } from "effect" import { Cause, Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
import { notFound } from "../errors" import { notFound } from "../errors"
import { ApiVcsApplyError } from "../groups/instance" import { ApiVcsApplyError } from "../groups/instance"
import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" import { ApiWorkspaceCreateError, ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -30,7 +30,22 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
extra: ctx.payload.extra ?? null, extra: ctx.payload.extra ?? null,
projectID: instance.project.id, projectID: instance.project.id,
}) })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) .pipe(
Effect.catchCause((cause) => {
// Plugin throws surface as defects (because EffectBridge.fromPromise uses Effect.promise),
// bypassing Effect.mapError. Walk the cause to surface the real error to the client.
const die = cause.reasons.find(Cause.isDieReason)
const fail = cause.reasons.find(Cause.isFailReason)
const reason: unknown = die?.defect ?? fail?.error
const message = reason instanceof Error ? reason.message : "Workspace creation failed"
return Effect.fail(
new ApiWorkspaceCreateError({
name: "WorkspaceCreateError",
data: { message },
}),
)
}),
)
}) })
const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () { const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () {