فهرست منبع

feat(httpapi): bridge workspace read endpoints (#24062)

Kit Langton 2 روز پیش
والد
کامیت
e50a688ca3

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

@@ -409,7 +409,7 @@ Current instance route inventory:
 - `project` - `bridged` (partial)
   bridged endpoints: `GET /project`, `GET /project/current`
   defer git-init mutation first
-- `workspace` - `next`
+- `workspace` - `bridged`
   best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
   defer create/remove mutations first
 - `file` - `later`
@@ -448,7 +448,7 @@ Recommended near-term sequence:
 - [x] port `config` providers read endpoint
 - [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
 - [x] port `GET /config` full read endpoint
-- [ ] port `workspace` read endpoints
+- [x] port `workspace` read endpoints
 - [ ] port `file` JSON read endpoints
 - [ ] decide when to remove the flag and make Effect routes the default
 

+ 3 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
 import { ProjectApi, projectHandlers } from "./project"
 import { ProviderApi, providerHandlers } from "./provider"
 import { QuestionApi, questionHandlers } from "./question"
+import { WorkspaceApi, workspaceHandlers } from "./workspace"
 import { memoMap } from "@/effect/memo-map"
 
 const Query = Schema.Struct({
@@ -112,6 +113,7 @@ const PermissionSecured = PermissionApi.middleware(Authorization)
 const ProjectSecured = ProjectApi.middleware(Authorization)
 const ProviderSecured = ProviderApi.middleware(Authorization)
 const ConfigSecured = ConfigApi.middleware(Authorization)
+const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
 
 export const routes = Layer.mergeAll(
   HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
@@ -119,6 +121,7 @@ export const routes = Layer.mergeAll(
   HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
   HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
   HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
+  HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
 ).pipe(
   Layer.provide(auth),
   Layer.provide(normalize),

+ 82 - 0
packages/opencode/src/server/routes/instance/httpapi/workspace.ts

@@ -0,0 +1,82 @@
+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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const root = "/experimental/workspace"
+export const WorkspacePaths = {
+  adaptors: `${root}/adaptor`,
+  list: root,
+  status: `${root}/status`,
+} as const
+
+export const WorkspaceApi = HttpApi.make("workspace")
+  .add(
+    HttpApiGroup.make("workspace")
+      .add(
+        HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
+          success: Schema.Array(WorkspaceAdaptorEntry),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.workspace.adaptor.list",
+            summary: "List workspace adaptors",
+            description: "List all available workspace adaptors for the current project.",
+          }),
+        ),
+        HttpApiEndpoint.get("list", WorkspacePaths.list, {
+          success: Schema.Array(Workspace.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.workspace.list",
+            summary: "List workspaces",
+            description: "List all workspaces.",
+          }),
+        ),
+        HttpApiEndpoint.get("status", WorkspacePaths.status, {
+          success: Schema.Array(Workspace.ConnectionStatus),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.workspace.status",
+            summary: "Workspace status",
+            description: "Get connection status for workspaces in the current project.",
+          }),
+        ),
+      )
+      .annotateMerge(
+        OpenApi.annotations({
+          title: "workspace",
+          description: "Experimental HttpApi workspace routes.",
+        }),
+      ),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )
+
+export const workspaceHandlers = Layer.unwrap(
+  Effect.gen(function* () {
+    const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
+      const ctx = yield* InstanceState.context
+      return yield* Effect.promise(() => listAdaptors(ctx.project.id))
+    })
+
+    const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
+      return Workspace.list((yield* InstanceState.context).project)
+    })
+
+    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))
+    })
+
+    return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
+      handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
+    )
+  }),
+)

+ 18 - 7
packages/opencode/src/server/server.ts

@@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global"
 import { WorkspaceRouterMiddleware } from "./workspace"
 import { InstanceMiddleware } from "./routes/instance/middleware"
 import { WorkspaceRoutes } from "./routes/control/workspace"
+import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
+import { WorkspacePaths } from "./routes/instance/httpapi/workspace"
+import { Context } from "effect"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -54,16 +57,24 @@ function create(opts: { cors?: string[] }) {
     }
   }
 
+  const workspaceApp = new Hono()
+  const workspaceLegacyApp = new Hono()
+    .use(InstanceMiddleware())
+    .route("/experimental/workspace", WorkspaceRoutes())
+    .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
+  if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
+    const handler = ExperimentalHttpApiServer.webHandler().handler
+    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.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
+  }
+  workspaceApp.route("/", workspaceLegacyApp)
+
   return {
     app: app
       .route("/", ControlPlaneRoutes())
-      .route(
-        "/",
-        new Hono()
-          .use(InstanceMiddleware())
-          .route("/experimental/workspace", WorkspaceRoutes())
-          .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
-      )
+      .route("/", workspaceApp)
       .route("/", InstanceRoutes(runtime.upgradeWebSocket))
       .route("/", UIRoutes()),
     runtime,

+ 55 - 0
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Context } from "effect"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
+import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
+import { Log } from "../../src/util"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+
+void Log.init({ print: false })
+
+const context = Context.empty() as Context.Context<unknown>
+
+function request(path: string, directory: string) {
+  return ExperimentalHttpApiServer.webHandler().handler(
+    new Request(`http://localhost${path}`, {
+      headers: {
+        "x-opencode-directory": directory,
+      },
+    }),
+    context,
+  )
+}
+
+afterEach(async () => {
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("workspace HttpApi", () => {
+  test("serves read endpoints", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    const [adaptors, workspaces, status] = await Promise.all([
+      request(WorkspacePaths.adaptors, tmp.path),
+      request(WorkspacePaths.list, tmp.path),
+      request(WorkspacePaths.status, tmp.path),
+    ])
+
+    expect(adaptors.status).toBe(200)
+    expect(await adaptors.json()).toEqual([
+      {
+        type: "worktree",
+        name: "Worktree",
+        description: "Create a git worktree",
+      },
+    ])
+
+    expect(workspaces.status).toBe(200)
+    expect(await workspaces.json()).toEqual([])
+
+    expect(status.status).toBe(200)
+    expect(await status.json()).toEqual([])
+  })
+})