Skip to content

Commit 8a1f79d

Browse files
kitlangtonvaur94
authored andcommitted
feat(httpapi): bridge workspace read endpoints (anomalyco#24062)
(cherry picked from commit e50a688)
1 parent baa92e7 commit 8a1f79d

5 files changed

Lines changed: 160 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ Current instance route inventory:
409409
- `project` - `bridged` (partial)
410410
bridged endpoints: `GET /project`, `GET /project/current`
411411
defer git-init mutation first
412-
- `workspace` - `next`
412+
- `workspace` - `bridged`
413413
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
414414
defer create/remove mutations first
415415
- `file` - `later`
@@ -448,7 +448,7 @@ Recommended near-term sequence:
448448
- [x] port `config` providers read endpoint
449449
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
450450
- [x] port `GET /config` full read endpoint
451-
- [ ] port `workspace` read endpoints
451+
- [x] port `workspace` read endpoints
452452
- [ ] port `file` JSON read endpoints
453453
- [ ] decide when to remove the flag and make Effect routes the default
454454

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
1414
import { ProjectApi, projectHandlers } from "./project"
1515
import { ProviderApi, providerHandlers } from "./provider"
1616
import { QuestionApi, questionHandlers } from "./question"
17+
import { WorkspaceApi, workspaceHandlers } from "./workspace"
1718
import { memoMap } from "@/effect/memo-map"
1819

1920
const Query = Schema.Struct({
@@ -112,13 +113,15 @@ const PermissionSecured = PermissionApi.middleware(Authorization)
112113
const ProjectSecured = ProjectApi.middleware(Authorization)
113114
const ProviderSecured = ProviderApi.middleware(Authorization)
114115
const ConfigSecured = ConfigApi.middleware(Authorization)
116+
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
115117

116118
export const routes = Layer.mergeAll(
117119
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
118120
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
119121
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
120122
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
121123
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
124+
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
122125
).pipe(
123126
Layer.provide(auth),
124127
Layer.provide(normalize),
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { listAdaptors } from "@/control-plane/adaptors"
2+
import { Workspace } from "@/control-plane/workspace"
3+
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
4+
import * as InstanceState from "@/effect/instance-state"
5+
import { Effect, Layer, Schema } from "effect"
6+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
7+
8+
const root = "/experimental/workspace"
9+
export const WorkspacePaths = {
10+
adaptors: `${root}/adaptor`,
11+
list: root,
12+
status: `${root}/status`,
13+
} as const
14+
15+
export const WorkspaceApi = HttpApi.make("workspace")
16+
.add(
17+
HttpApiGroup.make("workspace")
18+
.add(
19+
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
20+
success: Schema.Array(WorkspaceAdaptorEntry),
21+
}).annotateMerge(
22+
OpenApi.annotations({
23+
identifier: "experimental.workspace.adaptor.list",
24+
summary: "List workspace adaptors",
25+
description: "List all available workspace adaptors for the current project.",
26+
}),
27+
),
28+
HttpApiEndpoint.get("list", WorkspacePaths.list, {
29+
success: Schema.Array(Workspace.Info),
30+
}).annotateMerge(
31+
OpenApi.annotations({
32+
identifier: "experimental.workspace.list",
33+
summary: "List workspaces",
34+
description: "List all workspaces.",
35+
}),
36+
),
37+
HttpApiEndpoint.get("status", WorkspacePaths.status, {
38+
success: Schema.Array(Workspace.ConnectionStatus),
39+
}).annotateMerge(
40+
OpenApi.annotations({
41+
identifier: "experimental.workspace.status",
42+
summary: "Workspace status",
43+
description: "Get connection status for workspaces in the current project.",
44+
}),
45+
),
46+
)
47+
.annotateMerge(
48+
OpenApi.annotations({
49+
title: "workspace",
50+
description: "Experimental HttpApi workspace routes.",
51+
}),
52+
),
53+
)
54+
.annotateMerge(
55+
OpenApi.annotations({
56+
title: "opencode experimental HttpApi",
57+
version: "0.0.1",
58+
description: "Experimental HttpApi surface for selected instance routes.",
59+
}),
60+
)
61+
62+
export const workspaceHandlers = Layer.unwrap(
63+
Effect.gen(function* () {
64+
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
65+
const ctx = yield* InstanceState.context
66+
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
67+
})
68+
69+
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
70+
return Workspace.list((yield* InstanceState.context).project)
71+
})
72+
73+
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
74+
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
75+
return Workspace.status().filter((item) => ids.has(item.workspaceID))
76+
})
77+
78+
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
79+
handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status),
80+
)
81+
}),
82+
)

