feat(httpapi): bridge workspace mutations (#24483)

This commit is contained in:
Kit Langton 2026-04-26 11:12:04 -04:00 committed by GitHub
parent 37c5eab6f8
commit aa5999b188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 28 deletions

View file

@ -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.

View file

@ -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),
)
}),
)

View file

@ -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)

View file

@ -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([])
})
})