Skip to content

Commit aa5999b

Browse files
authored
feat(httpapi): bridge workspace mutations (#24483)
1 parent 37c5eab commit aa5999b

4 files changed

Lines changed: 189 additions & 28 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
170170

171171
## Current Route Status
172172

173-
| Area | Status | Notes |
174-
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
175-
| `question` | `bridged` | `GET /question`, reply, reject |
176-
| `permission` | `bridged` | list and reply |
177-
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
178-
| `config` | `bridged` | read, providers, update |
179-
| `project` | `bridged` | list, current, git init, update |
180-
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
181-
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
182-
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
183-
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184-
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
185-
| `session` | `later/special` | large stateful surface plus streaming |
186-
| `sync` | `later` | process/control side effects |
187-
| `event` | `special` | SSE |
188-
| `pty` | `special` | websocket |
189-
| `tui` | `special` | UI bridge |
173+
| Area | Status | Notes |
174+
| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
175+
| `question` | `bridged` | `GET /question`, reply, reject |
176+
| `permission` | `bridged` | list and reply |
177+
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
178+
| `config` | `bridged` | read, providers, update |
179+
| `project` | `bridged` | list, current, git init, update |
180+
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
181+
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
182+
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
183+
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184+
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
185+
| `session` | `later/special` | large stateful surface plus streaming |
186+
| `sync` | `later` | process/control side effects |
187+
| `event` | `special` | SSE |
188+
| `pty` | `special` | websocket |
189+
| `tui` | `special` | UI bridge |
190190

191191
## Full Route Checklist
192192

@@ -272,11 +272,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
272272
### Workspace Routes
273273

274274
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
275-
- [ ] `POST /experimental/workspace` - create workspace.
275+
- [x] `POST /experimental/workspace` - create workspace.
276276
- [x] `GET /experimental/workspace` - list workspaces.
277277
- [x] `GET /experimental/workspace/status` - workspace status.
278-
- [ ] `DELETE /experimental/workspace/:id` - remove workspace.
279-
- [ ] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
278+
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
279+
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
280280

281281
### Sync Routes
282282

@@ -352,7 +352,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
352352
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
353353
4. [x] Bridge experimental console switch and tool list routes.
354354
5. [x] Bridge experimental global session list.
355-
6. [ ] Bridge workspace create/remove/session-restore routes.
355+
6. [x] Bridge workspace create/remove/session-restore routes.
356356
7. [ ] Bridge sync start/replay/history routes.
357357
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
358358
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.

packages/opencode/src/server/routes/instance/httpapi/workspace.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,28 @@ import { listAdaptors } from "@/control-plane/adaptors"
22
import { Workspace } from "@/control-plane/workspace"
33
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
44
import * as InstanceState from "@/effect/instance-state"
5-
import { Effect, Layer, Schema } from "effect"
5+
import { Instance } from "@/project/instance"
6+
import { Effect, Layer, Schema, Struct } from "effect"
67
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
78
import { Authorization } from "./auth"
89

910
const root = "/experimental/workspace"
11+
const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
12+
identifier: "WorkspaceCreateInput",
13+
})
14+
const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])).annotate({
15+
identifier: "WorkspaceSessionRestoreInput",
16+
})
17+
const SessionRestoreResponse = Schema.Struct({
18+
total: Schema.Number,
19+
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
20+
1021
export const WorkspacePaths = {
1122
adaptors: `${root}/adaptor`,
1223
list: root,
1324
status: `${root}/status`,
25+
remove: `${root}/:id`,
26+
sessionRestore: `${root}/:id/session-restore`,
1427
} as const
1528

1629
export const WorkspaceApi = HttpApi.make("workspace")
@@ -35,6 +48,16 @@ export const WorkspaceApi = HttpApi.make("workspace")
3548
description: "List all workspaces.",
3649
}),
3750
),
51+
HttpApiEndpoint.post("create", WorkspacePaths.list, {
52+
payload: CreatePayload,
53+
success: Workspace.Info,
54+
}).annotateMerge(
55+
OpenApi.annotations({
56+
identifier: "experimental.workspace.create",
57+
summary: "Create workspace",
58+
description: "Create a workspace for the current project.",
59+
}),
60+
),
3861
HttpApiEndpoint.get("status", WorkspacePaths.status, {
3962
success: Schema.Array(Workspace.ConnectionStatus),
4063
}).annotateMerge(
@@ -44,6 +67,27 @@ export const WorkspaceApi = HttpApi.make("workspace")
4467
description: "Get connection status for workspaces in the current project.",
4568
}),
4669
),
70+
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
71+
params: { id: Workspace.Info.fields.id },
72+
success: Schema.UndefinedOr(Workspace.Info),
73+
}).annotateMerge(
74+
OpenApi.annotations({
75+
identifier: "experimental.workspace.remove",
76+
summary: "Remove workspace",
77+
description: "Remove an existing workspace.",
78+
}),
79+
),
80+
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
81+
params: { id: Workspace.Info.fields.id },
82+
payload: SessionRestorePayload,
83+
success: SessionRestoreResponse,
84+
}).annotateMerge(
85+
OpenApi.annotations({
86+
identifier: "experimental.workspace.sessionRestore",
87+
summary: "Restore session into workspace",
88+
description: "Replay a session's sync events into the target workspace in batches.",
89+
}),
90+
),
4791
)
4892
.annotateMerge(
4993
OpenApi.annotations({
@@ -72,13 +116,51 @@ export const workspaceHandlers = Layer.unwrap(
72116
return Workspace.list((yield* InstanceState.context).project)
73117
})
74118

119+
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
120+
const instance = yield* InstanceState.context
121+
return yield* Effect.promise(() =>
122+
Instance.restore(instance, () =>
123+
Workspace.create({
124+
...Schema.decodeUnknownSync(CreatePayload)(ctx.payload),
125+
projectID: instance.project.id,
126+
}),
127+
),
128+
)
129+
})
130+
75131
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
76132
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
77133
return Workspace.status().filter((item) => ids.has(item.workspaceID))
78134
})
79135

