Browse Source

feat(httpapi): bridge instance dispose endpoint (#24368)

Kit Langton 4 months ago
parent
commit
b4f4134e81

+ 1 - 1
packages/opencode/specs/effect/http-api.md

@@ -163,7 +163,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `file`                   | `bridged` partial | find text/file/symbol, list/content/status                                               |
 | `file`                   | `bridged` partial | find text/file/symbol, list/content/status                                               |
 | `mcp`                    | `bridged` partial | status only                                                                              |
 | `mcp`                    | `bridged` partial | status only                                                                              |
 | `workspace`              | `bridged`         | list, get, enter                                                                         |
 | `workspace`              | `bridged`         | list, get, enter                                                                         |
-| top-level instance reads | `bridged`         | path, vcs, command, agent, skill, lsp, formatter                                         |
+| top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                |
 | experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list, resource list; global session list remains later |
 | experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list, resource list; global session list remains later |
 | `session`                | `later/special`   | large stateful surface plus streaming                                                    |
 | `session`                | `later/special`   | large stateful surface plus streaming                                                    |
 | `sync`                   | `later`           | process/control side effects                                                             |
 | `sync`                   | `later`           | process/control side effects                                                             |

+ 17 - 0
packages/opencode/src/server/routes/instance/httpapi/instance.ts

@@ -9,6 +9,7 @@ import * as InstanceState from "@/effect/instance-state"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "./auth"
 import { Authorization } from "./auth"
+import { markInstanceForDisposal } from "./lifecycle"
 
 
 const PathInfo = Schema.Struct({
 const PathInfo = Schema.Struct({
   home: Schema.String,
   home: Schema.String,
@@ -23,6 +24,7 @@ const VcsDiffQuery = Schema.Struct({
 })
 })
 
 
 export const InstancePaths = {
 export const InstancePaths = {
+  dispose: "/instance/dispose",
   path: "/path",
   path: "/path",
   vcs: "/vcs",
   vcs: "/vcs",
   vcsDiff: "/vcs/diff",
   vcsDiff: "/vcs/diff",
@@ -37,6 +39,15 @@ export const InstanceApi = HttpApi.make("instance")
   .add(
   .add(
     HttpApiGroup.make("instance")
     HttpApiGroup.make("instance")
       .add(
       .add(
+        HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "instance.dispose",
+            summary: "Dispose instance",
+            description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
+          }),
+        ),
         HttpApiEndpoint.get("path", InstancePaths.path, {
         HttpApiEndpoint.get("path", InstancePaths.path, {
           success: PathInfo,
           success: PathInfo,
         }).annotateMerge(
         }).annotateMerge(
@@ -138,6 +149,11 @@ export const instanceHandlers = Layer.unwrap(
     const skill = yield* Skill.Service
     const skill = yield* Skill.Service
     const vcs = yield* Vcs.Service
     const vcs = yield* Vcs.Service
 
 
+    const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
+      yield* markInstanceForDisposal(yield* InstanceState.context)
+      return true
+    })
+
     const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
     const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
       const ctx = yield* InstanceState.context
       const ctx = yield* InstanceState.context
       return {
       return {
@@ -180,6 +196,7 @@ export const instanceHandlers = Layer.unwrap(
 
 
     return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
     return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
       handlers
       handlers
+        .handle("dispose", dispose)
         .handle("path", getPath)
         .handle("path", getPath)
         .handle("vcs", getVcs)
         .handle("vcs", getVcs)
         .handle("vcsDiff", getVcsDiff)
         .handle("vcsDiff", getVcsDiff)

+ 24 - 0
packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts

@@ -0,0 +1,24 @@
+import { Instance, type InstanceContext } from "@/project/instance"
+import { Effect } from "effect"
+import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
+
+const disposeAfterResponse = new WeakMap<object, InstanceContext>()
+
+export const markInstanceForDisposal = (ctx: InstanceContext) =>
+  HttpEffect.appendPreResponseHandler((request, response) =>
+    Effect.sync(() => {
+      disposeAfterResponse.set(request.source, ctx)
+      return 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
+    disposeAfterResponse.delete(request.source)
+    yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
+    return response
+  })

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -19,6 +19,7 @@ import { ProjectApi, projectHandlers } from "./project"
 import { ProviderApi, providerHandlers } from "./provider"
 import { ProviderApi, providerHandlers } from "./provider"
 import { QuestionApi, questionHandlers } from "./question"
 import { QuestionApi, questionHandlers } from "./question"
 import { WorkspaceApi, workspaceHandlers } from "./workspace"
 import { WorkspaceApi, workspaceHandlers } from "./workspace"
+import { disposeMiddleware } from "./lifecycle"
 import { memoMap } from "@opencode-ai/core/effect/memo-map"
 import { memoMap } from "@opencode-ai/core/effect/memo-map"
 
 
 const Query = Schema.Struct({
 const Query = Schema.Struct({
@@ -83,6 +84,7 @@ export const routes = Layer.mergeAll(
 export const webHandler = lazy(() =>
 export const webHandler = lazy(() =>
   HttpRouter.toWebHandler(routes, {
   HttpRouter.toWebHandler(routes, {
     memoMap,
     memoMap,
+    middleware: disposeMiddleware,
   }),
   }),
 )
 )
 
 

+ 1 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -64,6 +64,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.get(FilePaths.content, (c) => handler(c.req.raw, context))
     app.get(FilePaths.content, (c) => handler(c.req.raw, context))
     app.get(FilePaths.status, (c) => handler(c.req.raw, context))
     app.get(FilePaths.status, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
+    app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.command, (c) => handler(c.req.raw, context))

+ 23 - 0
packages/opencode/test/server/httpapi-instance.test.ts

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
 import type { UpgradeWebSocket } from "hono/ws"
 import type { UpgradeWebSocket } from "hono/ws"
 import path from "path"
 import path from "path"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus } from "@/bus/global"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { InstanceRoutes } from "../../src/server/routes/instance"
 import { InstanceRoutes } from "../../src/server/routes/instance"
 import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
 import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
@@ -77,4 +78,26 @@ describe("instance HttpApi", () => {
     expect(formatter.status).toBe(200)
     expect(formatter.status).toBe(200)
     expect(await formatter.json()).toEqual([])
     expect(await formatter.json()).toEqual([])
   })
   })
+
+  test("serves instance dispose through Hono bridge", async () => {
+    await using tmp = await tmpdir()
+
+    const disposed = new Promise<string | undefined>((resolve) => {
+      const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
+        if (event.payload.type !== "server.instance.disposed") return
+        GlobalBus.off("event", onEvent)
+        resolve(event.directory)
+      }
+      GlobalBus.on("event", onEvent)
+    })
+
+    const response = await app().request(InstancePaths.dispose, {
+      method: "POST",
+      headers: { "x-opencode-directory": tmp.path },
+    })
+
+    expect(response.status).toBe(200)
+    expect(await response.json()).toBe(true)
+    expect(await disposed).toBe(tmp.path)
+  })
 })
 })