fix(httpapi): wire global and control handlers (#24835)

This commit is contained in:
Kit Langton
2026-04-28 16:31:45 -04:00
committed by GitHub
parent 0acac216ae
commit 58836e75f0
18 changed files with 448 additions and 207 deletions
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
@@ -57,7 +57,7 @@ export const ConfigApi = HttpApi.make("config")
}),
)
export const configHandlers = Layer.unwrap(
export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
@@ -80,8 +80,6 @@ export const configHandlers = Layer.unwrap(
}
})
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
handlers.handle("get", get).handle("update", update).handle("providers", providers),
)
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
)
@@ -1,7 +1,8 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const AuthParams = Schema.Struct({
providerID: ProviderID,
@@ -69,3 +70,30 @@ export const ControlApi = HttpApi.make("control").add(
)
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
)
export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -10,7 +10,7 @@ import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Layer, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -210,7 +210,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
)
export const experimentalHandlers = Layer.unwrap(
export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
@@ -335,8 +335,7 @@ export const experimentalHandlers = Layer.unwrap(
return yield* mcp.resources()
})
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
handlers
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
@@ -347,15 +346,6 @@ export const experimentalHandlers = Layer.unwrap(
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource),
)
.handle("resource", resource)
}),
).pipe(
Layer.provide(Account.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Worktree.defaultLayer),
)
@@ -2,7 +2,7 @@ import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -116,7 +116,7 @@ export const FileApi = HttpApi.make("file")
}),
)
export const fileHandlers = Layer.unwrap(
export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
@@ -154,14 +154,12 @@ export const fileHandlers = Layer.unwrap(
return yield* svc.status()
})
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
handlers
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status),
)
.handle("status", status)
}),
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
)
@@ -1,13 +1,21 @@
import { Config } from "@/config/config"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const log = Log.create({ service: "server" })
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
const GlobalEvent = Schema.Struct({
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
@@ -50,7 +58,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEvent,
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
@@ -99,3 +107,153 @@ export const GlobalApi = HttpApi.make("global").add(
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
function eventData(data: unknown) {
return `data: ${JSON.stringify(data)}\n\n`
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | undefined
let unsubscribe = () => {}
let done = false
const cleanup = () => {
if (done) return
done = true
if (heartbeat) clearInterval(heartbeat)
unsubscribe()
log.info("global event disconnected")
}
log.info("global event connected")
return HttpServerResponse.raw(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
const write = (data: unknown) => {
if (done) return
try {
controller.enqueue(encoder.encode(eventData(data)))
} catch {
cleanup()
}
}
const handler = (event: GlobalBusEvent) => write(event)
unsubscribe = () => GlobalBus.off("event", handler)
GlobalBus.on("event", handler)
write({ payload: { type: "server.connected", properties: {} } })
heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
},
cancel: cleanup,
}),
{
headers: {
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
),
)
}
export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -6,7 +6,7 @@ import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
@@ -140,7 +140,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
)
export const instanceHandlers = Layer.unwrap(
export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
@@ -194,8 +194,7 @@ export const instanceHandlers = Layer.unwrap(
return yield* format.status()
})
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
handlers
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
@@ -204,14 +203,6 @@ export const instanceHandlers = Layer.unwrap(
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter),
)
.handle("formatter", getFormatter)
}),
).pipe(
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Vcs.defaultLayer),
)
@@ -1,6 +1,6 @@
import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -137,7 +137,7 @@ export const McpApi = HttpApi.make("mcp")
}),
)
export const mcpHandlers = Layer.unwrap(
export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
@@ -188,8 +188,7 @@ export const mcpHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
handlers
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
@@ -197,7 +196,6 @@ export const mcpHandlers = Layer.unwrap(
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect),
)
.handle("disconnect", disconnect)
}),
).pipe(Layer.provide(MCP.defaultLayer))
)
@@ -1,6 +1,6 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -47,7 +47,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
)
export const permissionHandlers = Layer.unwrap(
export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
@@ -67,8 +67,6 @@ export const permissionHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
return handlers.handle("list", list).handle("reply", reply)
}),
).pipe(Layer.provide(Permission.defaultLayer))
)
@@ -3,7 +3,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Project } from "@/project/project"
import { InstanceBootstrap } from "@/project/bootstrap"
import { ProjectID } from "@/project/schema"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForReload } from "./lifecycle"
@@ -69,7 +69,7 @@ export const ProjectApi = HttpApi.make("project")
}),
)
export const projectHandlers = Layer.unwrap(
export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
@@ -102,8 +102,6 @@ export const projectHandlers = Layer.unwrap(
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update),
)
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
).pipe(Layer.provide(Project.defaultLayer))
)
@@ -4,7 +4,7 @@ import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -74,7 +74,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
)
export const providerHandlers = Layer.unwrap(
export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
@@ -148,16 +148,10 @@ export const providerHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
handlers
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback),
)
.handle("callback", callback)
}),
).pipe(
Layer.provide(ProviderAuth.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
)
@@ -2,7 +2,7 @@ import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -131,7 +131,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
export const ptyHandlers = Layer.unwrap(
export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
@@ -179,15 +179,13 @@ export const ptyHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
handlers
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove),
)
.handle("remove", remove)
}),
)
@@ -1,6 +1,6 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -57,7 +57,7 @@ export const QuestionApi = HttpApi.make("question")
}),
)
export const questionHandlers = Layer.unwrap(
export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
@@ -81,8 +81,6 @@ export const questionHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
handlers.handle("list", list).handle("reply", reply).handle("reject", reject),
)
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
).pipe(Layer.provide(Question.defaultLayer))
)
@@ -1,21 +1,47 @@
import { Context, Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Command } from "@/command"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Format } from "@/format"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Installation } from "@/installation"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { ControlApi, controlHandlers } from "./control"
import { eventRoute } from "./event"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
import { GlobalApi, globalHandlers } from "./global"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
@@ -73,30 +99,59 @@ const instance = HttpRouter.middleware()(
}),
).layer
export const routes = Layer.mergeAll(
eventRoute,
ptyConnectRoute,
const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers))
const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers))
const instanceApiRoutes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)),
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
HttpApiBuilder.layer(TuiApi).pipe(
Layer.provide(tuiHandlers),
Layer.provide(Session.defaultLayer),
Layer.provide(Bus.layer),
),
HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
).pipe(
)
const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe(
Layer.provide(authorizationLayer),
Layer.provide(instance),
)
export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes).pipe(
Layer.provide(Account.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(File.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Installation.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(ProviderAuth.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Pty.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Session.defaultLayer),
).pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(Worktree.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
@@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Layer, Schema, SchemaGetter, Struct } from "effect"
import { Effect, Schema, SchemaGetter, Struct } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
@@ -431,7 +431,7 @@ export const SessionApi = HttpApi.make("session")
}),
)
export const sessionHandlers = Layer.unwrap(
export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service
@@ -908,8 +908,7 @@ export const sessionHandlers = Layer.unwrap(
)
})
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
handlers
return handlers
.handle("list", list)
.handle("status", status)
.handle("get", get)
@@ -936,13 +935,6 @@ export const sessionHandlers = Layer.unwrap(
.handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart),
)
.handle("updatePart", updatePart)
}),
).pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
)
@@ -10,7 +10,7 @@ import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { NonNegativeInt } from "@/util/schema"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -97,7 +97,7 @@ export const SyncApi = HttpApi.make("sync")
}),
)
export const syncHandlers = Layer.unwrap(
export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
@@ -132,8 +132,6 @@ export const syncHandlers = Layer.unwrap(
)
})
return HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
handlers.handle("start", start).handle("replay", replay).handle("history", history),
)
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -4,7 +4,7 @@ import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
@@ -183,7 +183,7 @@ export const TuiApi = HttpApi.make("tui")
}),
)
export const tuiHandlers = Layer.unwrap(
export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
@@ -273,8 +273,7 @@ export const tuiHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
handlers
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
@@ -287,7 +286,6 @@ export const tuiHandlers = Layer.unwrap(
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse),
)
.handle("controlResponse", controlResponse)
}),
)
@@ -3,7 +3,7 @@ import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect, Layer, Schema, Struct } from "effect"
import { Effect, Schema, Struct } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -107,7 +107,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
)
export const workspaceHandlers = Layer.unwrap(
export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
@@ -155,14 +155,12 @@ export const workspaceHandlers = Layer.unwrap(
)
})
return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
handlers
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore),
)
.handle("sessionRestore", sessionRestore)
}),
)
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -293,4 +295,55 @@ describe("HttpApi server", () => {
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "query" })
})
test("serves global health from Effect HttpApi", async () => {
const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ healthy: true })
})
test("serves global event stream from Effect HttpApi", async () => {
const response = await app().request(GlobalPaths.event)
if (!response.body) throw new Error("missing event stream body")
const reader = response.body.getReader()
const chunk = await reader.read()
await reader.cancel()
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
})
test("serves control log from Effect HttpApi", async () => {
const response = await app().request(ControlPaths.log, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
})
test("validates control auth without falling through to 404", async () => {
const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "api" }),
})
expect(response.status).toBe(400)
})
test("validates global upgrade without invoking installers", async () => {
const response = await app().request(GlobalPaths.upgrade, {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ success: false })
})
})