136+
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
137+
const instance = yield* InstanceState.context
138+
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
139+
})
140+
141+
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
142+
params: { id: Workspace.Info["id"] }
143+
payload: typeof SessionRestorePayload.Type
144+
}) {
145+
const instance = yield* InstanceState.context
146+
return yield* Effect.promise(() =>
147+
Instance.restore(instance, () =>
148+
Workspace.sessionRestore({
149+
workspaceID: ctx.params.id,
150+
sessionID: ctx.payload.sessionID,
151+
}),
152+
),
153+
)
154+
})
155+
80156
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
81-
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
157+
handlers
158+
.handle("adaptors", adaptors)
159+
.handle("list", list)
160+
.handle("create", create)
161+
.handle("status", status)
162+
.handle("remove", remove)
163+
.handle("sessionRestore", sessionRestore),
82164
)
83165
}),
84166
)

packages/opencode/src/server/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ function create(opts: { cors?: string[] }) {
6767
const context = Context.empty() as Context.Context<unknown>
6868
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
6969
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
70+
workspaceApp.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
7071
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
72+
workspaceApp.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
73+
workspaceApp.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
7174
}
7275
workspaceApp.route("/", workspaceLegacyApp)
7376

packages/opencode/test/server/httpapi-workspace.test.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { afterEach, describe, expect, test } from "bun:test"
2-
import { Context } from "effect"
2+
import { mkdir } from "node:fs/promises"
3+
import path from "node:path"
4+
import { Context, Effect } from "effect"
5+
import { Flag } from "@opencode-ai/core/flag/flag"
6+
import { registerAdaptor } from "../../src/control-plane/adaptors"
7+
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
8+
import { Workspace } from "../../src/control-plane/workspace"
39
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
410
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
11+
import { Session } from "../../src/session"
512
import { Log } from "../../src/util"
613
import { resetDatabase } from "../fixture/db"
714
import { tmpdir } from "../fixture/fixture"
@@ -10,19 +17,50 @@ import { Instance } from "../../src/project/instance"
1017
void Log.init({ print: false })
1118

1219
const context = Context.empty() as Context.Context<unknown>
20+
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
1321

14-
function request(path: string, directory: string) {
22+
function request(path: string, directory: string, init: RequestInit = {}) {
23+
const headers = new Headers(init.headers)
24+
headers.set("x-opencode-directory", directory)
1525
return ExperimentalHttpApiServer.webHandler().handler(
1626
new Request(`http://localhost${path}`, {
17-
headers: {
18-
"x-opencode-directory": directory,
19-
},
27+
...init,
28+
headers,
2029
}),
2130
context,
2231
)
2332
}
2433

34+
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
35+
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
36+
}
37+
38+
function localAdaptor(directory: string): WorkspaceAdaptor {
39+
return {
40+
name: "Local Test",
41+
description: "Create a local test workspace",
42+
configure(info) {
43+
return {
44+
...info,
45+
name: "local-test",
46+
directory,
47+
}
48+
},
49+
async create() {
50+
await mkdir(directory, { recursive: true })
51+
},
52+
async remove() {},
53+
target() {
54+
return {
55+
type: "local" as const,
56+
directory,
57+
}
58+
},
59+
}
60+
}
61+
2562
afterEach(async () => {
63+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
2664
await Instance.disposeAll()
2765
await resetDatabase()
2866
})
@@ -52,4 +90,42 @@ describe("workspace HttpApi", () => {
5290
expect(status.status).toBe(200)
5391
expect(await status.json()).toEqual([])
5492
})
93+
94+
test("serves mutation endpoints", async () => {
95+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
96+
await using tmp = await tmpdir({ git: true })
97+
await Instance.provide({
98+
directory: tmp.path,
99+
fn: async () => registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))),
100+
})
101+
102+
const created = await request(WorkspacePaths.list, tmp.path, {
103+
method: "POST",
104+
headers: { "content-type": "application/json" },
105+
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
106+
})
107+
expect(created.status).toBe(200)
108+
const workspace = (await created.json()) as Workspace.Info
109+
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
110+
111+
const session = await Instance.provide({
112+
directory: tmp.path,
113+
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
114+
})
115+
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
116+
method: "POST",
117+
headers: { "content-type": "application/json" },
118+
body: JSON.stringify({ sessionID: session.id }),
119+
})
120+
expect(restored.status).toBe(200)
121+
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
122+
123+
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
124+
expect(removed.status).toBe(200)
125+
expect(await removed.json()).toMatchObject({ id: workspace.id })
126+
127+
const listed = await request(WorkspacePaths.list, tmp.path)
128+
expect(listed.status).toBe(200)
129+
expect(await listed.json()).toEqual([])
130+
})
55131
})

0 commit comments

Comments
 (0)