From 48d4175461d381fc5c6711e51b30da46dfc26d02 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 22:24:43 -0400 Subject: [PATCH] fix: preserve workspace on HttpApi instance lifecycle events --- .../routes/instance/httpapi/lifecycle.ts | 60 ++++++++++++++----- .../server/httpapi-instance-context.test.ts | 59 +++++++++++++++++- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index c93261a0be..29e43cb669 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,32 +1,60 @@ +import type { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { WorkspaceRef } from "@/effect/instance-ref" import { Instance, type InstanceContext } from "@/project/instance" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" -const disposeAfterResponse = new WeakMap() +type MarkedInstance = { + ctx: InstanceContext + workspaceID?: WorkspaceID +} + +const disposeAfterResponse = new WeakMap() export const markInstanceForDisposal = (ctx: InstanceContext) => - HttpEffect.appendPreResponseHandler((request, response) => - Effect.sync(() => { - disposeAfterResponse.set(request.source, ctx) - return response - }), - ) + Effect.gen(function* () { + const workspaceID = yield* WorkspaceRef + return yield* HttpEffect.appendPreResponseHandler((request, response) => + Effect.sync(() => { + disposeAfterResponse.set(request.source, { ctx, workspaceID }) + return response + }), + ) + }) export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => - HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as( - Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), - response, - ), - ) + Effect.gen(function* () { + const workspaceID = yield* WorkspaceRef + return yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.as( + Effect.uninterruptible( + Effect.promise(() => + WorkspaceContext.provide({ + workspaceID, + fn: () => Instance.restore(ctx, () => Instance.reload(next)), + }), + ), + ), + response, + ), + ) + }) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => Effect.gen(function* () { const response = yield* effect const request = yield* HttpServerRequest.HttpServerRequest - const ctx = disposeAfterResponse.get(request.source) - if (!ctx) return response + const marked = disposeAfterResponse.get(request.source) + if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))) + yield* Effect.uninterruptible( + Effect.promise(() => + WorkspaceContext.provide({ + workspaceID: marked.workspaceID, + fn: () => Instance.restore(marked.ctx, () => Instance.dispose()), + }), + ), + ) return response }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 74b1ecdeba..aec3743e60 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,7 +1,8 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" @@ -12,6 +13,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" +import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" @@ -84,6 +86,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) +const waitDisposedEvent = Effect.promise( + () => + new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { + const timer = setTimeout(() => { + GlobalBus.off("event", onEvent) + reject(new Error("timed out waiting for instance disposal")) + }, 10_000) + + function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { + if (event.payload.type !== "server.instance.disposed") return + clearTimeout(timer) + GlobalBus.off("event", onEvent) + resolve({ directory: event.directory, workspace: event.workspace }) + } + + GlobalBus.on("event", onEvent) + }), +) + +const serveDisposeProbe = () => + HttpRouter.serve( + HttpRouter.add( + "POST", + "/dispose-probe", + Effect.gen(function* () { + const instance = yield* InstanceRef + if (!instance) return HttpServerResponse.empty({ status: 500 }) + yield* markInstanceForDisposal(instance) + return yield* HttpServerResponse.json(true) + }), + ).pipe(Layer.provide(instanceContextTestLayer)), + { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }, + ).pipe(Layer.build) + describe("HttpApi instance context middleware", () => { it.live("provides instance context from the routed directory", () => Effect.gen(function* () { @@ -164,4 +200,25 @@ describe("HttpApi instance context middleware", () => { }) }), ) + + it.live("preserves selected workspace id on instance disposal events", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-dispose-event", + directory: workspaceDir, + }) + yield* serveDisposeProbe() + const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped) + + const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute) + + expect(response.status).toBe(200) + expect(yield* response.json).toBe(true) + expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id }) + }), + ) })