feat(httpapi): bridge experimental read endpoints (#24365)

This commit is contained in:
Kit Langton
2026-04-25 14:46:06 -04:00
committed by GitHub
parent f5dce6d960
commit 95d4bb2130
8 changed files with 270 additions and 13 deletions
+24 -1
View File
@@ -30,6 +30,29 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
## Route Slice Checklist
Use this checklist for each small HttpApi migration PR:
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
## Experimental Read Slice Guidance
For the experimental route group, port read-only JSON routes before mutations:
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
- Preserve response headers such as pagination cursors if a route is ported.
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
## Schema Notes
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
@@ -141,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `mcp` | `bridged` partial | status only |
| `workspace` | `bridged` | list, get, enter |
| top-level instance reads | `bridged` | path, vcs, command, agent, skill, lsp, formatter |
| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list |
| experimental JSON routes | `bridged` partial | console reads, tool ids, resource list; worktree and global session list remain later |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
| `event` | `special` | SSE |
+10 -10
View File
@@ -36,16 +36,16 @@ import { withStatics } from "@/util/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
export const Resource = z
.object({
name: z.string(),
uri: z.string(),
description: z.string().optional(),
mimeType: z.string().optional(),
client: z.string(),
})
.meta({ ref: "McpResource" })
export type Resource = z.infer<typeof Resource>
export const Resource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
description: Schema.optional(Schema.String),
mimeType: Schema.optional(Schema.String),
client: Schema.String,
})
.annotate({ identifier: "McpResource" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Resource = Schema.Schema.Type<typeof Resource>
export const ToolsChanged = BusEvent.define(
"mcp.tools.changed",
@@ -394,7 +394,7 @@ export const ExperimentalRoutes = lazy(() =>
description: "MCP resources",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Resource)),
schema: resolver(z.record(z.string(), MCP.Resource.zod)),
},
},
},
@@ -0,0 +1,159 @@
import { Account } from "@/account/account"
import { Config } from "@/config"
import { MCP } from "@/mcp"
import { ToolRegistry } from "@/tool"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optionalKey(Schema.String),
switchableOrgCount: Schema.Number,
}).annotate({ identifier: "ConsoleState" })
const ConsoleOrgOption = Schema.Struct({
accountID: Schema.String,
accountEmail: Schema.String,
accountUrl: Schema.String,
orgID: Schema.String,
orgName: Schema.String,
active: Schema.Boolean,
}).annotate({ identifier: "ConsoleOrgOption" })
const ConsoleOrgList = Schema.Struct({
orgs: Schema.Array(ConsoleOrgOption),
}).annotate({ identifier: "ConsoleOrgList" })
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
export const ExperimentalPaths = {
console: "/experimental/console",
consoleOrgs: "/experimental/console/orgs",
toolIDs: "/experimental/tool/ids",
resource: "/experimental/resource",
} as const
export const ExperimentalApi = HttpApi.make("experimental")
.add(
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
success: ConsoleStateResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.get",
summary: "Get active Console provider metadata",
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
success: ConsoleOrgList,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.listOrgs",
summary: "List switchable Console orgs",
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
success: ToolIDs,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.ids",
summary: "List tool IDs",
description:
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.resource.list",
summary: "Get MCP resources",
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "experimental",
description: "Experimental HttpApi read-only routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const experimentalHandlers = Layer.unwrap(
Effect.gen(function* () {
const account = yield* Account.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const registry = yield* ToolRegistry.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
orgs: groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
),
}
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("toolIDs", toolIDs)
.handle("resource", resource),
)
}),
).pipe(
Layer.provide(Account.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
)
@@ -11,6 +11,7 @@ import { Filesystem } from "@/util"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
@@ -63,6 +64,7 @@ const instance = HttpRouter.middleware()(
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
@@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { ExperimentalPaths } from "./httpapi/experimental"
import { FilePaths } from "./httpapi/file"
import { InstancePaths } from "./httpapi/instance"
import { McpPaths } from "./httpapi/mcp"
@@ -45,6 +46,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
app.get("/config", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
+3 -1
View File
@@ -20,6 +20,7 @@ import { Plugin } from "@/plugin"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { InstanceState } from "@/effect"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -575,9 +576,10 @@ export const BashTool = Tool.define(
log.info("bash tool using shell", { shell })
const limits = yield* trunc.limits()
const instance = yield* InstanceState.context
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
description: DESCRIPTION.replaceAll("${directory}", instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return InstanceRoutes(websocket)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("experimental HttpApi", () => {
test("serves read-only experimental endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
})
const headers = { "x-opencode-directory": tmp.path }
const [consoleState, consoleOrgs, toolIDs, resources] = await Promise.all([
app().request(ExperimentalPaths.console, { headers }),
app().request(ExperimentalPaths.consoleOrgs, { headers }),
app().request(ExperimentalPaths.toolIDs, { headers }),
app().request(ExperimentalPaths.resource, { headers }),
])
expect(consoleState.status).toBe(200)
expect(await consoleState.json()).toEqual({
consoleManagedProviders: [],
switchableOrgCount: 0,
})
expect(consoleOrgs.status).toBe(200)
expect(await consoleOrgs.json()).toEqual({ orgs: [] })
expect(toolIDs.status).toBe(200)
expect(await toolIDs.json()).toContain("bash")
expect(resources.status).toBe(200)
expect(await resources.json()).toEqual({})
})
})