feat(httpapi): bridge instance dispose endpoint (#24368)
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user