diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts index e659cf74e0..eef825967b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts index f850f76e7e..718629db71 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts @@ -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) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts index 7e0aae8f48..cc39c7604b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts @@ -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), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts index 9f2ab8a3ce..df525680ae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts index 215c19ef71..ef7fb331f6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts @@ -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 | 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({ + 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) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/instance.ts index d36c43c767..8c471c12a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/instance.ts @@ -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), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts index 8fea8da9f0..f5552f6f2f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/permission.ts index 85dbecd116..357c832990 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/permission.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts index f5a39e39e9..276798b0b9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts index 45cb643750..7dbc491e13 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts @@ -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), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index 2170002535..d4e77c9d03 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -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) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/question.ts index 526a78ee0a..2169e17c5c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/question.ts @@ -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)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 5ab00d6a00..66c4f2dd16 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -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), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index 449d70e176..9001ae49d5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -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), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts index 1374518c61..67fcede2f8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/sync.ts @@ -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) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts index 36004ea250..2bcc740ddd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/tui.ts @@ -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) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts index c269596011..1c5b4f87d8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts @@ -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) }), ) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 165d0a6c67..7a7105dfaa 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -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 }) + }) })