packages/opencode/src/server/server.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { GlobalRoutes } from "./routes/global"
1616
import { WorkspaceRouterMiddleware } from "./workspace"
1717
import { InstanceMiddleware } from "./routes/instance/middleware"
1818
import { WorkspaceRoutes } from "./routes/control/workspace"
19+
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
20+
import { WorkspacePaths } from "./routes/instance/httpapi/workspace"
21+
import { Context } from "effect"
1922

2023
// @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
2124
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -54,16 +57,24 @@ function create(opts: { cors?: string[] }) {
5457
}
5558
}
5659

60+
const workspaceApp = new Hono()
61+
const workspaceLegacyApp = new Hono()
62+
.use(InstanceMiddleware())
63+
.route("/experimental/workspace", WorkspaceRoutes())
64+
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
65+
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
66+
const handler = ExperimentalHttpApiServer.webHandler().handler
67+
const context = Context.empty() as Context.Context<unknown>
68+
workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
69+
workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
70+
workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
71+
}
72+
workspaceApp.route("/", workspaceLegacyApp)
73+
5774
return {
5875
app: app
5976
.route("/", ControlPlaneRoutes())
60-
.route(
61-
"/",
62-
new Hono()
63-
.use(InstanceMiddleware())
64-
.route("/experimental/workspace", WorkspaceRoutes())
65-
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
66-
)
77+
.route("/", workspaceApp)
6778
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
6879
.route("/", UIRoutes()),
6980
runtime,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Context } from "effect"
3+
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
4+
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
5+
import { Log } from "../../src/util"
6+
import { resetDatabase } from "../fixture/db"
7+
import { tmpdir } from "../fixture/fixture"
8+
import { Instance } from "../../src/project/instance"
9+
10+
void Log.init({ print: false })
11+
12+
const context = Context.empty() as Context.Context<unknown>
13+
14+
function request(path: string, directory: string) {
15+
return ExperimentalHttpApiServer.webHandler().handler(
16+
new Request(`http://localhost${path}`, {
17+
headers: {
18+
"x-opencode-directory": directory,
19+
},
20+
}),
21+
context,
22+
)
23+
}
24+
25+
afterEach(async () => {
26+
await Instance.disposeAll()
27+
await resetDatabase()
28+
})
29+
30+
describe("workspace HttpApi", () => {
31+
test("serves read endpoints", async () => {
32+
await using tmp = await tmpdir({ git: true })
33+
34+
const [adaptors, workspaces, status] = await Promise.all([
35+
request(WorkspacePaths.adaptors, tmp.path),
36+
request(WorkspacePaths.list, tmp.path),
37+
request(WorkspacePaths.status, tmp.path),
38+
])
39+
40+
expect(adaptors.status).toBe(200)
41+
expect(await adaptors.json()).toEqual([
42+
{
43+
type: "worktree",
44+
name: "Worktree",
45+
description: "Create a git worktree",
46+
},
47+
])
48+
49+
expect(workspaces.status).toBe(200)
50+
expect(await workspaces.json()).toEqual([])
51+
52+
expect(status.status).toBe(200)
53+
expect(await status.json()).toEqual([])
54+
})
55+
})

0 commit comments

Comments
 (0)