chore(observability): merge v2

This commit is contained in:
starptech 2026-07-09 06:36:36 +02:00
commit f4e661f874
276 changed files with 17001 additions and 3755 deletions

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.14",
"version": "1.17.15",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { PluginHandler } from "./handlers/plugin"
import { HealthHandler } from "./handlers/health"
import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
export const handlers = Layer.mergeAll(
HealthHandler,
ServerHandler,
DebugHandler,
LocationHandler,
AgentHandler,

View file

@ -6,7 +6,9 @@ import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
const subscriberCapacity = 256
// Session execution emits dense event bursts; allow healthy SSE clients enough
// time to absorb one without weakening the bounded slow-subscriber failure.
const subscriberCapacity = 4_096
function eventData(data: unknown): Sse.Event {
return {

View file

@ -0,0 +1,13 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return { urls: info.urls() }
}),
),
)

View file

@ -206,37 +206,39 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.move",
Effect.fn(function* (ctx) {
yield* moveSession.moveSession({
sessionID: ctx.params.sessionID,
destination: ctx.payload.destination,
moveChanges: ctx.payload.moveChanges,
}).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
yield* moveSession
.moveSession({
sessionID: ctx.params.sessionID,
destination: ctx.payload.destination,
moveChanges: ctx.payload.moveChanges,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
),
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
),
Effect.catchTag("MoveSession.ApplyChangesError", () =>
Effect.fail(
new InvalidRequestError({
message:
"Unable to apply your changes in the destination directory. The files may conflict with existing changes.",
}),
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
),
),
Effect.catchTag("MoveSession.CaptureChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
Effect.catchTag("MoveSession.ResetSourceChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
)
Effect.catchTag("MoveSession.ApplyChangesError", () =>
Effect.fail(
new InvalidRequestError({
message:
"Unable to apply your changes in the destination directory. The files may conflict with existing changes.",
}),
),
),
Effect.catchTag("MoveSession.CaptureChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
Effect.catchTag("MoveSession.ResetSourceChangesError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
)
return HttpApiSchema.NoContent.make()
}),
)
@ -248,7 +250,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.prompt({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
prompt: ctx.payload.prompt,
text: ctx.payload.text,
files: ctx.payload.files,
agents: ctx.payload.agents,
metadata: ctx.payload.metadata,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
})
@ -270,7 +275,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
),
),
Effect.catchTag("Session.AttachmentError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })),
Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })),
),
),
}
@ -362,12 +367,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.synthetic",
Effect.fn(function* (ctx) {
yield* session
const data = yield* session
.synthetic({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
text: ctx.payload.text,
description: ctx.payload.description,
metadata: ctx.payload.metadata,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
})
.pipe(
@ -379,8 +386,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
),
Effect.catchTag("Session.SyntheticConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Synthetic input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
),
)
return HttpApiSchema.NoContent.make()
return { data }
}),
)
.handle(

View file

@ -8,6 +8,7 @@ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
export type Options = {
readonly hostname: string
@ -48,7 +49,12 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
createRoutes(password).pipe(
createRoutes(password, () => {
const address = server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}).pipe(
Layer.flatMap((context) =>
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger).pipe(
Layer.provide(Layer.succeedContext(context)),

View file

@ -31,6 +31,7 @@ import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerObservability } from "./observability"
import { ServerInfo } from "./server-info"
const applicationServices = LayerNode.group([
Database.node,
@ -51,11 +52,12 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
serviceURLs,
)
}
@ -63,7 +65,10 @@ export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
}
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [],
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node],
@ -89,7 +94,10 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
Layer.provideMerge(Observability.layer),
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context))
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)),
ServerInfo.layer(serviceURLs),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
Layer.provide(formLocationLayer),

View file

@ -0,0 +1,32 @@
import { Context, Layer } from "effect"
import { networkInterfaces } from "node:os"
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
"@opencode-ai/server/ServerInfo",
) {}
export function layer(urls: () => ReadonlyArray<string>) {
return Layer.succeed(Service, Service.of({ urls }))
}
export function connectionURLs(value: string, requestedHostname?: string) {
const url = new URL(value)
const hostname = requestedHostname ?? url.hostname
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
if (family === undefined) return [value]
return [
...new Set(
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => !entry.internal && entry.family === family)
.map((entry) => {
const result = new URL(value)
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
return result.toString().replace(/\/$/, "")
}),
),
]
}
export * as ServerInfo from "./server-info"