refactor(config): migrate config.ts root Info to Effect Schema (#23241)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user