From 0409e6884dace3051fdf2da3c2f01a8dc73c189c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 20 Jul 2026 20:49:25 -0400 Subject: [PATCH] refactor(server): inject runtime options --- packages/cli/src/index.ts | 7 ++- packages/cli/src/server-process.ts | 19 ++++++- packages/core/src/database/database.ts | 9 ++-- packages/core/src/effect/runtime.ts | 2 +- packages/core/src/filesystem/search.ts | 19 +++++-- packages/core/src/filesystem/watcher.ts | 18 +++++-- packages/core/src/models-dev.ts | 11 ++-- packages/core/src/observability.ts | 50 ++++++++++++------- packages/core/src/observability/otlp.ts | 29 +++++++---- .../core/test/effect/observability.test.ts | 2 +- packages/opencode/src/effect/app-runtime.ts | 2 +- .../opencode/src/effect/bootstrap-runtime.ts | 2 +- packages/opencode/src/effect/run-service.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 +- packages/sdk-next/src/opencode.ts | 10 ++-- packages/server/src/options.ts | 30 +++++++---- packages/server/src/routes.ts | 6 ++- 17 files changed, 146 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7ee80846ed..0ca2b66de1 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -61,7 +61,12 @@ Effect.logInfo("cli starting", { Effect.provide(Config.layer), Effect.provide(Updater.layer), Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), - Effect.provide(Observability.layer), + Effect.provide( + Observability.layer({ + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, + }), + ), Effect.provide(NodeServices.layer), Effect.scoped, Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))), diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index b35e74e460..3c5663f56d 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -71,7 +71,20 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { models: { url: process.env.OPENCODE_MODELS_URL, file: process.env.OPENCODE_MODELS_PATH, - fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""), + fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH), + }, + observability: { + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, + }, + fs: { + filewatcher: !truthy( + process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER, + ), + fff: + process.env.OPENCODE_DISABLE_FFF === undefined + ? process.platform !== "win32" + : !truthy(process.env.OPENCODE_DISABLE_FFF), }, }, serviceOptions === undefined @@ -177,6 +190,10 @@ function serviceURL(hostname: string, port: number) { return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}` } +function truthy(value?: string) { + return value === "1" || value?.toLowerCase() === "true" +} + function addressInUse(error: unknown): boolean { if (typeof error !== "object" || error === null) return false if ("code" in error && error.code === "EADDRINUSE") return true diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index c3ab69baa0..d6cb3069d4 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,7 +2,7 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { sqliteLayer } from "#sqlite" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { Global } from "../global" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" @@ -16,9 +16,10 @@ export interface Interface { db: DatabaseShape } -export interface Options { - readonly path?: string -} +export const Options = Schema.Struct({ + path: Schema.optional(Schema.String), +}) +export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/v2/storage/Database") {} diff --git a/packages/core/src/effect/runtime.ts b/packages/core/src/effect/runtime.ts index 6ad0f85176..ea9e0607e5 100644 --- a/packages/core/src/effect/runtime.ts +++ b/packages/core/src/effect/runtime.ts @@ -5,7 +5,7 @@ import { Observability } from "../observability" export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined const getRuntime = () => - (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer, { + (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer()) as Layer.Layer, { memoMap, })) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index a2b21d32cd..28241ca380 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search" import { makeLocationNode } from "../effect/app-node" import path from "path" -import { Context, Effect, Layer, Scope } from "effect" +import { Context, Effect, Layer, Schema, Scope } from "effect" import { Fff } from "#fff" import fuzzysort from "fuzzysort" import { FileSystem } from "../filesystem" @@ -10,7 +10,6 @@ import { FSUtil } from "../fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" -import { Flag } from "../flag/flag" export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect @@ -18,6 +17,11 @@ export interface Interface { readonly grep: (input: FileSystem.GrepInput) => Effect.Effect } +export const Options = Schema.Struct({ + fff: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type + export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {} export const ripgrepLayer = Layer.effect( @@ -232,13 +236,18 @@ export const fffLayer = Layer.effect( }), ) -const layer = Layer.unwrap( +export const layer = (options?: Options) => Layer.unwrap( Effect.gen(function* () { - if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer + if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available()) + return ripgrepLayer const location = yield* Location.Service // Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree. return location.vcs ? fffLayer : ripgrepLayer }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] }) +export function nodeWith(options?: Options) { + return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] }) +} + +export const node = nodeWith() diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 8c4e6265a2..4c0c72ce30 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -5,9 +5,8 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { FileSystem } from "@opencode-ai/schema/filesystem" import { makeGlobalNode } from "../effect/app-node" -import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect" +import { Cause, Context, Effect, Layer, PubSub, Schema, Scope, Stream } from "effect" import { KeyedMutex } from "../effect/keyed-mutex" -import { Flag } from "../flag/flag" import { lazy } from "../util/lazy" import { watch as watchFileSystem } from "node:fs" import path from "path" @@ -50,14 +49,19 @@ export interface Interface { readonly subscribe: (input: WatchInput) => Stream.Stream } +export const Options = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type + export class Service extends Context.Service()("@opencode/Watcher") {} -const layer = Layer.effect( +export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { const backend = getBackend() const native = watcher() - if (Flag.OPENCODE_DISABLE_FILEWATCHER) { + if (options?.enabled === false) { return Service.of({ subscribe: () => Stream.empty }) } @@ -140,7 +144,11 @@ const layer = Layer.effect( }), ) -export const node = makeGlobalNode({ service: Service, layer, deps: [] }) +export function nodeWith(options?: Options) { + return makeGlobalNode({ service: Service, layer: layer(options), deps: [] }) +} + +export const node = nodeWith() function subscribeDirectory( native: typeof import("@parcel/watcher") | undefined, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6be577b55b..bef11207b5 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -530,11 +530,12 @@ export interface Interface { readonly refresh: (force?: boolean) => Effect.Effect } -export interface Options { - readonly url?: string - readonly file?: string - readonly fetch?: boolean -} +export const Options = Schema.Struct({ + url: Schema.optional(Schema.String), + file: Schema.optional(Schema.String), + fetch: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/ModelsDev") {} diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index e99d8919d2..71a99ce2e7 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -2,29 +2,41 @@ export * as Observability from "./observability" import { NodeFileSystem } from "@effect/platform-node" import { LayerNode } from "./effect/layer-node" -import { Effect, Layer, Logger, References } from "effect" +import { Effect, Layer, Logger, References, Schema } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" import { Logging } from "./observability/logging" import { Otlp } from "./observability/otlp" -const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( - Layer.provide(NodeFileSystem.layer), - Layer.orDie, - Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), -) +export const Options = Schema.Struct({ + endpoint: Schema.optional(Schema.String), + headers: Schema.optional(Schema.String), +}) +export type Options = typeof Options.Type -export const layer = Layer.unwrap( - Effect.gen(function* () { - const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe( - Layer.provide(NodeFileSystem.layer), - Layer.provide(OtlpSerialization.layerJson), - Layer.provide(FetchHttpClient.layer), - Layer.orDie, - Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), - ) - return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) - }), -).pipe(Layer.catchCause(() => local)) +export function layer( + options: Options = { + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, + }, +) { + const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), + ) + return Layer.unwrap( + Effect.gen(function* () { + const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options)], { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.provide(OtlpSerialization.layerJson), + Layer.provide(FetchHttpClient.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), + ) + return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options))) + }), + ).pipe(Layer.catchCause(() => local)) +} -export const node = LayerNode.make({ name: "observability", layer, deps: [] }) +export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] }) diff --git a/packages/core/src/observability/otlp.ts b/packages/core/src/observability/otlp.ts index dd99ebc143..d64fbe9940 100644 --- a/packages/core/src/observability/otlp.ts +++ b/packages/core/src/observability/otlp.ts @@ -4,10 +4,14 @@ import { Flag } from "../flag/flag" import { InstallationChannel, InstallationVersion } from "../installation/version" import { runID } from "./shared" -const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT +export interface Options { + readonly endpoint?: string + readonly headers?: string +} -const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS - ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( +function parseHeaders(value?: string) { + return value + ? value.split(",").reduce( (acc, entry) => { const [key, ...value] = entry.split("=") acc[key] = value.join("=") @@ -15,7 +19,8 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS }, {} as Record, ) - : undefined + : undefined +} function resourceAttributes() { const value = process.env.OTEL_RESOURCE_ATTRIBUTES @@ -47,13 +52,15 @@ export function resource(): { serviceName: string; serviceVersion: string; attri } } -export function loggers() { - if (!endpoint) return [] - return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })] +export function loggers(options?: Options) { + if (!options?.endpoint) return [] + return [ + OtlpLogger.make({ url: `${options.endpoint}/v1/logs`, resource: resource(), headers: parseHeaders(options.headers) }), + ] } -export async function tracingLayer() { - if (!endpoint) return Layer.empty +export async function tracingLayer(options?: Options) { + if (!options?.endpoint) return Layer.empty const NodeSdk = await import("@effect/opentelemetry/NodeSdk") const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") const SdkBase = await import("@opentelemetry/sdk-trace-base") @@ -69,8 +76,8 @@ export async function tracingLayer() { resource: resource(), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ - url: `${endpoint}/v1/traces`, - headers, + url: `${options.endpoint}/v1/traces`, + headers: parseHeaders(options.headers), }), ), })) diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index c075986364..792978446b 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -66,7 +66,7 @@ test("falls back to local logging when OTLP initialization fails", async () => { ` import { Effect } from "effect" import { Observability } from "./src/observability.ts" - await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise) + await Effect.void.pipe(Effect.provide(Observability.layer()), Effect.scoped, Effect.runPromise) `, ], { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 1ebed3b472..85ae3679a2 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -104,7 +104,7 @@ export const AppLayer = AppNodeBuilderV1.build( ShareNext.node, SessionShare.node, ]), -).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer())) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 57fe43b46e..eeec0659b1 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -14,6 +14,6 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map" export const BootstrapLayer = AppNodeBuilder.build( LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), -).pipe(Layer.provide(Observability.layer)) +).pipe(Layer.provide(Observability.layer())) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index cfcc50081e..2c2cffc706 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -32,7 +32,7 @@ export function attach(effect: Effect.Effect): Effect.Effect(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined - const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer), { memoMap })) + const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer()), { memoMap })) return { runSync: (fn: (svc: S) => Effect.Effect) => getRuntime().runSync(attach(service.use(fn))), diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fad59961b1..4fd6cfb3e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -305,7 +305,7 @@ export function createRoutes( Layer.provide(locationServiceMapV2), Layer.provide(AppNodeBuilderV1.build(app)), - Layer.provideMerge(Observability.layer), + Layer.provideMerge(Observability.layer()), ) } diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 34191aee5a..bd6b9cc7c2 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,19 +1,15 @@ import { OpenCode } from "@opencode-ai/client/effect" -import { Database } from "@opencode-ai/core/database/database" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { ModelsDev } from "@opencode-ai/core/models-dev" 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" -export const create = Effect.fn("OpenCode.create")(function* (options: { - readonly database?: Database.Options - readonly models?: ModelsDev.Options -} = {}) { +export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) { const runtime = yield* Effect.acquireRelease( Effect.sync(() => ManagedRuntime.make( - createEmbeddedRoutes({ database: { path: ":memory:", ...options.database }, models: options.models }).pipe( + createEmbeddedRoutes({ ...options, database: { path: ":memory:", ...options.database } }).pipe( Layer.provide(HttpServer.layerServices), ), ), diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index d3d3ecdbc1..43c8e9213e 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -1,10 +1,22 @@ -import type { Database } from "@opencode-ai/core/database/database" -import type { ModelsDev } from "@opencode-ai/core/models-dev" +import { Database } from "@opencode-ai/core/database/database" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Observability } from "@opencode-ai/core/observability" +import { Schema } from "effect" -export interface ServerOptions { - readonly hostname?: string - readonly port?: number - readonly password?: string - readonly database?: Database.Options - readonly models?: ModelsDev.Options -} +export const ServerOptions = Schema.Struct({ + hostname: Schema.optional(Schema.String), + port: Schema.optional( + Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535)), + ), + password: Schema.optional(Schema.String), + database: Schema.optional(Database.Options), + models: Schema.optional(ModelsDev.Options), + observability: Schema.optional(Observability.Options), + fs: Schema.optional( + Schema.Struct({ + filewatcher: Schema.optional(Schema.Boolean), + fff: Schema.optional(Schema.Boolean), + }), + ), +}) +export type ServerOptions = typeof ServerOptions.Type diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index eb1cf607b7..f7f94581be 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -4,6 +4,7 @@ import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" 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/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" @@ -18,6 +19,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { WellKnown } from "@opencode-ai/core/wellknown" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Context, Effect, Layer, Option } from "effect" @@ -76,6 +78,8 @@ function makeRoutes( const replacements: LayerNode.Replacements = [ [Database.node, Database.layer(options.database)], [ModelsDev.node, ModelsDev.nodeWith(options.models)], + [Watcher.node, Watcher.nodeWith({ enabled: options.fs?.filewatcher })], + [FileSystemSearch.node, FileSystemSearch.nodeWith({ fff: options.fs?.fff })], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ] @@ -104,7 +108,7 @@ function makeRoutes( Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide(Observability.layer), + Layer.provide(Observability.layer(options.observability)), HttpRouter.provideRequest(requestServices), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer),