refactor(core): resolve database and websearch config through Effect Config

This commit is contained in:
Kit Langton 2026-07-02 00:25:31 -04:00
commit cb32c42e6f
7 changed files with 148 additions and 43 deletions

View file

@ -2,9 +2,9 @@ export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite" import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect" import { Config, Context, Effect, Layer, Option } from "effect"
import { Global } from "../global" import { Global } from "../global"
import { Flag } from "../flag/flag" import { truthy } from "../flag/flag"
import { isAbsolute, join } from "path" import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration" import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version" import { InstallationChannel } from "../installation/version"
@ -40,18 +40,33 @@ export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename }))) return layer.pipe(Layer.provide(sqliteLayer({ filename })))
} }
export function path() { /** One placement rule shared by the config-backed layer and the V1 `path()` helper. */
if (Flag.OPENCODE_DB) { export function resolvePath(input: { readonly file: string | undefined; readonly disableChannelDb: boolean }) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB if (input.file) {
return join(Global.Path.data, Flag.OPENCODE_DB) if (input.file === ":memory:" || isAbsolute(input.file)) return input.file
return join(Global.Path.data, input.file)
} }
if ( if (["latest", "beta", "prod"].includes(InstallationChannel) || input.disableChannelDb)
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db") return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
} }
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) /** V1 compatibility helper; reads the process environment at call time. */
export function path() {
return resolvePath({
file: process.env.OPENCODE_DB,
disableChannelDb: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
})
}
// Placement is resolved through Effect Config when the layer is built, not at
// module import, so tests and tooling can override it with a ConfigProvider.
const configuredLayer = Layer.unwrap(
Effect.gen(function* () {
const file = yield* Config.option(Config.string("OPENCODE_DB"))
const disableChannelDb = yield* Config.boolean("OPENCODE_DISABLE_CHANNEL_DB").pipe(Config.withDefault(false))
return layerFromPath(resolvePath({ file: Option.getOrUndefined(file), disableChannelDb }))
}).pipe(Effect.orDie),
)
export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] })

View file

@ -44,7 +44,6 @@ export const Flag = {
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),

View file

@ -1,11 +1,10 @@
export * as WebSearchTool from "./websearch" export * as WebSearchTool from "./websearch"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect" import { Config, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform" import { LayerNodePlatform } from "../effect/app-node-platform"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
@ -69,19 +68,33 @@ export interface Config {
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {} export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
/** Isolates the retained product environment contract from the generic tool implementation. */ const flag = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({ /**
provider: * Isolates the retained product environment contract from the generic tool
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" * implementation. Reads through Effect `Config` when the layer is built, so
? process.env.OPENCODE_WEBSEARCH_PROVIDER * tests can override values with a `ConfigProvider` instead of mutating
: undefined, * `process.env`. Malformed values fail the layer instead of silently
enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"), * disabling a provider the user asked for.
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), */
exaApiKey: process.env.EXA_API_KEY, export const defaultConfigLayer = Layer.effect(
parallelApiKey: process.env.PARALLEL_API_KEY, ConfigService,
Effect.gen(function* () {
const provider = yield* Config.option(Config.literals(["exa", "parallel"], "OPENCODE_WEBSEARCH_PROVIDER"))
const exaApiKey = yield* Config.option(Config.redacted("EXA_API_KEY"))
const parallelApiKey = yield* Config.option(Config.redacted("PARALLEL_API_KEY"))
return ConfigService.of({
provider: Option.getOrUndefined(provider),
enableExa:
(yield* flag("OPENCODE_EXPERIMENTAL")) ||
(yield* flag("OPENCODE_ENABLE_EXA")) ||
(yield* flag("OPENCODE_EXPERIMENTAL_EXA")),
enableParallel: (yield* flag("OPENCODE_ENABLE_PARALLEL")) || (yield* flag("OPENCODE_EXPERIMENTAL_PARALLEL")),
exaApiKey: Option.getOrUndefined(Option.map(exaApiKey, Redacted.value)),
parallelApiKey: Option.getOrUndefined(Option.map(parallelApiKey, Redacted.value)),
})
}), }),
) ).pipe(Layer.orDie)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })

View file

