feat(sdk): configure embedded logging (#41809)
This commit is contained in:
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
@@ -44,7 +44,12 @@ const layer = Layer.effect(
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||
inherit,
|
||||
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
|
||||
@@ -13,6 +13,19 @@ const session = yield * opencode.sessions.get({ sessionID })
|
||||
|
||||
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
|
||||
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
|
||||
|
||||
```ts
|
||||
const opencode =
|
||||
yield *
|
||||
OpenCode.create({
|
||||
log: {
|
||||
level: "warn",
|
||||
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
|
||||
|
||||
The same constructor is available as a service Layer:
|
||||
@@ -26,4 +39,4 @@ const program = Effect.gen(function* () {
|
||||
yield * program.pipe(Effect.provide(OpenCode.layer))
|
||||
```
|
||||
|
||||
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
|
||||
`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Context, Formatter, Layer, Logger, References } from "effect"
|
||||
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
|
||||
|
||||
export type LogEntry = {
|
||||
readonly level: LogLevel
|
||||
readonly message: string
|
||||
readonly attributes?: Readonly<Record<string, unknown>>
|
||||
readonly cause?: unknown
|
||||
}
|
||||
|
||||
export type LogWriter = (entry: LogEntry) => void
|
||||
|
||||
export type LogOptions = {
|
||||
readonly level?: LogLevel
|
||||
readonly emit: LogWriter
|
||||
}
|
||||
|
||||
const levels: Record<LogLevel, Logger.Options<unknown>["logLevel"]> = {
|
||||
trace: "Trace",
|
||||
debug: "Debug",
|
||||
info: "Info",
|
||||
warn: "Warn",
|
||||
error: "Error",
|
||||
fatal: "Fatal",
|
||||
}
|
||||
|
||||
function normalizeLevel(level: Logger.Options<unknown>["logLevel"]): LogLevel | undefined {
|
||||
const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name]))
|
||||
return output[level] as LogLevel
|
||||
}
|
||||
|
||||
export function layer(log?: LogOptions) {
|
||||
const logger = Logger.make((options) => {
|
||||
if (!log) return
|
||||
const level = normalizeLevel(options.logLevel)
|
||||
if (!level) return
|
||||
const entry = Logger.formatStructured.log(options)
|
||||
const values = Array.isArray(entry.message) ? entry.message : [entry.message]
|
||||
const [message, ...data] = values
|
||||
const details =
|
||||
data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly<Record<string, unknown>>) : undefined
|
||||
const { cause: detailCause, ...detailAttributes } = details ?? {}
|
||||
const attributes = {
|
||||
...entry.annotations,
|
||||
...detailAttributes,
|
||||
...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}),
|
||||
...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}),
|
||||
}
|
||||
try {
|
||||
log.emit({
|
||||
level,
|
||||
message: typeof message === "string" ? message : Formatter.format(message),
|
||||
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
||||
...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }),
|
||||
})
|
||||
} catch {
|
||||
// A host logger must not break OpenCode operations.
|
||||
}
|
||||
})
|
||||
return Layer.merge(
|
||||
Logger.layer([logger], { mergeWithExisting: false }),
|
||||
Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]),
|
||||
)
|
||||
}
|
||||
|
||||
export function context(source: Context.Context<never>) {
|
||||
return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe(
|
||||
Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)),
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,27 @@ import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import type { ServerOptions } from "@opencode-ai/server/options"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import * as Logging from "./logging"
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
|
||||
import type { LogOptions } from "./logging"
|
||||
|
||||
export type CreateOptions = ServerOptions & {
|
||||
readonly log?: LogOptions
|
||||
}
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
|
||||
const { log, ...server } = options
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
ManagedRuntime.make(
|
||||
createEmbeddedRoutes({
|
||||
...options,
|
||||
app: { ...options.app, name: options.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...options.database },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
...server,
|
||||
app: { ...server.app, name: server.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...server.database },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
|
||||
),
|
||||
),
|
||||
(runtime) => runtime.disposeEffect,
|
||||
@@ -21,7 +30,9 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
|
||||
const context = yield* runtime.contextEffect
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const router = Context.get(context, HttpRouter.HttpRouter)
|
||||
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
|
||||
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
|
||||
Logging.context(context),
|
||||
)(router.asHttpEffect())
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
|
||||
Reference in New Issue
Block a user