fix(core): keep lenient env grammar for config-backed layers
This commit is contained in:
parent
cb32c42e6f
commit
ca318328ea
5 changed files with 66 additions and 35 deletions
|
|
@ -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 { Config, Context, Effect, Layer, Option } from "effect"
|
import { Config, Context, Effect, Layer } from "effect"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { truthy } from "../flag/flag"
|
import { truthy, truthyConfig } 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"
|
||||||
|
|
@ -61,12 +61,14 @@ export function path() {
|
||||||
|
|
||||||
// Placement is resolved through Effect Config when the layer is built, not at
|
// 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.
|
// module import, so tests and tooling can override it with a ConfigProvider.
|
||||||
|
// truthyConfig shares the `truthy` grammar so this layer and the V1 `path()`
|
||||||
|
// helper always agree on the same environment.
|
||||||
const configuredLayer = Layer.unwrap(
|
const configuredLayer = Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = yield* Config.option(Config.string("OPENCODE_DB"))
|
const file = yield* Config.string("OPENCODE_DB").pipe(Config.withDefault(undefined))
|
||||||
const disableChannelDb = yield* Config.boolean("OPENCODE_DISABLE_CHANNEL_DB").pipe(Config.withDefault(false))
|
const disableChannelDb = yield* truthyConfig("OPENCODE_DISABLE_CHANNEL_DB")
|
||||||
return layerFromPath(resolvePath({ file: Option.getOrUndefined(file), disableChannelDb }))
|
return layerFromPath(resolvePath({ file, disableChannelDb }))
|
||||||
}).pipe(Effect.orDie),
|
}),
|
||||||
)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] })
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
|
|
||||||
export function truthy(key: string) {
|
function truthyValue(value: string) {
|
||||||
const value = process.env[key]?.toLowerCase()
|
const lower = value.toLowerCase()
|
||||||
return value === "true" || value === "1"
|
return lower === "true" || lower === "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function truthy(key: string) {
|
||||||
|
const value = process.env[key]
|
||||||
|
return value !== undefined && truthyValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `truthy` grammar read through Effect Config, so layer builds can be
|
||||||
|
* steered by a ConfigProvider. Missing or malformed values are false, exactly
|
||||||
|
* like `truthy`; strict boolean decoding would turn a stray env value into a
|
||||||
|
* startup defect.
|
||||||
|
*/
|
||||||
|
export const truthyConfig = (name: string) =>
|
||||||
|
Config.string(name).pipe(Config.map(truthyValue), Config.withDefault(false))
|
||||||
|
|
||||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||||
const fff = process.env["OPENCODE_DISABLE_FFF"]
|
const fff = process.env["OPENCODE_DISABLE_FFF"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
export * as WebSearchTool from "./websearch"
|
export * as WebSearchTool from "./websearch"
|
||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Config, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect"
|
import { Config, Context, Duration, Effect, Layer, 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 { truthyConfig } 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"
|
||||||
|
|
@ -68,30 +69,36 @@ 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") {}
|
||||||
|
|
||||||
const flag = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Isolates the retained product environment contract from the generic tool
|
* Isolates the retained product environment contract from the generic tool
|
||||||
* implementation. Reads through Effect `Config` when the layer is built, so
|
* implementation. Reads through Effect `Config` when the layer is built, so
|
||||||
* tests can override values with a `ConfigProvider` instead of mutating
|
* tests can override values with a `ConfigProvider` instead of mutating
|
||||||
* `process.env`. Malformed values fail the layer instead of silently
|
* `process.env`. Parsing keeps the legacy lenient grammar: missing or
|
||||||
* disabling a provider the user asked for.
|
* malformed values disable rather than fail, because this layer builds at
|
||||||
|
* Location boot where a defect would surface as opaque session failures.
|
||||||
*/
|
*/
|
||||||
export const defaultConfigLayer = Layer.effect(
|
export const defaultConfigLayer = Layer.effect(
|
||||||
ConfigService,
|
ConfigService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const provider = yield* Config.option(Config.literals(["exa", "parallel"], "OPENCODE_WEBSEARCH_PROVIDER"))
|
const env = yield* Config.all({
|
||||||
const exaApiKey = yield* Config.option(Config.redacted("EXA_API_KEY"))
|
provider: Config.string("OPENCODE_WEBSEARCH_PROVIDER").pipe(Config.withDefault(undefined)),
|
||||||
const parallelApiKey = yield* Config.option(Config.redacted("PARALLEL_API_KEY"))
|
experimental: truthyConfig("OPENCODE_EXPERIMENTAL"),
|
||||||
|
enableExa: truthyConfig("OPENCODE_ENABLE_EXA"),
|
||||||
|
experimentalExa: truthyConfig("OPENCODE_EXPERIMENTAL_EXA"),
|
||||||
|
enableParallel: truthyConfig("OPENCODE_ENABLE_PARALLEL"),
|
||||||
|
experimentalParallel: truthyConfig("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||||
|
exaApiKey: Config.string("EXA_API_KEY").pipe(Config.withDefault(undefined)),
|
||||||
|
parallelApiKey: Config.string("PARALLEL_API_KEY").pipe(Config.withDefault(undefined)),
|
||||||
|
})
|
||||||
|
const provider = env.provider !== undefined && Schema.is(Provider)(env.provider) ? env.provider : undefined
|
||||||
|
if (env.provider !== undefined && provider === undefined)
|
||||||
|
yield* Effect.logWarning("ignoring invalid OPENCODE_WEBSEARCH_PROVIDER", { value: env.provider })
|
||||||
return ConfigService.of({
|
return ConfigService.of({
|
||||||
provider: Option.getOrUndefined(provider),
|
provider,
|
||||||
enableExa:
|
enableExa: env.experimental || env.enableExa || env.experimentalExa,
|
||||||
(yield* flag("OPENCODE_EXPERIMENTAL")) ||
|
enableParallel: env.enableParallel || env.experimentalParallel,
|
||||||
(yield* flag("OPENCODE_ENABLE_EXA")) ||
|
exaApiKey: env.exaApiKey,
|
||||||
(yield* flag("OPENCODE_EXPERIMENTAL_EXA")),
|
parallelApiKey: env.parallelApiKey,
|
||||||
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)
|
).pipe(Layer.orDie)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { beforeEach, describe, expect, test } from "bun:test"
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Exit, Layer, Schema } from "effect"
|
import { ConfigProvider, Effect, 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"
|
||||||
|
|
@ -48,12 +48,9 @@ describe("WebSearchTool provider selection", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const readDefaultConfig = (env: Record<string, string>) =>
|
const readDefaultConfig = (env: Record<string, string>) =>
|
||||||
Effect.gen(function* () {
|
Effect.provide(
|
||||||
return yield* WebSearchTool.ConfigService
|
WebSearchTool.ConfigService,
|
||||||
}).pipe(
|
WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))),
|
||||||
Effect.provide(
|
|
||||||
WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
describe("WebSearchTool default config", () => {
|
describe("WebSearchTool default config", () => {
|
||||||
|
|
@ -87,9 +84,17 @@ describe("WebSearchTool default config", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("fails on an invalid provider instead of silently ignoring it", async () => {
|
test("keeps the legacy lenient truthiness grammar", async () => {
|
||||||
const exit = await Effect.runPromiseExit(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" }))
|
const decoded = await Effect.runPromise(
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
readDefaultConfig({ OPENCODE_ENABLE_EXA: "TRUE", OPENCODE_ENABLE_PARALLEL: "banana" }),
|
||||||
|
)
|
||||||
|
expect(decoded.enableExa).toBe(true)
|
||||||
|
expect(decoded.enableParallel).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores an invalid provider instead of failing the Location layer", async () => {
|
||||||
|
const decoded = await Effect.runPromise(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" }))
|
||||||
|
expect(decoded.provider).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB
|
||||||
export const exerciseDatabasePath =
|
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`)
|
||||||
|
// Must run before the first Effect Config read anywhere in the process: the
|
||||||
|
// default ConfigProvider snapshots process.env on first use, so a Config read
|
||||||
|
// during an earlier module import would freeze the wrong database path.
|
||||||
process.env.OPENCODE_DB = exerciseDatabasePath
|
process.env.OPENCODE_DB = exerciseDatabasePath
|
||||||
|
|
||||||
export const original = {
|
export const original = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue