summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-23 17:32:02 -0400
committerGitHub <[email protected]>2026-04-23 17:32:02 -0400
commite50a688ca309ba4c992fd8e47b5b75a11aef025e (patch)
tree32b9ab756cf1fb9ade3f0d9970a48cc61b11e6dd
parent334ab4707c809172e77619ae7d6b22c5577c7238 (diff)
downloadopencode-e50a688ca309ba4c992fd8e47b5b75a11aef025e.tar.gz
opencode-e50a688ca309ba4c992fd8e47b5b75a11aef025e.zip
feat(httpapi): bridge workspace read endpoints (#24062)
-rw-r--r--packages/opencode/specs/effect/http-api.md4
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts3
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/workspace.ts82
-rw-r--r--packages/opencode/src/server/server.ts25
-rw-r--r--packages/opencode/test/server/httpapi-workspace.test.ts55
5 files changed, 160 insertions, 9 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index d882857ba..6c80dc65a 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 d012e2c16..7b131d400 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 000000000..596545073
--- /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 8b1f1aee1..d74de559d 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<unknown>
+ 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 000000000..8256d8330
--- /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<unknown>
+
+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([])
+ })
+})