diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index a16ef69e7..5b31b08a9 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -44,7 +44,7 @@ type ToolListQuery = { model: string } -const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* () { +export const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* () { const config = yield* Config.Service const account = yield* Account.Service const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { @@ -56,7 +56,7 @@ const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* () } }) -const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(function* () { +export const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(function* () { const account = yield* Account.Service const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { concurrency: "unbounded", @@ -76,18 +76,18 @@ const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(functio } }) -const switchConsoleOrg = Effect.fn("ExperimentalRoutes.console.switchOrg")(function* (body: ConsoleSwitchBody) { +export const switchConsoleOrg = Effect.fn("ExperimentalRoutes.console.switchOrg")(function* (body: ConsoleSwitchBody) { const account = yield* Account.Service yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) return true }) -const listToolIDs = Effect.fn("ExperimentalRoutes.tool.ids")(function* () { +export const listToolIDs = Effect.fn("ExperimentalRoutes.tool.ids")(function* () { const registry = yield* ToolRegistry.Service return yield* registry.ids() }) -const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provider, model }: ToolListQuery) { +export const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provider, model }: ToolListQuery) { const registry = yield* ToolRegistry.Service const agents = yield* Agent.Service const agent = yield* agents.get(yield* agents.defaultAgent()) @@ -104,29 +104,29 @@ const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provide })) }) -const createWorktree = Effect.fn("ExperimentalRoutes.worktree.create")(function* (body?: Worktree.CreateInput) { +export const createWorktree = Effect.fn("ExperimentalRoutes.worktree.create")(function* (body?: Worktree.CreateInput) { const worktrees = yield* Worktree.Service return yield* worktrees.create(body) }) -const listWorktrees = Effect.fn("ExperimentalRoutes.worktree.list")(function* () { +export const listWorktrees = Effect.fn("ExperimentalRoutes.worktree.list")(function* () { const worktrees = yield* Worktree.Service return yield* worktrees.list() }) -const removeWorktree = Effect.fn("ExperimentalRoutes.worktree.remove")(function* (body: Worktree.RemoveInput) { +export const removeWorktree = Effect.fn("ExperimentalRoutes.worktree.remove")(function* (body: Worktree.RemoveInput) { const worktrees = yield* Worktree.Service yield* worktrees.remove(body) return true }) -const resetWorktree = Effect.fn("ExperimentalRoutes.worktree.reset")(function* (body: Worktree.ResetInput) { +export const resetWorktree = Effect.fn("ExperimentalRoutes.worktree.reset")(function* (body: Worktree.ResetInput) { const worktrees = yield* Worktree.Service yield* worktrees.reset(body) return true }) -const listResources = Effect.fn("ExperimentalRoutes.resource.list")(function* () { +export const listResources = Effect.fn("ExperimentalRoutes.resource.list")(function* () { const mcp = yield* MCP.Service return yield* mcp.resources() }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts new file mode 100644 index 000000000..4e11ef0c0 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -0,0 +1,193 @@ +import { ConsoleState } from "@/config/console-state" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { BadRequestError } from "./common" + +const root = "/experimental" + +const ConsoleOrgOption = Schema.Struct({ + accountID: Schema.String, + accountEmail: Schema.String, + accountUrl: Schema.String, + orgID: Schema.String, + orgName: Schema.String, + active: Schema.Boolean, +}) + +const ConsoleOrgList = Schema.Struct({ + orgs: Schema.Array(ConsoleOrgOption), +}) + +const ConsoleSwitchPayload = Schema.Struct({ + accountID: Schema.String, + orgID: Schema.String, +}) + +const ToolListQuery = Schema.Struct({ + provider: Schema.String, + model: Schema.String, +}) + +const ToolListItem = Schema.Struct({ + id: Schema.String, + description: Schema.String, + parameters: Schema.Any, +}) + +const McpResource = Schema.Struct({ + name: Schema.String, + uri: Schema.String, + description: Schema.optionalKey(Schema.String), + mimeType: Schema.optionalKey(Schema.String), + client: Schema.String, +}) + +const WorktreeInfo = Schema.Struct({ + name: Schema.String, + branch: Schema.String, + directory: Schema.String, + source: Schema.optionalKey(Schema.Literals(["created", "existing"])), +}) + +const WorktreeCreatePayload = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + startCommand: Schema.optionalKey(Schema.String), +}) + +const WorktreeDirectoryPayload = Schema.Struct({ + directory: Schema.String, +}) + +export const ExperimentalPaths = { + console: `${root}/console`, + consoleOrgs: `${root}/console/orgs`, + consoleSwitch: `${root}/console/switch`, + tool: `${root}/tool`, + toolIds: `${root}/tool/ids`, + resource: `${root}/resource`, + worktree: `${root}/worktree`, + worktreeReset: `${root}/worktree/reset`, +} as const + +export const ExperimentalApi = HttpApi.make("experimental") + .add( + HttpApiGroup.make("experimental") + .add( + HttpApiEndpoint.get("console", ExperimentalPaths.console, { + success: ConsoleState, + }).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.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + payload: ConsoleSwitchPayload, + success: Schema.Boolean, + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.console.switchOrg", + summary: "Switch active Console org", + description: "Persist a new active Console account/org selection for the current local OpenCode state.", + }), + ), + HttpApiEndpoint.get("tool", ExperimentalPaths.tool, { + query: ToolListQuery, + success: Schema.Array(ToolListItem), + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tool.list", + summary: "List tools", + description: + "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + }), + ), + HttpApiEndpoint.get("toolIds", ExperimentalPaths.toolIds, { + success: Schema.Array(Schema.String), + error: BadRequestError, + }).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, McpResource), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.resource.list", + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + }), + ), + HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + payload: Schema.optional(WorktreeCreatePayload), + success: WorktreeInfo, + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "worktree.create", + summary: "Create worktree", + description: "Create a new git worktree for the current project and run any configured startup scripts.", + }), + ), + HttpApiEndpoint.get("worktreeList", ExperimentalPaths.worktree, { + success: Schema.Array(WorktreeInfo), + }).annotateMerge( + OpenApi.annotations({ + identifier: "worktree.list", + summary: "List worktrees", + description: "List all sandbox worktrees for the current project.", + }), + ), + HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + payload: WorktreeDirectoryPayload, + success: Schema.Boolean, + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "worktree.remove", + summary: "Remove worktree", + description: "Remove a git worktree and delete its branch.", + }), + ), + HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + payload: WorktreeDirectoryPayload, + success: Schema.Boolean, + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "worktree.reset", + summary: "Reset worktree", + description: "Reset a worktree branch to the primary default branch.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "experimental", + description: "HttpApi experimental JSON routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "HttpApi surface for ordinary experimental JSON routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts new file mode 100644 index 000000000..1d9baf80b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -0,0 +1,123 @@ +import { + createWorktree, + getConsoleState, + listConsoleOrgs, + listResources, + listToolIDs, + listTools, + listWorktrees, + removeWorktree, + resetWorktree, + switchConsoleOrg, +} from "@/server/instance/experimental" +import { NamedError } from "@opencode-ai/util/error" +import { Effect } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import z from "zod" +import { ExperimentalApi } from "../groups/experimental" + +const ConsoleSwitchBody = z.object({ + accountID: z.string(), + orgID: z.string(), +}) + +const WorktreeCreateBody = z + .object({ + name: z.string().optional(), + startCommand: z.string().optional(), + }) + .optional() +const WorktreeDirectoryBody = z.object({ + directory: z.string(), +}) + +function isJsonContentType(contentType: string | undefined) { + // Mirrors hono/validator's jsonRegex, reached through hono-openapi's validator("json"). + return /^application\/([a-z-.]+\+)?json(?:;\s*[a-zA-Z0-9-]+=([^;]+))*$/.test(contentType ?? "") +} + +function badRequestJson(body: unknown) { + return HttpServerResponse.jsonUnsafe(body, { status: 400 }) +} + +function parseJsonBody(request: HttpServerRequest.HttpServerRequest, schema: z.ZodType) { + return Effect.gen(function* () { + const body = isJsonContentType(request.headers["content-type"]) + ? yield* request.json.pipe( + Effect.catch(() => Effect.succeed(HttpServerResponse.raw("Malformed JSON in request body", { status: 400 }))), + ) + : {} + if (HttpServerResponse.isHttpServerResponse(body)) return body + + const parsed = schema.safeParse(body) + if (!parsed.success) return badRequestJson({ data: body, error: parsed.error.issues, success: false }) + return parsed.data + }) +} + +function experimentalFailure(error: unknown) { + if (error instanceof NamedError) { + const status = error.name.startsWith("Worktree") ? 400 : 500 + return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status })) + } + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ message: "Unexpected server error. Check server logs for details." }).toObject(), + { status: 500 }, + ), + ) +} + +function jsonResponse(effect: Effect.Effect) { + return effect.pipe( + Effect.map((value) => HttpServerResponse.jsonUnsafe(value)), + Effect.catch(experimentalFailure), + Effect.catchDefect(experimentalFailure), + ) +} + +export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => + handlers + .handleRaw("console", () => jsonResponse(getConsoleState())) + .handleRaw("consoleOrgs", () => jsonResponse(listConsoleOrgs())) + .handleRaw("consoleSwitch", (ctx) => + Effect.gen(function* () { + const payload = yield* parseJsonBody(ctx.request, ConsoleSwitchBody) + if (HttpServerResponse.isHttpServerResponse(payload)) return payload + return yield* jsonResponse(switchConsoleOrg(payload)) + }), + ) + .handleRaw("tool", (ctx) => + jsonResponse( + listTools({ + provider: ctx.query.provider, + model: ctx.query.model, + }), + ), + ) + .handleRaw("toolIds", () => jsonResponse(listToolIDs())) + .handleRaw("resource", () => jsonResponse(listResources())) + .handleRaw("worktreeCreate", (ctx) => + Effect.gen(function* () { + const payload = yield* parseJsonBody(ctx.request, WorktreeCreateBody) + if (HttpServerResponse.isHttpServerResponse(payload)) return payload + return yield* jsonResponse(createWorktree(payload)) + }), + ) + .handleRaw("worktreeList", () => jsonResponse(listWorktrees())) + .handleRaw("worktreeRemove", (ctx) => + Effect.gen(function* () { + const payload = yield* parseJsonBody(ctx.request, WorktreeDirectoryBody) + if (HttpServerResponse.isHttpServerResponse(payload)) return payload + return yield* jsonResponse(removeWorktree(payload)) + }), + ) + .handleRaw("worktreeReset", (ctx) => + Effect.gen(function* () { + const payload = yield* parseJsonBody(ctx.request, WorktreeDirectoryBody) + if (HttpServerResponse.isHttpServerResponse(payload)) return payload + return yield* jsonResponse(resetWorktree(payload)) + }), + ), +) diff --git a/packages/opencode/test/server/experimental-routes.test.ts b/packages/opencode/test/server/experimental-routes.test.ts index 6ebc3388a..c6198279b 100644 --- a/packages/opencode/test/server/experimental-routes.test.ts +++ b/packages/opencode/test/server/experimental-routes.test.ts @@ -1,9 +1,16 @@ import { afterEach, describe, expect, test } from "bun:test" +import { NodeFileSystem, NodeHttpPlatform, NodePath } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { Etag, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" import { Hono } from "hono" import { Log } from "@opencode-ai/core/util/log" +import { AppRuntime } from "../../src/effect/app-runtime" import { Instance } from "../../src/project/instance" import { ExperimentalRoutes } from "../../src/server/instance/experimental" import { ErrorMiddleware } from "../../src/server/middleware" +import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { experimentalHandlers } from "../../src/server/routes/instance/httpapi/handlers/experimental" import { Session } from "../../src/session" import { Worktree } from "../../src/worktree" import { tmpdir } from "../fixture/fixture" @@ -19,6 +26,44 @@ describe("experimental routes", () => { return new Hono().route("/experimental", ExperimentalRoutes()).onError(ErrorMiddleware) } + function requestExperimentalHttpApi(routePath: string, init?: RequestInit) { + return AppRuntime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const router = yield* HttpRouter.toHttpEffect( + HttpApiBuilder.layer(ExperimentalApi).pipe( + Layer.provide(experimentalHandlers), + Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodeHttpPlatform.layer, NodePath.layer, Etag.layer)), + ), + ) + const request = HttpServerRequest.fromWeb(new Request(`http://localhost${routePath}`, init)) + const response = yield* router.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.orDie) + return HttpServerResponse.toWeb(response) + }), + ) as Effect.Effect, + ) + } + + test("declares the ordinary experimental route group as HttpApi endpoints", () => { + const spec = OpenApi.fromApi(ExperimentalApi) as any + + for (const [routePath, method] of [ + ["/experimental/console", "get"], + ["/experimental/console/orgs", "get"], + ["/experimental/console/switch", "post"], + ["/experimental/tool", "get"], + ["/experimental/tool/ids", "get"], + ["/experimental/resource", "get"], + ["/experimental/worktree", "get"], + ["/experimental/worktree", "post"], + ["/experimental/worktree", "delete"], + ["/experimental/worktree/reset", "post"], + ] as const) { + expect(spec.paths).toHaveProperty(routePath) + expect(spec.paths[routePath]).toHaveProperty(method) + } + }) + test("lists tool IDs through the route runtime", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -33,6 +78,41 @@ describe("experimental routes", () => { }) }) + test("lists console, tool, worktree, and resource data through the HttpApi handlers", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const consoleState = await requestExperimentalHttpApi("/experimental/console") + expect(consoleState.status).toBe(200) + expect(await consoleState.json()).toMatchObject({ + consoleManagedProviders: [], + switchableOrgCount: 0, + }) + + const orgs = await requestExperimentalHttpApi("/experimental/console/orgs") + expect(orgs.status).toBe(200) + expect(await orgs.json()).toEqual({ orgs: [] }) + + const toolIDs = await requestExperimentalHttpApi("/experimental/tool/ids") + expect(toolIDs.status).toBe(200) + expect(await toolIDs.json()).toBeArray() + + const tools = await requestExperimentalHttpApi("/experimental/tool?provider=anthropic&model=claude") + expect(tools.status).toBe(200) + expect(await tools.json()).toBeArray() + + const worktrees = await requestExperimentalHttpApi("/experimental/worktree") + expect(worktrees.status).toBe(200) + expect(await worktrees.json()).toBeArray() + + const resources = await requestExperimentalHttpApi("/experimental/resource") + expect(resources.status).toBe(200) + expect(await resources.json()).toBeObject() + }, + }) + }) + test("lists worktrees through the route runtime", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -91,6 +171,53 @@ describe("experimental routes", () => { }) }) + test("DELETE /worktree keeps active session failures as bad requests through the HttpApi handlers", async () => { + await using tmp = await tmpdir({ git: true }) + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => { + const info = await Worktree.makeWorktreeInfo("bound-session-httpapi") + await Worktree.createFromInfo(info) + const session = await Session.create({ title: "Bound session HttpApi" }) + await Session.updateExecutionContext({ sessionID: session.id, activeWorktree: info }) + return info + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const response = await requestExperimentalHttpApi("/experimental/worktree", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: info.directory }), + }) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.name).toBe("WorktreeRemoveFailedError") + expect(body.data.message).toContain("Worktree is in use by session") + }, + }) + }) + + test("rejects malformed console switch JSON through the HttpApi handlers", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const response = await requestExperimentalHttpApi("/experimental/console/switch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }) + + expect(response.status).toBe(400) + expect(await response.text()).toBe("Malformed JSON in request body") + }, + }) + }) + test("parses ?roots=false and ?archived=false as false instead of coercing them to true", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/packages/opencode/test/server/route-inventory-harness.test.ts b/packages/opencode/test/server/route-inventory-harness.test.ts index 1a2cc3456..eb62725c1 100644 --- a/packages/opencode/test/server/route-inventory-harness.test.ts +++ b/packages/opencode/test/server/route-inventory-harness.test.ts @@ -137,6 +137,28 @@ describe("route inventory harness", () => { }) }) + test("tracks local HttpApi migration coverage for ordinary experimental JSON routes", async () => { + const inventory = await buildRouteInventory({ root, requireUpstream: false }) + + for (const [method, routePath] of [ + ["GET", "/experimental/console"], + ["GET", "/experimental/console/orgs"], + ["POST", "/experimental/console/switch"], + ["GET", "/experimental/tool"], + ["GET", "/experimental/tool/ids"], + ["GET", "/experimental/resource"], + ["GET", "/experimental/worktree"], + ["POST", "/experimental/worktree"], + ["DELETE", "/experimental/worktree"], + ["POST", "/experimental/worktree/reset"], + ] as const) { + expect(inventory.rows.find((row) => row.method === method && row.path === routePath)).toMatchObject({ + hono: true, + localHttpApi: true, + }) + } + }) + test("parses upstream HttpApi route declarations without requiring a live upstream ref", () => { const routes = parseHttpApiRoutesFromText( `