Просмотр исходного кода

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

Kit Langton 4 месяцев назад
Родитель
Сommit
aa5999b188

+ 21 - 21
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
 ## 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
 ## 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.
-- [ ] `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
 ### 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.

+ 84 - 2
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 { 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),
     )
     )
   }),
   }),
 )
 )

+ 3 - 0
packages/opencode/src/server/server.ts

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

+ 81 - 5
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -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: {
-        "x-opencode-directory": directory,
-      },
+      ...init,
+      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([])
+  })
 })
 })