Sfoglia il codice sorgente

fix(httpapi): install Instance ALS for adapter Promise bridge (#25417)

Kit Langton 4 mesi fa
parent
commit
5242a1c6b4

+ 9 - 8
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(() => {

+ 19 - 0
packages/opencode/src/effect/bridge.ts

@@ -21,6 +21,25 @@ function restore<R>(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 = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
+  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<Shape> {
   return Effect.gen(function* () {
     const ctx = yield* Effect.context()

+ 3 - 4
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<Target> {
-  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(

+ 18 - 0
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