Skip to content

Commit 58836e7

Browse files
authored
fix(httpapi): wire global and control handlers (#24835)
1 parent 0acac21 commit 58836e7

18 files changed

Lines changed: 448 additions & 207 deletions

File tree

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Config } from "@/config/config"
22
import { Provider } from "@/provider/provider"
33
import * as InstanceState from "@/effect/instance-state"
4-
import { Effect, Layer } from "effect"
4+
import { Effect } from "effect"
55
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
66
import { Authorization } from "./auth"
77
import { markInstanceForDisposal } from "./lifecycle"
@@ -57,7 +57,7 @@ export const ConfigApi = HttpApi.make("config")
5757
}),
5858
)
5959

60-
export const configHandlers = Layer.unwrap(
60+
export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
6161
Effect.gen(function* () {
6262
const providerSvc = yield* Provider.Service
6363
const configSvc = yield* Config.Service
@@ -80,8 +80,6 @@ export const configHandlers = Layer.unwrap(
8080
}
8181
})
8282

83-
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
84-
handlers.handle("get", get).handle("update", update).handle("providers", providers),
85-
)
83+
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
8684
}),
87-
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
85+
)

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Auth } from "@/auth"
22
import { ProviderID } from "@/provider/schema"
3-
import { Schema } from "effect"
4-
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
3+
import * as Log from "@opencode-ai/core/util/log"
4+
import { Effect, Schema } from "effect"
5+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
56

67
const AuthParams = Schema.Struct({
78
providerID: ProviderID,
@@ -69,3 +70,30 @@ export const ControlApi = HttpApi.make("control").add(
6970
)
7071
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
7172
)
73+
74+
export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
75+
Effect.gen(function* () {
76+
const auth = yield* Auth.Service
77+
78+
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
79+
params: { providerID: ProviderID }
80+
payload: Auth.Info
81+
}) {
82+
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
83+
return true
84+
})
85+
86+
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
87+
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
88+
return true
89+
})
90+
91+
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
92+
const logger = Log.create({ service: ctx.payload.service })
93+
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
94+
return true
95+
})
96+
97+
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
98+
}),
99+
)

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

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Session } from "@/session/session"
1010
import { ToolRegistry } from "@/tool/registry"
1111
import * as EffectZod from "@/util/effect-zod"
1212
import { Worktree } from "@/worktree"
13-
import { Effect, Layer, Option, Schema, SchemaGetter } from "effect"
13+
import { Effect, Option, Schema, SchemaGetter } from "effect"
1414
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
1515
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
1616
import { Authorization } from "./auth"
@@ -210,7 +210,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
210210
}),
211211
)
212212

213-
export const experimentalHandlers = Layer.unwrap(
213+
export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
214214
Effect.gen(function* () {
215215
const account = yield* Account.Service
216216
const agents = yield* Agent.Service
@@ -335,27 +335,17 @@ export const experimentalHandlers = Layer.unwrap(
335335
return yield* mcp.resources()
336336
})
337337

338-
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
339-
handlers
340-
.handle("console", getConsole)
341-
.handle("consoleOrgs", listConsoleOrgs)
342-
.handle("consoleSwitch", switchConsole)
343-
.handle("tool", tool)
344-
.handle("toolIDs", toolIDs)
345-
.handle("worktree", worktree)
346-
.handle("worktreeCreate", worktreeCreate)
347-
.handle("worktreeRemove", worktreeRemove)
348-
.handle("worktreeReset", worktreeReset)
349-
.handle("session", session)
350-
.handle("resource", resource),
351-
)
338+
return handlers
339+
.handle("console", getConsole)
340+
.handle("consoleOrgs", listConsoleOrgs)
341+
.handle("consoleSwitch", switchConsole)
342+
.handle("tool", tool)
343+
.handle("toolIDs", toolIDs)
344+
.handle("worktree", worktree)
345+
.handle("worktreeCreate", worktreeCreate)
346+
.handle("worktreeRemove", worktreeRemove)
347+
.handle("worktreeReset", worktreeReset)
348+
.handle("session", session)
349+
.handle("resource", resource)
352350
}),
353-
).pipe(
354-
Layer.provide(Account.defaultLayer),
355-
Layer.provide(Agent.defaultLayer),
356-
Layer.provide(Config.defaultLayer),
357-
Layer.provide(MCP.defaultLayer),
358-
Layer.provide(Project.defaultLayer),
359-
Layer.provide(ToolRegistry.defaultLayer),
360-
Layer.provide(Worktree.defaultLayer),
361351
)

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

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { File } from "@/file"
22
import { Ripgrep } from "@/file/ripgrep"
33
import * as InstanceState from "@/effect/instance-state"
44
import { LSP } from "@/lsp/lsp"
5-
import { Effect, Layer, Schema } from "effect"
5+
import { Effect, Schema } from "effect"
66
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
77
import { Authorization } from "./auth"
88