@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { ConfigProvider, Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { Global } from "@opencode-ai/core/global"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
describe("Database placement", () => {
test("resolves explicit files, relative names, and the channel default", () => {
expect(Database.resolvePath({ file: ":memory:", disableChannelDb: false })).toBe(":memory:")
expect(Database.resolvePath({ file: "/tmp/explicit.db", disableChannelDb: false })).toBe("/tmp/explicit.db")
expect(Database.resolvePath({ file: "relative.db", disableChannelDb: false })).toBe(
path.join(Global.Path.data, "relative.db"),
)
expect(Database.resolvePath({ file: undefined, disableChannelDb: true })).toBe(
path.join(Global.Path.data, "opencode.db"),
)
})
test("reads placement from the active ConfigProvider when the layer is built", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "config-seam.sqlite")
// The preload sets OPENCODE_DB=":memory:" in the process environment, so a
// database appearing at this path proves the layer reads through the
// replaced ConfigProvider rather than the environment snapshot.
await Effect.runPromise(
Layer.build(
LayerNode.compile(LayerNode.group([Database.node])).pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_DB: file }))),
),
).pipe(Effect.scoped, Effect.asVoid),
)
expect(await Bun.file(file).exists()).toBe(true)
})
})

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test" import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { ConfigProvider, Effect, Exit, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@ -47,6 +47,52 @@ describe("WebSearchTool provider selection", () => {
}) })
}) })
const readDefaultConfig = (env: Record<string, string>) =>
Effect.gen(function* () {
return yield* WebSearchTool.ConfigService
}).pipe(
Effect.provide(
WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))),
),
)
describe("WebSearchTool default config", () => {
test("decodes an empty environment to defaults", async () => {
expect(await Effect.runPromise(readDefaultConfig({}))).toEqual({
provider: undefined,
enableExa: false,
enableParallel: false,
exaApiKey: undefined,
parallelApiKey: undefined,
})
})
test("decodes provider, truthy flags, and credentials from the active ConfigProvider", async () => {
expect(
await Effect.runPromise(
readDefaultConfig({
OPENCODE_WEBSEARCH_PROVIDER: "parallel",
OPENCODE_ENABLE_EXA: "1",
OPENCODE_EXPERIMENTAL_PARALLEL: "true",
EXA_API_KEY: "exa-key",
PARALLEL_API_KEY: "parallel-key",
}),
),
).toEqual({
provider: "parallel",
enableExa: true,
enableParallel: true,
exaApiKey: "exa-key",
parallelApiKey: "parallel-key",
})
})
test("fails on an invalid provider instead of silently ignoring it", async () => {
const exit = await Effect.runPromiseExit(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" }))
expect(Exit.isFailure(exit)).toBe(true)
})
})
describe("WebSearchTool MCP response parser", () => { describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => { test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results") expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")

View file

@ -19,7 +19,6 @@ export const exerciseDatabasePath =
process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? process.env.OPENCODE_HTTPAPI_EXERCISE_DB ??
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`)
process.env.OPENCODE_DB = exerciseDatabasePath process.env.OPENCODE_DB = exerciseDatabasePath
Flag.OPENCODE_DB = exerciseDatabasePath
export const original = { export const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,

View file

@ -1,15 +1,20 @@
import { expect, test } from "bun:test" import { afterAll, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises" import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
import type { OpenCodeEvent } from "../src" import type { OpenCodeEvent } from "../src"
// The database layer resolves OPENCODE_DB through Effect Config, and the
// default ConfigProvider snapshots the process environment on first use, so
// database placement is process-wide. Point every embedded host in this file
// at one shared temporary database before anything builds a runtime.
const databaseDirectory = await mkdtemp(join(tmpdir(), "opencode-embedded-db-"))
process.env.OPENCODE_DB = join(databaseDirectory, "opencode.sqlite")
afterAll(() => rm(databaseDirectory, { recursive: true, force: true }))
test("embedded client uses the real router and handlers", async () => { test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
@ -99,15 +104,12 @@ test("embedded client uses the real router and handlers", async () => {
}) })
await Effect.runPromise(Effect.scoped(program)) await Effect.runPromise(Effect.scoped(program))
} finally { } finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}) })
test("Location-owned runner events reach the ready global client", async () => { test("Location-owned runner events reach the ready global client", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
@ -138,15 +140,12 @@ test("Location-owned runner events reach the ready global client", async () => {
}) })
await Effect.runPromise(Effect.scoped(program)) await Effect.runPromise(Effect.scoped(program))
} finally { } finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}, 10_000) }, 10_000)
test("independent embedded hosts do not share live notifications", async () => { test("independent embedded hosts do not share live notifications", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
@ -181,15 +180,12 @@ test("independent embedded hosts do not share live notifications", async () => {
}) })
await Effect.runPromise(Effect.scoped(program)) await Effect.runPromise(Effect.scoped(program))
} finally { } finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}, 10_000) }, 10_000)
test("embedded client is available as a Layer service", async () => { test("embedded client is available as a Layer service", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Location, OpenCode, Session } = await import("../src") const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
@ -206,7 +202,6 @@ test("embedded client is available as a Layer service", async () => {
expect(created.id).toBe(sessionID) expect(created.id).toBe(sessionID)
} finally { } finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
}) })