From 667e59c74d4ee2bf7d3c298f4d601391933c6cd7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Apr 2026 17:11:28 -0400 Subject: [PATCH] feat(httpapi): bridge workspace read endpoints --- packages/opencode/specs/effect/http-api.md | 4 +- .../server/routes/instance/httpapi/server.ts | 3 + .../routes/instance/httpapi/workspace.ts | 82 +++++++++++++++++++ packages/opencode/src/server/server.ts | 25 ++++-- .../test/server/httpapi-workspace.test.ts | 55 +++++++++++++ 5 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/workspace.ts create mode 100644 packages/opencode/test/server/httpapi-workspace.test.ts diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index d882857ba10a..6c80dc65a250 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -409,7 +409,7 @@ Current instance route inventory: - `project` - `bridged` (partial) bridged endpoints: `GET /project`, `GET /project/current` defer git-init mutation first -- `workspace` - `next` +- `workspace` - `bridged` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` defer create/remove mutations first - `file` - `later` @@ -448,7 +448,7 @@ Recommended near-term sequence: - [x] port `config` providers read endpoint - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) - [x] port `GET /config` full read endpoint -- [ ] port `workspace` read endpoints +- [x] port `workspace` read endpoints - [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d012e2c166a3..7b131d400029 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" +import { WorkspaceApi, workspaceHandlers } from "./workspace" import { memoMap } from "@/effect/memo-map" const Query = Schema.Struct({ @@ -112,6 +113,7 @@ const PermissionSecured = PermissionApi.middleware(Authorization) const ProjectSecured = ProjectApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) const ConfigSecured = ConfigApi.middleware(Authorization) +const WorkspaceSecured = WorkspaceApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), @@ -119,6 +121,7 @@ export const routes = Layer.mergeAll( HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), + HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts new file mode 100644 index 000000000000..596545073e12 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts @@ -0,0 +1,82 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import * as InstanceState from "@/effect/instance-state" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/workspace" +export const WorkspacePaths = { + adaptors: `${root}/adaptor`, + list: root, + status: `${root}/status`, +} as const + +export const WorkspaceApi = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { + success: Schema.Array(WorkspaceAdaptorEntry), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.adaptor.list", + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + }), + ), + HttpApiEndpoint.get("list", WorkspacePaths.list, { + success: Schema.Array(Workspace.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.get("status", WorkspacePaths.status, { + success: Schema.Array(Workspace.ConnectionStatus), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.status", + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "workspace", + description: "Experimental HttpApi workspace routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const workspaceHandlers = Layer.unwrap( + Effect.gen(function* () { + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const ctx = yield* InstanceState.context + return yield* Effect.promise(() => listAdaptors(ctx.project.id)) + }) + + const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list((yield* InstanceState.context).project) + }) + + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) + }) + + return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => + handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status), + ) + }), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8b1f1aee1061..d74de559dc1f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global" import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" +import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { WorkspacePaths } from "./routes/instance/httpapi/workspace" +import { Context } from "effect" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -54,16 +57,24 @@ function create(opts: { cors?: string[] }) { } } + const workspaceApp = new Hono() + const workspaceLegacyApp = new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context)) + workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + } + workspaceApp.route("/", workspaceLegacyApp) + return { app: app .route("/", ControlPlaneRoutes()) - .route( - "/", - new Hono() - .use(InstanceMiddleware()) - .route("/experimental/workspace", WorkspaceRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)), - ) + .route("/", workspaceApp) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts new file mode 100644 index 000000000000..8256d8330f0f --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context } from "effect" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" + +void Log.init({ print: false }) + +const context = Context.empty() as Context.Context + +function request(path: string, directory: string) { + return ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${path}`, { + headers: { + "x-opencode-directory": directory, + }, + }), + context, + ) +} + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +describe("workspace HttpApi", () => { + test("serves read endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + + const [adaptors, workspaces, status] = await Promise.all([ + request(WorkspacePaths.adaptors, tmp.path), + request(WorkspacePaths.list, tmp.path), + request(WorkspacePaths.status, tmp.path), + ]) + + expect(adaptors.status).toBe(200) + expect(await adaptors.json()).toEqual([ + { + type: "worktree", + name: "Worktree", + description: "Create a git worktree", + }, + ]) + + expect(workspaces.status).toBe(200) + expect(await workspaces.json()).toEqual([]) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual([]) + }) +})