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

This commit is contained in:
Kit Langton
2026-04-25 15:24:07 -04:00
committed by GitHub
parent cd64b67038
commit b4f4134e81
6 changed files with 68 additions and 1 deletions

View File

@@ -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 |
| `mcp` | `bridged` partial | status only |
| `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 |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |

View File

@@ -9,6 +9,7 @@ import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
const PathInfo = Schema.Struct({
home: Schema.String,
@@ -23,6 +24,7 @@ const VcsDiffQuery = Schema.Struct({
})
export const InstancePaths = {
dispose: "/instance/dispose",
path: "/path",
vcs: "/vcs",
vcsDiff: "/vcs/diff",
@@ -37,6 +39,15 @@ export const InstanceApi = HttpApi.make("instance")
.add(
HttpApiGroup.make("instance")
.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, {
success: PathInfo,
}).annotateMerge(
@@ -138,6 +149,11 @@ export const instanceHandlers = Layer.unwrap(
const skill = yield* Skill.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 ctx = yield* InstanceState.context
return {
@@ -180,6 +196,7 @@ export const instanceHandlers = Layer.unwrap(
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)

View File

@@ -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
})

View File

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

View File

@@ -64,6 +64,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.get(FilePaths.content, (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.post(InstancePaths.dispose, (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.command, (c) => handler(c.req.raw, context))

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
@@ -77,4 +78,26 @@ describe("instance HttpApi", () => {
expect(formatter.status).toBe(200)
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)
})
})