diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts index 523041de84..6eba8b2787 100644 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ b/packages/opencode/src/server/instance/httpapi/index.ts @@ -1,7 +1,12 @@ import { lazy } from "@/util/lazy" import { Hono } from "hono" import { QuestionHttpApiHandler } from "./question" +import { WorkspaceHttpApiHandler } from "./workspace" export const HttpApiRoutes = lazy(() => - new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler), + new Hono() + .all("/question", QuestionHttpApiHandler) + .all("/question/*", QuestionHttpApiHandler) + .all("/workspace", WorkspaceHttpApiHandler) + .all("/workspace/*", WorkspaceHttpApiHandler), ) diff --git a/packages/opencode/src/server/instance/httpapi/workspace.ts b/packages/opencode/src/server/instance/httpapi/workspace.ts new file mode 100644 index 0000000000..519138e796 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/workspace.ts @@ -0,0 +1,123 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceID } from "@/control-plane/schema" +import { AppLayer } from "@/effect/app-runtime" +import { memoMap } from "@/effect/run-service" +import { ProjectID } from "@/project/schema" +import { Instance } from "@/project/instance" +import { lazy } from "@/util/lazy" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import type { Handler } from "hono" + +class Adaptor extends Schema.Class("WorkspaceAdaptor")({ + type: Schema.String, + name: Schema.String, + description: Schema.String, +}) {} + +class Info extends Schema.Class("Workspace")({ + id: WorkspaceID, + type: Schema.String, + name: Schema.NullOr(Schema.String), + branch: Schema.NullOr(Schema.String), + directory: Schema.NullOr(Schema.String), + extra: Schema.NullOr(Schema.Unknown), + projectID: ProjectID, +}) {} + +class Status extends Schema.Class("WorkspaceConnectionStatus")({ + workspaceID: WorkspaceID, + status: Schema.Union([ + Schema.Literal("connected"), + Schema.Literal("connecting"), + Schema.Literal("disconnected"), + Schema.Literal("error"), + ]), + error: Schema.optional(Schema.String), +}) {} + +const root = "/experimental/httpapi/workspace" + +const Api = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", `${root}/adaptor`, { + success: Schema.Array(Adaptor), + }).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", root, { + success: Schema.Array(Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.get("status", `${root}/status`, { + success: Schema.Array(Status), + }).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.", + }), + ) + +const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + return Schema.decodeUnknownSync(Schema.Array(Adaptor))(yield* Effect.promise(() => listAdaptors(Instance.project.id))) +}) + +const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Schema.decodeUnknownSync(Schema.Array(Info))(Workspace.list(Instance.project)) +}) + +const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) + return Schema.decodeUnknownSync(Schema.Array(Status))(Workspace.status().filter((item) => ids.has(item.workspaceID))) +}) + +const WorkspaceLive = HttpApiBuilder.group(Api, "workspace", (handlers) => + handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status), +) + +const web = lazy(() => + HttpRouter.toWebHandler( + Layer.mergeAll( + AppLayer, + HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe( + Layer.provide(WorkspaceLive), + Layer.provide(HttpServer.layerServices), + ), + ), + { + disableLogger: true, + memoMap, + }, + ), +) + +export const WorkspaceHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw) diff --git a/packages/opencode/test/server/workspace-httpapi.test.ts b/packages/opencode/test/server/workspace-httpapi.test.ts new file mode 100644 index 0000000000..2e06de9626 --- /dev/null +++ b/packages/opencode/test/server/workspace-httpapi.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { Server } from "../../src/server/server" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("experimental workspace httpapi", () => { + test("lists adaptors, workspaces, status, and serves docs", async () => { + await using tmp = await tmpdir({ git: true }) + const app = Server.Default().app + const headers = { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + } + + const adaptors = await app.request("/experimental/httpapi/workspace/adaptor", { headers }) + expect(adaptors.status).toBe(200) + expect(Array.isArray(await adaptors.json())).toBe(true) + + const list = await app.request("/experimental/httpapi/workspace", { headers }) + expect(list.status).toBe(200) + expect(Array.isArray(await list.json())).toBe(true) + + const status = await app.request("/experimental/httpapi/workspace/status", { headers }) + expect(status.status).toBe(200) + expect(Array.isArray(await status.json())).toBe(true) + + const doc = await app.request("/experimental/httpapi/workspace/doc", { headers }) + expect(doc.status).toBe(200) + const spec = await doc.json() + expect(spec.paths["/experimental/httpapi/workspace/adaptor"]?.get?.operationId).toBe( + "experimental.workspace.adaptor.list", + ) + expect(spec.paths["/experimental/httpapi/workspace"]?.get?.operationId).toBe("experimental.workspace.list") + expect(spec.paths["/experimental/httpapi/workspace/status"]?.get?.operationId).toBe("experimental.workspace.status") + }) +})