From 51734c1381f8b8e2b6b137cadf8c9237d03ae91e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:37:24 -0400 Subject: [PATCH] fix(httpapi): install Instance ALS for adapter Promise bridge --- .../opencode/src/control-plane/workspace.ts | 17 +++++++++-------- packages/opencode/src/effect/bridge.ts | 19 +++++++++++++++++++ .../httpapi/middleware/workspace-routing.ts | 7 +++---- .../test/server/httpapi-workspace.test.ts | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7e4b4a6ff466..485cb2e925fd 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -25,6 +25,7 @@ import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { EffectBridge } from "@/effect/bridge" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" @@ -336,7 +337,7 @@ export const layer = Layer.effect( const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") return @@ -420,7 +421,7 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -459,8 +460,8 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* Effect.promise(() => - Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), + const config = yield* EffectBridge.fromPromise(() => + adapter.configure({ ...input, id, name: Slug.create(), directory: null }), ) const info: Info = { @@ -496,7 +497,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adapter.create(config, env)) + yield* EffectBridge.fromPromise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -532,7 +533,7 @@ export const layer = Layer.effect( }) const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) yield* sync.run(Session.Event.Updated, { sessionID: input.sessionID, @@ -724,10 +725,10 @@ export const layer = Layer.effect( yield* stopSync(id) const info = fromRow(row) - yield* Effect.catch( + yield* Effect.catchCause( Effect.gen(function* () { const adapter = getAdapter(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) + yield* EffectBridge.fromPromise(() => adapter.remove(info)) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 3c310129f151..16d8f93669c6 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -21,6 +21,25 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +/** + * Bridge from Effect into a Promise-returning JS callback while installing + * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for + * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do + * not propagate across async/await boundaries inside `Effect.promise(() => + * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, + * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses + * into JS that may itself spawn new Effect runtimes (workspace adapters, + * legacy plugins, etc.). + * + * Mirrors `Effect.promise` but restores legacy ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + }) + export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index c8762bae6600..4a07aaf11c57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -2,6 +2,7 @@ import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" +import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" @@ -79,10 +80,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - return Effect.gen(function* () { - const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) - }) + const adapter = getAdapter(workspace.projectID, workspace.type) + return EffectBridge.fromPromise(() => adapter.target(workspace)) } function proxyRemote( diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 48dcd885b221..7fc1ec761d1f 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -217,6 +217,24 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + it.live("documents legacy Hono accepting the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true