feat(httpapi): bridge workspace mutations (#24483)
This commit is contained in:
@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
|||||||
|
|
||||||
## Current Route Status
|
## Current Route Status
|
||||||
|
|
||||||
| Area | Status | Notes |
|
| Area | Status | Notes |
|
||||||
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
|
| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||||
| `permission` | `bridged` | list and reply |
|
| `permission` | `bridged` | list and reply |
|
||||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||||
| `config` | `bridged` | read, providers, update |
|
| `config` | `bridged` | read, providers, update |
|
||||||
| `project` | `bridged` | list, current, git init, update |
|
| `project` | `bridged` | list, current, git init, update |
|
||||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||||
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| 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 |
|
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||||
| `session` | `later/special` | large stateful surface plus streaming |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
| `sync` | `later` | process/control side effects |
|
| `sync` | `later` | process/control side effects |
|
||||||
| `event` | `special` | SSE |
|
| `event` | `special` | SSE |
|
||||||
| `pty` | `special` | websocket |
|
| `pty` | `special` | websocket |
|
||||||
| `tui` | `special` | UI bridge |
|
| `tui` | `special` | UI bridge |
|
||||||
|
|
||||||
## Full Route Checklist
|
## Full Route Checklist
|
||||||
|
|
||||||
@@ -272,11 +272,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
|||||||
### Workspace Routes
|
### Workspace Routes
|
||||||
|
|
||||||
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
|
- [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` - list workspaces.
|
||||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||||
- [ ] `DELETE /experimental/workspace/:id` - remove workspace.
|
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
|
||||||
- [ ] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
|
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
|
||||||
|
|
||||||
### Sync Routes
|
### 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.
|
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||||
4. [x] Bridge experimental console switch and tool list routes.
|
4. [x] Bridge experimental console switch and tool list routes.
|
||||||
5. [x] Bridge experimental global session list.
|
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.
|
7. [ ] Bridge sync start/replay/history routes.
|
||||||
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||||
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||||
|
|||||||
@@ -2,15 +2,28 @@ import { listAdaptors } from "@/control-plane/adaptors"
|
|||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/experimental/workspace"
|
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 = {
|
export const WorkspacePaths = {
|
||||||
adaptors: `${root}/adaptor`,
|
adaptors: `${root}/adaptor`,
|
||||||
list: root,
|
list: root,
|
||||||
status: `${root}/status`,
|
status: `${root}/status`,
|
||||||
|
remove: `${root}/:id`,
|
||||||
|
sessionRestore: `${root}/:id/session-restore`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WorkspaceApi = HttpApi.make("workspace")
|
export const WorkspaceApi = HttpApi.make("workspace")
|
||||||
@@ -35,6 +48,16 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
|||||||
description: "List all workspaces.",
|
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, {
|
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||||
success: Schema.Array(Workspace.ConnectionStatus),
|
success: Schema.Array(Workspace.ConnectionStatus),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
@@ -44,6 +67,27 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
|||||||
description: "Get connection status for workspaces in the current project.",
|
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(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -72,13 +116,51 @@ export const workspaceHandlers = Layer.unwrap(
|
|||||||
return Workspace.list((yield* InstanceState.context).project)
|
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 status = Effect.fn("WorkspaceHttpApi.status")(function* () {
|
||||||
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
|
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
|
||||||
return Workspace.status().filter((item) => ids.has(item.workspaceID))
|
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) =>
|
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),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ function create(opts: { cors?: string[] }) {
|
|||||||
const context = Context.empty() as Context.Context<unknown>
|
const context = Context.empty() as Context.Context<unknown>
|
||||||
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
|
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
|
||||||
workspaceApp.get(WorkspacePaths.list, (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.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)
|
workspaceApp.route("/", workspaceLegacyApp)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
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 { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
import { Log } from "../../src/util"
|
import { Log } from "../../src/util"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -10,19 +17,50 @@ import { Instance } from "../../src/project/instance"
|
|||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
const context = Context.empty() as Context.Context<unknown>
|
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(
|
return ExperimentalHttpApiServer.webHandler().handler(
|
||||||
new Request(`http://localhost${path}`, {
|
new Request(`http://localhost${path}`, {
|
||||||
headers: {
|
...init,
|
||||||
"x-opencode-directory": directory,
|
headers,
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
context,
|
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 () => {
|
afterEach(async () => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
await resetDatabase()
|
await resetDatabase()
|
||||||
})
|
})
|
||||||
@@ -52,4 +90,42 @@ describe("workspace HttpApi", () => {
|
|||||||
expect(status.status).toBe(200)
|
expect(status.status).toBe(200)
|
||||||
expect(await status.json()).toEqual([])
|
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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user