feat(plugin): expose app metadata (#38179)

This commit is contained in:
Dax 2026-07-21 18:16:25 -04:00 committed by GitHub
commit 8de40be6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 477 additions and 286 deletions

View file

@ -1,15 +1,18 @@
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers
.handle("health.get", () =>
Effect.succeed({
healthy: true as const,
version: InstallationVersion,
pid: process.pid,
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
healthy: true as const,
version: info.app.version ?? "unknown",
pid: process.pid,
}
}),
)
.handle("health.stop", () => Effect.succeed({ accepted: false })),

View file

@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
client: Schema.optional(Schema.String),
app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
hostname: Schema.optional(Schema.String),
port: Schema.optional(
Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)),

View file

@ -1,7 +1,6 @@
export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
dispatch(password, status, application, shutdown).pipe(
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
),
HttpMiddleware.logger,
@ -153,6 +152,7 @@ function dispatch(
status: Status.Interface,
application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>,
version: string,
): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () {
@ -166,7 +166,7 @@ function dispatch(
: undefined
if (lifecycle !== undefined) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void))
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
}
const state = yield* status.current
const app = yield* Ref.get(application)
@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* (
route: "health" | "stop",
status: Status.Interface,
stop: () => void,
version: string,
) {
if (route === "health") return yield* healthResponse(status)
if (route === "health") return yield* healthResponse(status, version)
const body = yield* request.json.pipe(Effect.option)
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
@ -210,10 +211,10 @@ const control = Effect.fnUntraced(function* (
return HttpServerResponse.jsonUnsafe({ accepted })
})
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
const state = yield* status.current
return HttpServerResponse.jsonUnsafe(
{ healthy: true, version: InstallationVersion, pid: process.pid },
{ healthy: true, version, pid: process.pid },
{
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,

View file

@ -1,4 +1,5 @@
import { Database } from "@opencode-ai/core/database/database"
import { App } from "@opencode-ai/core/app"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -6,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { CommandV2 } from "@opencode-ai/core/command"
@ -15,12 +15,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@ -88,7 +85,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Client.node, Client.configured(options.client)],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
@ -105,6 +102,15 @@ function makeRoutes<AuthError, AuthServices>(
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
@ -112,7 +118,7 @@ function makeRoutes<AuthError, AuthServices>(
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements()
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
}),
)
@ -123,7 +129,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
ServerInfo.layer(serviceURLs),
ServerInfo.layer(serviceURLs, options.app),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
@ -133,7 +139,14 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))),
Layer.provide(
Observability.layer({
...options.observability,
client: options.app?.name,
version: options.app?.version,
channel: options.app?.channel,
}),
),
HttpRouter.provideRequest(requestServices),
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),

View file

@ -1,12 +1,14 @@
import { Context, Layer } from "effect"
import { networkInterfaces } from "node:os"
import type { ServerOptions } from "./options"
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
"@opencode-ai/server/ServerInfo",
) {}
export class Service extends Context.Service<
Service,
{ readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
>()("@opencode-ai/server/ServerInfo") {}
export function layer(urls: () => ReadonlyArray<string>) {
return Layer.succeed(Service, Service.of({ urls }))
export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
return Layer.succeed(Service, Service.of({ urls, app }))
}
export function connectionURLs(value: string, requestedHostname?: string) {