refactor(config): migrate config.ts root Info to Effect Schema (#23241)

This commit is contained in:
Kit Langton
2026-04-17 23:44:35 -04:00
committed by GitHub
parent c0eab9e442
commit 23f31475e7

View File

@@ -21,9 +21,10 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
import { ConfigAgent } from "./agent" import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command" import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter" import { ConfigFormatter } from "./formatter"
@@ -79,152 +80,182 @@ export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout export type Layout = ConfigLayout.Layout
export const Info = z // Schemas that still live at the zod layer (have .transform / .preprocess /
.object({ // .meta not expressible in current Effect Schema) get referenced via a
$schema: z.string().optional().describe("JSON schema reference for configuration validation"), // ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
logLevel: Log.Level.optional().describe("Log level"), // exact zod directly, preserving component $refs.
server: Server.optional().describe("Server configuration for opencode serve and web commands"), const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
command: z const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
.record(z.string(), ConfigCommand.Info.zod) const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"), const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
watcher: z
.object({ const InfoSchema = Schema.Struct({
ignore: z.array(z.string()).optional(), $schema: Schema.optional(Schema.String).annotate({
}) description: "JSON schema reference for configuration validation",
.optional(), }),
snapshot: z logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
.boolean() server: Schema.optional(ConfigServer.Server).annotate({
.optional() description: "Server configuration for opencode serve and web commands",
.describe( }),
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
), description: "Command configuration, see https://opencode.ai/docs/commands",
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. }),
plugin: ConfigPlugin.Spec.zod.array().optional(), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
share: z watcher: Schema.optional(
.enum(["manual", "auto", "disabled"]) Schema.Struct({
.optional() ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
.describe( }),
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", ),
), snapshot: Schema.optional(Schema.Boolean).annotate({
autoshare: z description:
.boolean() "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
.optional() }),
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
autoupdate: z plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
.union([z.boolean(), z.literal("notify")]) share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
.optional() description:
.describe( "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", }),
), autoshare: Schema.optional(Schema.Boolean).annotate({
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
enabled_providers: z }),
.array(z.string()) autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
.optional() description:
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), }),
small_model: ConfigModelID.zod disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
.describe("Small model to use for tasks like title generation in the format of provider/model") description: "Disable providers that are loaded automatically",
.optional(), }),
default_agent: z enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
.string() description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
.optional() }),
.describe( model: Schema.optional(ConfigModelID).annotate({
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", description: "Model to use in the format of provider/model, eg anthropic/claude-2",
), }),
username: z.string().optional().describe("Custom username to display in conversations instead of system username"), small_model: Schema.optional(ConfigModelID).annotate({
mode: z description: "Small model to use for tasks like title generation in the format of provider/model",
.object({ }),
build: ConfigAgent.Info.optional(), default_agent: Schema.optional(Schema.String).annotate({
plan: ConfigAgent.Info.optional(), description:
}) "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
.catchall(ConfigAgent.Info) }),
.optional() username: Schema.optional(Schema.String).annotate({
.describe("@deprecated Use `agent` field instead."), description: "Custom username to display in conversations instead of system username",
agent: z }),
.object({ mode: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
build: Schema.optional(AgentRef),
plan: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "@deprecated Use `agent` field instead." }),
agent: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
// primary // primary
plan: ConfigAgent.Info.optional(), plan: Schema.optional(AgentRef),
build: ConfigAgent.Info.optional(), build: Schema.optional(AgentRef),
// subagent // subagent
general: ConfigAgent.Info.optional(), general: Schema.optional(AgentRef),
explore: ConfigAgent.Info.optional(), explore: Schema.optional(AgentRef),
// specialized // specialized
title: ConfigAgent.Info.optional(), title: Schema.optional(AgentRef),
summary: ConfigAgent.Info.optional(), summary: Schema.optional(AgentRef),
compaction: ConfigAgent.Info.optional(), compaction: Schema.optional(AgentRef),
}) }),
.catchall(ConfigAgent.Info) [Schema.Record(Schema.String, AgentRef)],
.optional() ),
.describe("Agent configuration, see https://opencode.ai/docs/agents"), ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: z provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
.record(z.string(), ConfigProvider.Info.zod) description: "Custom provider configurations and model overrides",
.optional() }),
.describe("Custom provider configurations and model overrides"), mcp: Schema.optional(
mcp: z Schema.Record(
.record( Schema.String,
z.string(), Schema.Union([
z.union([ ConfigMCP.Info,
ConfigMCP.Info.zod, // Matches the legacy `{ enabled: false }` form used to disable a server.
z Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
.object({ ]),
enabled: z.boolean(), ),
}) ).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
.strict(), formatter: Schema.optional(ConfigFormatter.Info),
]), lsp: Schema.optional(ConfigLSP.Info),
) instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
.optional() description: "Additional instruction files or patterns to include",
.describe("MCP (Model Context Protocol) server configurations"), }),
formatter: ConfigFormatter.Info.zod.optional(), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
lsp: ConfigLSP.Info.zod.optional(), permission: Schema.optional(PermissionRef),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."), enterprise: Schema.optional(
permission: ConfigPermission.Info.optional(), Schema.Struct({
tools: z.record(z.string(), z.boolean()).optional(), url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
enterprise: z }),
.object({ ),
url: z.string().optional().describe("Enterprise URL"), compaction: Schema.optional(
}) Schema.Struct({
.optional(), auto: Schema.optional(Schema.Boolean).annotate({
compaction: z description: "Enable automatic compaction when context is full (default: true)",
.object({ }),
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), prune: Schema.optional(Schema.Boolean).annotate({
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), description: "Enable pruning of old tool outputs (default: true)",
reserved: z }),
.number() reserved: Schema.optional(NonNegativeInt).annotate({
.int() description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
.min(0) }),
.optional() }),
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), ),
}) experimental: Schema.optional(
.optional(), Schema.Struct({
experimental: z disable_paste_summary: Schema.optional(Schema.Boolean),
.object({ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
disable_paste_summary: z.boolean().optional(), openTelemetry: Schema.optional(Schema.Boolean).annotate({
batch_tool: z.boolean().optional().describe("Enable the batch tool"), description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
openTelemetry: z }),
.boolean() primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
.optional() description: "Tools that should only be available to primary agents.",
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), }),
primary_tools: z continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
.array(z.string()) description: "Continue the agent loop when a tool call is denied",
.optional() }),
.describe("Tools that should only be available to primary agents."), mcp_timeout: Schema.optional(PositiveInt).annotate({
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), description: "Timeout in milliseconds for model context protocol (MCP) requests",
mcp_timeout: z }),
.number() }),
.int() ),
.positive() })
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"), // Schema.Struct produces readonly types by default, but the service code
}) // below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
.optional(), // readonly recursively so callers get the same mutable shape zod inferred.
}) //
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
// fields like `ConfigPlugin.Options`. The local version gates on
// `extends object` so `unknown` passes through.
//
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
// shape (otherwise the general array branch widens it to an array).
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict() .strict()
.meta({ .meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
ref: "Config",
})
export type Info = z.output<typeof Info> & { export type Info = z.output<typeof Info> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together