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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 448 additions and 207 deletions

View file

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

View file

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

View file

@ -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,27 +335,17 @@ export const experimentalHandlers = Layer.unwrap(
return yield* mcp.resources()
})
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource),
)
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.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),
)

View file

@ -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
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status),
)
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
)

View file

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

View file

@ -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,24 +194,15 @@ export const instanceHandlers = Layer.unwrap(
return yield* format.status()
})
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter),
)
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.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),
)

View file

@ -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,16 +188,14 @@ export const mcpHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect),
)
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
).pipe(Layer.provide(MCP.defaultLayer))
)

View file

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

View file

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

View file

@ -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
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback),
)
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
).pipe(
Layer.provide(ProviderAuth.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
)

View file

@ -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
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove),
)
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)

View file

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

View file

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

View file

@ -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,41 +908,33 @@ export const sessionHandlers = Layer.unwrap(
)
})
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
handlers
.handle("list", list)
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
.handle("message", message)
.handleRaw("create", createRaw)
.handle("remove", remove)
.handle("update", update)
.handle("fork", fork)
.handle("abort", abort)
.handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
.handle("summarize", summarize)
.handle("prompt", prompt)
.handle("promptAsync", promptAsync)
.handle("command", command)
.handle("shell", shell)
.handle("revert", revert)
.handle("unrevert", unrevert)
.handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart),
)
return handlers
.handle("list", list)
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
.handle("message", message)
.handleRaw("create", createRaw)
.handle("remove", remove)
.handle("update", update)
.handle("fork", fork)
.handle("abort", abort)
.handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
.handle("summarize", summarize)
.handle("prompt", prompt)
.handle("promptAsync", promptAsync)
.handle("command", command)
.handle("shell", shell)
.handle("revert", revert)
.handle("unrevert", unrevert)
.handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart)
}),
).pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
)

View file

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

View file

@ -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,21 +273,19 @@ export const tuiHandlers = Layer.unwrap(
return true
})
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse),
)
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)

View file

@ -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
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore),
)
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)

View file

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