@@ -116,7 +116,7 @@ export const FileApi = HttpApi.make("file")
116116
}),
117117
)
118118

119-
export const fileHandlers = Layer.unwrap(
119+
export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
120120
Effect.gen(function* () {
121121
const svc = yield* File.Service
122122
const ripgrep = yield* Ripgrep.Service
@@ -154,14 +154,12 @@ export const fileHandlers = Layer.unwrap(
154154
return yield* svc.status()
155155
})
156156

157-
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
158-
handlers
159-
.handle("findText", findText)
160-
.handle("findFile", findFile)
161-
.handle("findSymbol", findSymbol)
162-
.handle("list", list)
163-
.handle("content", content)
164-
.handle("status", status),
165-
)
157+
return handlers
158+
.handle("findText", findText)
159+
.handle("findFile", findFile)
160+
.handle("findSymbol", findSymbol)
161+
.handle("list", list)
162+
.handle("content", content)
163+
.handle("status", status)
166164
}),
167-
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
165+
)

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

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { Config } from "@/config/config"
2-
import { Schema } from "effect"
3-
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
2+
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
3+
import { Installation } from "@/installation"
4+
import { Instance } from "@/project/instance"
5+
import { InstallationVersion } from "@opencode-ai/core/installation/version"
6+
import * as Log from "@opencode-ai/core/util/log"
7+
import { Effect, Schema } from "effect"
8+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
9+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
10+
11+
const log = Log.create({ service: "server" })
412

513
const GlobalHealth = Schema.Struct({
614
healthy: Schema.Literal(true),
715
version: Schema.String,
816
}).annotate({ identifier: "GlobalHealth" })
917

