chore(observability): merge v2
This commit is contained in:
commit
f4e661f874
276 changed files with 17001 additions and 3755 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
13
packages/server/src/handlers/server.ts
Normal file
13
packages/server/src/handlers/server.ts
Normal 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() }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
32
packages/server/src/server-info.ts
Normal file
32
packages/server/src/server-info.ts
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue