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

This commit is contained in:
Kit Langton 2026-04-23 17:32:02 -04:00 committed by GitHub
parent 334ab4707c
commit e50a688ca3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 9 deletions

View file

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

View file

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

View file

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

View file

@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global"
import { WorkspaceRouterMiddleware } from "./workspace" import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware" import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace" 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 // @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 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 { return {
app: app app: app
.route("/", ControlPlaneRoutes()) .route("/", ControlPlaneRoutes())
.route( .route("/", workspaceApp)
"/",
new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
)
.route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()), .route("/", UIRoutes()),
runtime, runtime,

View file

@ -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([])
})
})