10-
const GlobalEvent = Schema.Struct({
18+
const GlobalEventSchema = Schema.Struct({
1119
directory: Schema.String,
1220
project: Schema.optional(Schema.String),
1321
workspace: Schema.optional(Schema.String),
@@ -50,7 +58,7 @@ export const GlobalApi = HttpApi.make("global").add(
5058
}),
5159
),
5260
HttpApiEndpoint.get("event", GlobalPaths.event, {
53-
success: GlobalEvent,
61+
success: GlobalEventSchema,
5462
}).annotateMerge(
5563
OpenApi.annotations({
5664
identifier: "global.event",
@@ -99,3 +107,153 @@ export const GlobalApi = HttpApi.make("global").add(
99107
)
100108
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
101109
)
110+
111+
function eventData(data: unknown) {
112+
return `data: ${JSON.stringify(data)}\n\n`
113+
}
114+
115+
function parseBody(body: string) {
116+
try {
117+
return JSON.parse(body || "{}") as unknown
118+
} catch {
119+
return undefined
120+
}
121+
}
122+
123+
function eventResponse() {
124+
const encoder = new TextEncoder()
125+
let heartbeat: ReturnType<typeof setInterval> | undefined
126+
let unsubscribe = () => {}
127+
let done = false
128+
129+
const cleanup = () => {
130+
if (done) return
131+
done = true
132+
if (heartbeat) clearInterval(heartbeat)
133+
unsubscribe()
134+
log.info("global event disconnected")
135+
}
136+
137+
log.info("global event connected")
138+
return HttpServerResponse.raw(
139+
new Response(
140+
new ReadableStream<Uint8Array>({
141+
start(controller) {
142+
const write = (data: unknown) => {
143+
if (done) return
144+
try {
145+
controller.enqueue(encoder.encode(eventData(data)))
146+
} catch {
147+
cleanup()
148+
}
149+
}
150+
const handler = (event: GlobalBusEvent) => write(event)
151+
unsubscribe = () => GlobalBus.off("event", handler)
152+
GlobalBus.on("event", handler)
153+
write({ payload: { type: "server.connected", properties: {} } })
154+
heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
155+
},
156+
cancel: cleanup,
157+
}),
158+
{
159+
headers: {
160+
"Cache-Control": "no-cache, no-transform",
161+
"Content-Type": "text/event-stream",
162+
"X-Accel-Buffering": "no",
163+
"X-Content-Type-Options": "nosniff",
164+
},
165+
},
166+
),
167+
)
168+
}
169+
170+
export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
171+
Effect.gen(function* () {
172+
const config = yield* Config.Service
173+
const installation = yield* Installation.Service
174+
175+
const health = Effect.fn("GlobalHttpApi.health")(function* () {
176+
return { healthy: true as const, version: InstallationVersion }
177+
})
178+
179+
const event = Effect.fn("GlobalHttpApi.event")(function* () {
180+
return eventResponse()
181+
})
182+
183+
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
184+
return yield* config.getGlobal()
185+
})
186+
187+
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
188+
return yield* config.updateGlobal(ctx.payload)
189+
})
190+
191+
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
192+
yield* Effect.promise(() => Instance.disposeAll())
193+
GlobalBus.emit("event", {
194+
directory: "global",
195+
payload: { type: "global.disposed", properties: {} },
196+
})
197+
return true
198+
})
199+
200+
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
201+
const method = yield* installation.method()
202+
if (method === "unknown") {
203+
return {
204+
status: 400,
205+
body: { success: false as const, error: "Unknown installation method" },
206+
}
207+
}
208+
const target = ctx.payload.target || (yield* installation.latest(method))
209+
const result = yield* installation.upgrade(method, target).pipe(
210+
Effect.as({ status: 200, body: { success: true as const, version: target } }),
211+
Effect.catch((err) =>
212+
Effect.succeed({
213+
status: 500,
214+
body: {
215+
success: false as const,
216+
error: err instanceof Error ? err.message : String(err),
217+
},
218+
}),
219+
),
220+
)
221+
if (!result.body.success) return result
222+
GlobalBus.emit("event", {
223+
directory: "global",
224+
payload: {
225+
type: Installation.Event.Updated.type,
226+
properties: { version: target },
227+
},
228+
})
229+
return result
230+
})
231+
232+
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
233+
request: HttpServerRequest.HttpServerRequest
234+
}) {
235+
const body = yield* Effect.orDie(ctx.request.text)
236+
const json = parseBody(body)
237+
if (json === undefined) {
238+
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
239+
}
240+
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
241+
Effect.map((payload) => ({ valid: true as const, payload })),
242+
Effect.catch(() => Effect.succeed({ valid: false as const })),
243+
)
244+
if (!payload.valid) {
245+
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
246+
}
247+
const result = yield* upgrade({ payload: payload.payload })
248+
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
249+
})
250+
251+
return handlers
252+
.handle("health", health)
253+
.handleRaw("event", event)
254+
.handle("configGet", configGet)
255+
.handle("configUpdate", configUpdate)
256+
.handle("dispose", dispose)
257+
.handleRaw("upgrade", upgradeRaw)
258+
}),
259+
)

0 commit comments

Comments
 (0)