Skip to content

Commit b4f4134

Browse files
authored
feat(httpapi): bridge instance dispose endpoint (#24368)
1 parent cd64b67 commit b4f4134

6 files changed

Lines changed: 68 additions & 1 deletion

File tree

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

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

packages/opencode/src/server/routes/instance/httpapi/instance.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as InstanceState from "@/effect/instance-state"
99
import { Effect, Layer, Schema } from "effect"
1010
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
1111
import { Authorization } from "./auth"
12+
import { markInstanceForDisposal } from "./lifecycle"
1213

1314
const PathInfo = Schema.Struct({
1415
home: Schema.String,
@@ -23,6 +24,7 @@ const VcsDiffQuery = Schema.Struct({
2324
})
2425

2526
export const InstancePaths = {
27+
dispose: "/instance/dispose",
2628
path: "/path",
2729
vcs: "/vcs",
2830
vcsDiff: "/vcs/diff",
@@ -37,6 +39,15 @@ export const InstanceApi = HttpApi.make("instance")
3739
.add(
3840
HttpApiGroup.make("instance")
3941
.add(
42+
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
43+
success: Schema.Boolean,
44+
}).annotateMerge(
45+
OpenApi.annotations({
46+
identifier: "instance.dispose",
47+
summary: "Dispose instance",
48+
description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
49+
}),
50+
),
4051
HttpApiEndpoint.get("path", InstancePaths.path, {
4152
success: PathInfo,
4253
}).annotateMerge(
@@ -138,6 +149,11 @@ export const instanceHandlers = Layer.unwrap(
138149
const skill = yield* Skill.Service
139150
const vcs = yield* Vcs.Service
140151

152+
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
153+
yield* markInstanceForDisposal(yield* InstanceState.context)
154+
return true
155+
})
156+
141157
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
142158
const ctx = yield* InstanceState.context
143159
return {
@@ -180,6 +196,7 @@ export const instanceHandlers = Layer.unwrap(
180196

181197
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
182198
handlers
199+
.handle("dispose", dispose)
183200
.handle("path", getPath)
184201
.handle("vcs", getVcs)
185202
.handle("vcsDiff", getVcsDiff)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Instance, type InstanceContext } from "@/project/instance"
2+
import { Effect } from "effect"
3+
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
4+
5+
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
6+
7+
export const markInstanceForDisposal = (ctx: InstanceContext) =>
8+
HttpEffect.appendPreResponseHandler((request, response) =>
9+
Effect.sync(() => {
10+
disposeAfterResponse.set(request.source, ctx)
11+
return response
12+
}),
13+
)
14+
15+
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
16+
Effect.gen(function* () {
17+
const response = yield* effect
18+
const request = yield* HttpServerRequest.HttpServerRequest
19+
const ctx = disposeAfterResponse.get(request.source)
20+
if (!ctx) return response
21+
disposeAfterResponse.delete(request.source)
22+
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
23+
return response
24+
})

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ProjectApi, projectHandlers } from "./project"
1919
import { ProviderApi, providerHandlers } from "./provider"
2020
import { QuestionApi, questionHandlers } from "./question"
2121
import { WorkspaceApi, workspaceHandlers } from "./workspace"
22+
import { disposeMiddleware } from "./lifecycle"
2223
import { memoMap } from "@opencode-ai/core/effect/memo-map"
2324

2425
const Query = Schema.Struct({
@@ -83,6 +84,7 @@ export const routes = Layer.mergeAll(
8384
export const webHandler = lazy(() =>
8485
HttpRouter.toWebHandler(routes, {
8586
memoMap,
87+
middleware: disposeMiddleware,
8688
}),
8789
)
8890

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
6464
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
6565
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
6666
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
67+
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
6768
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
6869
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
6970
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))

packages/opencode/test/server/httpapi-instance.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
22
import type { UpgradeWebSocket } from "hono/ws"
33
import path from "path"
44
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { GlobalBus } from "@/bus/global"
56
import { Instance } from "../../src/project/instance"
67
import { InstanceRoutes } from "../../src/server/routes/instance"
78
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
@@ -77,4 +78,26 @@ describe("instance HttpApi", () => {
7778
expect(formatter.status).toBe(200)
7879
expect(await formatter.json()).toEqual([])
7980
})
81+
82+
test("serves instance dispose through Hono bridge", async () => {
83+
await using tmp = await tmpdir()
84+
85+
const disposed = new Promise<string | undefined>((resolve) => {
86+
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
87+
if (event.payload.type !== "server.instance.disposed") return
88+
GlobalBus.off("event", onEvent)
89+
resolve(event.directory)
90+
}
91+
GlobalBus.on("event", onEvent)
92+
})
93+
94+
const response = await app().request(InstancePaths.dispose, {
95+
method: "POST",
96+
headers: { "x-opencode-directory": tmp.path },
97+
})
98+
99+
expect(response.status).toBe(200)
100+
expect(await response.json()).toBe(true)
101+
expect(await disposed).toBe(tmp.path)
102+
})
80103
})

0 commit comments

Comments
 (0)