Kaynağa Gözat

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

Kit Langton 3 ay önce
ebeveyn
işleme
58836e75f0

+ 4 - 6
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))
+)

+ 30 - 2
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)
+  }),
+)

+ 14 - 24
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),
 )

+ 10 - 12
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))
+)

+ 162 - 4
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<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)
+  }),
+)

+ 12 - 21
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),
 )

+ 12 - 14
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))
+)

+ 4 - 6
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))
+)

+ 4 - 6
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))
+)

+ 7 - 13
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),
 )

+ 9 - 11
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)
   }),
 )
 

+ 4 - 6
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))
+)

+ 65 - 10
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),
 )

+ 30 - 38
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),
 )

+ 3 - 5
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)
   }),
 )

+ 16 - 18
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)
   }),
 )

+ 9 - 11
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)
   }),
 )

+ 53 - 0
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 })
+  })
 })