summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-26 11:12:04 -0400
committerGitHub <[email protected]>2026-04-26 11:12:04 -0400
commitaa5999b188e6f2ec18a374eb277d7a4f23faa6ca (patch)
tree2514155e663ca7ac36ed0c634536f8ed9393a7e3
parent37c5eab6f8ccd5a64bad76865b4a687cd6721a27 (diff)
downloadopencode-aa5999b188e6f2ec18a374eb277d7a4f23faa6ca.tar.gz
opencode-aa5999b188e6f2ec18a374eb277d7a4f23faa6ca.zip
feat(httpapi): bridge workspace mutations (#24483)
-rw-r--r--packages/opencode/specs/effect/http-api.md42
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/workspace.ts86
-rw-r--r--packages/opencode/src/server/server.ts3
-rw-r--r--packages/opencode/test/server/httpapi-workspace.test.ts86
4 files changed, 189 insertions, 28 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index c2f51381a..9b84c799e 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
## Current Route Status
-| Area | Status | Notes |
-| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
-| `question` | `bridged` | `GET /question`, reply, reject |
-| `permission` | `bridged` | list and reply |
-| `provider` | `bridged` | list, auth, OAuth authorize/callback |
-| `config` | `bridged` | read, providers, update |
-| `project` | `bridged` | list, current, git init, update |
-| `file` | `bridged` partial | find text/file/symbol, list/content/status |
-| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
-| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
-| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
-| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
-| `session` | `later/special` | large stateful surface plus streaming |
-| `sync` | `later` | process/control side effects |
-| `event` | `special` | SSE |
-| `pty` | `special` | websocket |
-| `tui` | `special` | UI bridge |
+| Area | Status | Notes |
+| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
+| `question` | `bridged` | `GET /question`, reply, reject |
+| `permission` | `bridged` | list and reply |
+| `provider` | `bridged` | list, auth, OAuth authorize/callback |
+| `config` | `bridged` | read, providers, update |
+| `project` | `bridged` | list, current, git init, update |
+| `file` | `bridged` partial | find text/file/symbol, list/content/status |
+| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
+| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
+| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
+| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
+| `session` | `later/special` | large stateful surface plus streaming |
+| `sync` | `later` | process/control side effects |
+| `event` | `special` | SSE |
+| `pty` | `special` | websocket |
+| `tui` | `special` | UI bridge |
## Full Route Checklist
@@ -272,11 +272,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Workspace Routes
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
-- [ ] `POST /experimental/workspace` - create workspace.
+- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
-- [ ] `DELETE /experimental/workspace/:id` - remove workspace.
-- [ ] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
+- [x] `DELETE /experimental/workspace/:id` - remove workspace.
+- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
### Sync Routes
@@ -352,7 +352,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. [x] Bridge experimental console switch and tool list routes.
5. [x] Bridge experimental global session list.
-6. [ ] Bridge workspace create/remove/session-restore routes.
+6. [x] Bridge workspace create/remove/session-restore routes.
7. [ ] Bridge sync start/replay/history routes.
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
index 2ab6b03d2..6454af4a3 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
@@ -2,15 +2,28 @@ 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 { Instance } from "@/project/instance"
+import { Effect, Layer, Schema, Struct } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/experimental/workspace"
+const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
+ identifier: "WorkspaceCreateInput",
+})
+const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])).annotate({
+ identifier: "WorkspaceSessionRestoreInput",
+})
+const SessionRestoreResponse = Schema.Struct({
+ total: Schema.Number,
+}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
+
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
+ remove: `${root}/:id`,
+ sessionRestore: `${root}/:id/session-restore`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
@@ -35,6 +48,16 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "List all workspaces.",
}),
),
+ HttpApiEndpoint.post("create", WorkspacePaths.list, {
+ payload: CreatePayload,
+ success: Workspace.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.workspace.create",
+ summary: "Create workspace",
+ description: "Create a workspace for the current project.",
+ }),
+ ),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: Schema.Array(Workspace.ConnectionStatus),
}).annotateMerge(
@@ -44,6 +67,27 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Get connection status for workspaces in the current project.",
}),
),
+ HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
+ params: { id: Workspace.Info.fields.id },
+ success: Schema.UndefinedOr(Workspace.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.workspace.remove",
+ summary: "Remove workspace",
+ description: "Remove an existing workspace.",
+ }),
+ ),
+ HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
+ params: { id: Workspace.Info.fields.id },
+ payload: SessionRestorePayload,
+ success: SessionRestoreResponse,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.workspace.sessionRestore",
+ summary: "Restore session into workspace",
+ description: "Replay a session's sync events into the target workspace in batches.",
+ }),
+ ),
)
.annotateMerge(
OpenApi.annotations({
@@ -72,13 +116,51 @@ export const workspaceHandlers = Layer.unwrap(
return Workspace.list((yield* InstanceState.context).project)
})
+ const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ Workspace.create({
+ ...Schema.decodeUnknownSync(CreatePayload)(ctx.payload),
+ projectID: instance.project.id,
+ }),
+ ),
+ )
+ })
+
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))
})
+ const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
+ })
+
+ const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
+ params: { id: Workspace.Info["id"] }
+ payload: typeof SessionRestorePayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ Workspace.sessionRestore({
+ workspaceID: ctx.params.id,
+ sessionID: ctx.payload.sessionID,
+ }),
+ ),
+ )
+ })
+
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
- handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
+ handlers
+ .handle("adaptors", adaptors)
+ .handle("list", list)
+ .handle("create", create)
+ .handle("status", status)
+ .handle("remove", remove)
+ .handle("sessionRestore", sessionRestore),
)
}),
)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index fb278f268..c493e4e70 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -67,7 +67,10 @@ function create(opts: { cors?: string[] }) {
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.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
+ workspaceApp.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
+ workspaceApp.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
}
workspaceApp.route("/", workspaceLegacyApp)
diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts
index 8256d8330..d41dc7369 100644
--- a/packages/opencode/test/server/httpapi-workspace.test.ts
+++ b/packages/opencode/test/server/httpapi-workspace.test.ts
@@ -1,7 +1,14 @@
import { afterEach, describe, expect, test } from "bun:test"
-import { Context } from "effect"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { Context, Effect } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { registerAdaptor } from "../../src/control-plane/adaptors"
+import type { WorkspaceAdaptor } from "../../src/control-plane/types"
+import { Workspace } from "../../src/control-plane/workspace"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
+import { Session } from "../../src/session"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -10,19 +17,50 @@ import { Instance } from "../../src/project/instance"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
+const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
-function request(path: string, directory: string) {
+function request(path: string, directory: string, init: RequestInit = {}) {
+ const headers = new Headers(init.headers)
+ headers.set("x-opencode-directory", directory)
return ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${path}`, {
- headers: {
- "x-opencode-directory": directory,
- },
+ ...init,
+ headers,
}),
context,
)
}
+function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
+ return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function localAdaptor(directory: string): WorkspaceAdaptor {
+ return {
+ name: "Local Test",
+ description: "Create a local test workspace",
+ configure(info) {
+ return {
+ ...info,
+ name: "local-test",
+ directory,
+ }
+ },
+ async create() {
+ await mkdir(directory, { recursive: true })
+ },
+ async remove() {},
+ target() {
+ return {
+ type: "local" as const,
+ directory,
+ }
+ },
+ }
+}
+
afterEach(async () => {
+ Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
await resetDatabase()
})
@@ -52,4 +90,42 @@ describe("workspace HttpApi", () => {
expect(status.status).toBe(200)
expect(await status.json()).toEqual([])
})
+
+ test("serves mutation endpoints", async () => {
+ Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))),
+ })
+
+ const created = await request(WorkspacePaths.list, tmp.path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
+ })
+ expect(created.status).toBe(200)
+ const workspace = (await created.json()) as Workspace.Info
+ expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
+
+ const session = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
+ })
+ const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ sessionID: session.id }),
+ })
+ expect(restored.status).toBe(200)
+ expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
+
+ const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
+ expect(removed.status).toBe(200)
+ expect(await removed.json()).toMatchObject({ id: workspace.id })
+
+ const listed = await request(WorkspacePaths.list, tmp.path)
+ expect(listed.status).toBe(200)
+ expect(await listed.json()).toEqual([])
+ })
})