From 89a4f2a34ecca2413588115cd19e9aef209691d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 00:13:59 +0100 Subject: [PATCH] refactor(config): centralize runtime config state --- src/cli/update-cli/update-command.ts | 7 +- src/commands/models/load-config.ts | 10 +- src/config/config.ts | 4 + src/config/io.ts | 237 +++++++++++++++------------ src/config/materialize.ts | 85 ++++++++++ src/config/mutate.ts | 82 +++++++++ src/config/types.openclaw.ts | 22 ++- src/config/validation.ts | 130 +-------------- src/gateway/config-reload.ts | 21 ++- src/gateway/server-model-catalog.ts | 8 +- src/gateway/server.impl.ts | 3 +- src/infra/restart.ts | 4 +- src/logging/diagnostic.ts | 9 +- 13 files changed, 366 insertions(+), 256 deletions(-) create mode 100644 src/config/materialize.ts create mode 100644 src/config/mutate.ts diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index ef43bd3922e..4d767750ee4 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -11,6 +11,7 @@ import { writeConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; +import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { @@ -1023,8 +1024,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { postUpdateConfigSnapshot = { ...configSnapshot, parsed: next, - resolved: next, - config: next, + sourceConfig: asResolvedSourceConfig(next), + resolved: asResolvedSourceConfig(next), + runtimeConfig: asRuntimeConfig(next), + config: asRuntimeConfig(next), }; if (!opts.json) { defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); diff --git a/src/commands/models/load-config.ts b/src/commands/models/load-config.ts index 854cd5240da..c74e6ed9039 100644 --- a/src/commands/models/load-config.ts +++ b/src/commands/models/load-config.ts @@ -1,8 +1,8 @@ import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import { - loadConfig, - readConfigFileSnapshotForWrite, + getRuntimeConfig, + readSourceConfigSnapshotForWrite, setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../config/config.js"; @@ -16,9 +16,9 @@ export type LoadedModelsConfig = { async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise { try { - const { snapshot } = await readConfigFileSnapshotForWrite(); + const { snapshot } = await readSourceConfigSnapshotForWrite(); if (snapshot.valid) { - return snapshot.resolved; + return snapshot.sourceConfig; } } catch { // Fall back to runtime-loaded config if source snapshot cannot be read. @@ -30,7 +30,7 @@ export async function loadModelsConfigWithSource(params: { commandName: string; runtime?: RuntimeEnv; }): Promise { - const runtimeConfig = loadConfig(); + const runtimeConfig = getRuntimeConfig(); const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig); const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: runtimeConfig, diff --git a/src/config/config.ts b/src/config/config.ts index 08ac82bd955..295423d0202 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -4,6 +4,7 @@ export { clearRuntimeConfigSnapshot, registerConfigWriteListener, createConfigIO, + getRuntimeConfig, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, projectConfigOntoRuntimeSourceSnapshot, @@ -12,6 +13,8 @@ export { parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, + readSourceConfigSnapshot, + readSourceConfigSnapshotForWrite, resetConfigRuntimeState, resolveConfigSnapshotHash, setRuntimeConfigSnapshotRefreshHandler, @@ -20,6 +23,7 @@ export { } from "./io.js"; export type { ConfigWriteNotification } from "./io.js"; export { migrateLegacyConfig } from "./legacy-migrate.js"; +export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; export * from "./paths.js"; export * from "./runtime-overrides.js"; export * from "./types.js"; diff --git a/src/config/io.ts b/src/config/io.ts index 1139169e3a3..25ba96850c9 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -17,17 +17,6 @@ import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { maintainConfigBackups } from "./backup-rotation.js"; -import { - applyCompactionDefaults, - applyContextPruningDefaults, - applyAgentDefaults, - applyLoggingDefaults, - applyMessageDefaults, - applyModelDefaults, - applySessionDefaults, - applyTalkConfigNormalization, - applyTalkApiKey, -} from "./defaults.js"; import { restoreEnvVarRefs } from "./env-preserve.js"; import { type EnvSubstitutionWarning, @@ -43,9 +32,12 @@ import { } from "./includes.js"; import { migrateLegacyConfig } from "./legacy-migrate.js"; import { findLegacyConfigIssues } from "./legacy.js"; +import { + asResolvedSourceConfig, + asRuntimeConfig, + materializeRuntimeConfig, +} from "./materialize.js"; import { applyMergePatch } from "./merge-patch.js"; -import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; -import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; @@ -249,6 +241,8 @@ export type ConfigWriteNotification = { configPath: string; sourceConfig: OpenClawConfig; runtimeConfig: OpenClawConfig; + persistedHash: string; + writtenAtMs: number; }; export class ConfigRuntimeRefreshError extends Error { @@ -1637,6 +1631,38 @@ type ReadConfigFileSnapshotInternalResult = { envSnapshotForRestore?: Record; }; +function createConfigFileSnapshot(params: { + path: string; + exists: boolean; + raw: string | null; + parsed: unknown; + sourceConfig: OpenClawConfig; + valid: boolean; + runtimeConfig: OpenClawConfig; + hash?: string; + issues: ConfigFileSnapshot["issues"]; + warnings: ConfigFileSnapshot["warnings"]; + legacyIssues: LegacyConfigIssue[]; +}): ConfigFileSnapshot { + const sourceConfig = asResolvedSourceConfig(params.sourceConfig); + const runtimeConfig = asRuntimeConfig(params.runtimeConfig); + return { + path: params.path, + exists: params.exists, + raw: params.raw, + parsed: params.parsed, + sourceConfig, + resolved: sourceConfig, + valid: params.valid, + runtimeConfig, + config: runtimeConfig, + hash: params.hash, + issues: params.issues, + warnings: params.warnings, + legacyIssues: params.legacyIssues, + }; +} + async function finalizeReadConfigSnapshotInternalResult( deps: Required, result: ReadConfigFileSnapshotInternalResult, @@ -1700,17 +1726,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { warnOnConfigMiskeys(effectiveConfigRaw, deps.logger); if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) { observeLoadConfigSnapshot({ - path: configPath, - exists: true, - raw: effectiveRaw, - parsed: effectiveParsed, - resolved: {}, - valid: true, - config: {}, - hash, - issues: [], - warnings: [], - legacyIssues: legacyResolution.sourceLegacyIssues, + ...createConfigFileSnapshot({ + path: configPath, + exists: true, + raw: effectiveRaw, + parsed: effectiveParsed, + sourceConfig: {}, + valid: true, + runtimeConfig: {}, + hash, + issues: [], + warnings: [], + legacyIssues: legacyResolution.sourceLegacyIssues, + }), }); return {}; } @@ -1724,17 +1752,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env }); if (!validated.ok) { observeLoadConfigSnapshot({ - path: configPath, - exists: true, - raw: effectiveRaw, - parsed: effectiveParsed, - resolved: coerceConfig(effectiveConfigRaw), - valid: false, - config: coerceConfig(effectiveConfigRaw), - hash, - issues: validated.issues, - warnings: validated.warnings, - legacyIssues: legacyResolution.sourceLegacyIssues, + ...createConfigFileSnapshot({ + path: configPath, + exists: true, + raw: effectiveRaw, + parsed: effectiveParsed, + sourceConfig: coerceConfig(effectiveConfigRaw), + valid: false, + runtimeConfig: coerceConfig(effectiveConfigRaw), + hash, + issues: validated.issues, + warnings: validated.warnings, + legacyIssues: legacyResolution.sourceLegacyIssues, + }), }); const details = validated.issues .map( @@ -1761,31 +1791,21 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { deps.logger.warn(`Config warnings:\\n${details}`); } warnIfConfigFromFuture(validated.config, deps.logger); - const cfg = applyTalkConfigNormalization( - applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), - ), - ), - ), - ), - ); - normalizeConfigPaths(cfg); - normalizeExecSafeBinProfilesInConfig(cfg); + const cfg = materializeRuntimeConfig(validated.config, "load"); observeLoadConfigSnapshot({ - path: configPath, - exists: true, - raw: effectiveRaw, - parsed: effectiveParsed, - resolved: coerceConfig(effectiveConfigRaw), - valid: true, - config: cfg, - hash, - issues: [], - warnings: validated.warnings, - legacyIssues: legacyResolution.sourceLegacyIssues, + ...createConfigFileSnapshot({ + path: configPath, + exists: true, + raw: effectiveRaw, + parsed: effectiveParsed, + sourceConfig: coerceConfig(effectiveConfigRaw), + valid: true, + runtimeConfig: cfg, + hash, + issues: [], + warnings: validated.warnings, + legacyIssues: legacyResolution.sourceLegacyIssues, + }), }); const duplicates = findDuplicateAgentDirs(cfg, { @@ -1865,32 +1885,22 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const hash = hashConfigRaw(null); - const config = applyTalkApiKey( - applyTalkConfigNormalization( - applyModelDefaults( - applyCompactionDefaults( - applyContextPruningDefaults( - applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), - ), - ), - ), - ), - ); + const config = materializeRuntimeConfig({}, "missing"); const legacyIssues: LegacyConfigIssue[] = []; return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: false, raw: null, parsed: {}, - resolved: {}, + sourceConfig: {}, valid: true, - config, + runtimeConfig: config, hash, issues: [], warnings: [], legacyIssues, - }, + }), }); } @@ -1900,19 +1910,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const parsedRes = parseConfigJson5(raw, deps.json5); if (!parsedRes.ok) { return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: true, raw, parsed: {}, - resolved: {}, + sourceConfig: {}, valid: false, - config: {}, + runtimeConfig: {}, hash: rawHash, issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], warnings: [], legacyIssues: [], - }, + }), }); } @@ -1936,19 +1946,21 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ? err.message : `Include resolution failed: ${String(err)}`; return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: true, raw: effectiveRaw, parsed: effectiveParsed, - resolved: coerceConfig(effectiveParsed), + sourceConfig: coerceConfig(parsedRes.parsed), + // Keep the recovered root file payload here when read healing kicked in. + sourceConfig: coerceConfig(effectiveParsed), valid: false, - config: coerceConfig(effectiveParsed), + runtimeConfig: coerceConfig(effectiveParsed), hash, issues: [{ path: "", message }], warnings: [], legacyIssues: [], - }, + }), }); } @@ -1969,51 +1981,40 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env }); if (!validated.ok) { return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: true, raw: effectiveRaw, parsed: effectiveParsed, - resolved: coerceConfig(effectiveConfigRaw), + sourceConfig: coerceConfig(effectiveConfigRaw), valid: false, - config: coerceConfig(effectiveConfigRaw), + runtimeConfig: coerceConfig(effectiveConfigRaw), hash, issues: validated.issues, warnings: [...validated.warnings, ...envVarWarnings], legacyIssues: legacyResolution.sourceLegacyIssues, - }, + }), }); } warnIfConfigFromFuture(validated.config, deps.logger); - const snapshotConfig = normalizeConfigPaths( - applyTalkApiKey( - applyTalkConfigNormalization( - applyModelDefaults( - applyAgentDefaults( - applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), - ), - ), - ), - ), - ); - normalizeExecSafeBinProfilesInConfig(snapshotConfig); + const snapshotConfig = materializeRuntimeConfig(validated.config, "snapshot"); return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: true, raw: effectiveRaw, parsed: effectiveParsed, // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults) // for config set/unset operations (issue #6070) - resolved: coerceConfig(effectiveConfigRaw), + sourceConfig: coerceConfig(effectiveConfigRaw), valid: true, - config: snapshotConfig, + runtimeConfig: snapshotConfig, hash, issues: [], warnings: [...validated.warnings, ...envVarWarnings], legacyIssues: legacyResolution.sourceLegacyIssues, - }, + }), envSnapshotForRestore: readResolution.envSnapshotForRestore, }); } catch (err) { @@ -2037,19 +2038,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { message = `read failed: ${String(err)}`; } return await finalizeReadConfigSnapshotInternalResult(deps, { - snapshot: { + snapshot: createConfigFileSnapshot({ path: configPath, exists: true, raw: null, parsed: {}, - resolved: {}, + sourceConfig: {}, valid: false, - config: {}, + runtimeConfig: {}, hash: hashConfigRaw(null), issues: [{ path: "", message }], warnings: [], legacyIssues: [], - }, + }), }); } } @@ -2070,7 +2071,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }; } - async function writeConfigFile(cfg: OpenClawConfig, options: ConfigWriteOptions = {}) { + async function writeConfigFile( + cfg: OpenClawConfig, + options: ConfigWriteOptions = {}, + ): Promise<{ persistedHash: string }> { clearConfigCache(); let persistCandidate: unknown = cfg; const { snapshot } = await readConfigFileSnapshotInternal(); @@ -2333,7 +2337,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { undefined, await deps.fs.promises.stat(configPath).catch(() => null), ); - return; + return { persistedHash: nextHash }; } await deps.fs.promises.unlink(tmp).catch(() => { // best-effort @@ -2347,6 +2351,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { undefined, await deps.fs.promises.stat(configPath).catch(() => null), ); + return { persistedHash: nextHash }; } catch (err) { await appendWriteAudit("failed", err); throw err; @@ -2491,6 +2496,10 @@ export function loadConfig(): OpenClawConfig { return runtimeConfigSnapshot ?? config; } +export function getRuntimeConfig(): OpenClawConfig { + return loadConfig(); +} + export async function readBestEffortConfig(): Promise { const snapshot = await readConfigFileSnapshot(); return snapshot.valid ? loadConfig() : snapshot.config; @@ -2500,10 +2509,18 @@ export async function readConfigFileSnapshot(): Promise { return await createConfigIO().readConfigFileSnapshot(); } +export async function readSourceConfigSnapshot(): Promise { + return await readConfigFileSnapshot(); +} + export async function readConfigFileSnapshotForWrite(): Promise { return await createConfigIO().readConfigFileSnapshotForWrite(); } +export async function readSourceConfigSnapshotForWrite(): Promise { + return await readConfigFileSnapshotForWrite(); +} + export async function writeConfigFile( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, @@ -2518,7 +2535,7 @@ export async function writeConfigFile( } const sameConfigPath = options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath; - await io.writeConfigFile(nextCfg, { + const writeResult = await io.writeConfigFile(nextCfg, { envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined, unsetPaths: options.unsetPaths, }); @@ -2530,6 +2547,8 @@ export async function writeConfigFile( configPath: io.configPath, sourceConfig: nextCfg, runtimeConfig: runtimeConfigSnapshot, + persistedHash: writeResult.persistedHash, + writtenAtMs: Date.now(), }); }; // Keep the last-known-good runtime snapshot active until the specialized refresh path diff --git a/src/config/materialize.ts b/src/config/materialize.ts new file mode 100644 index 00000000000..0578218608d --- /dev/null +++ b/src/config/materialize.ts @@ -0,0 +1,85 @@ +import { + applyCompactionDefaults, + applyContextPruningDefaults, + applyAgentDefaults, + applyLoggingDefaults, + applyMessageDefaults, + applyModelDefaults, + applySessionDefaults, + applyTalkApiKey, + applyTalkConfigNormalization, +} from "./defaults.js"; +import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; +import { normalizeConfigPaths } from "./normalize-paths.js"; +import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./types.js"; + +export type ConfigMaterializationMode = "load" | "missing" | "snapshot"; + +type MaterializationProfile = { + includeTalkApiKey: boolean; + includeCompactionDefaults: boolean; + includeContextPruningDefaults: boolean; + includeLoggingDefaults: boolean; + normalizePaths: boolean; +}; + +const MATERIALIZATION_PROFILES: Record = { + load: { + includeTalkApiKey: false, + includeCompactionDefaults: true, + includeContextPruningDefaults: true, + includeLoggingDefaults: true, + normalizePaths: true, + }, + missing: { + includeTalkApiKey: true, + includeCompactionDefaults: true, + includeContextPruningDefaults: true, + includeLoggingDefaults: false, + normalizePaths: false, + }, + snapshot: { + includeTalkApiKey: true, + includeCompactionDefaults: false, + includeContextPruningDefaults: false, + includeLoggingDefaults: true, + normalizePaths: true, + }, +}; + +export function asResolvedSourceConfig(config: OpenClawConfig): ResolvedSourceConfig { + return config as ResolvedSourceConfig; +} + +export function asRuntimeConfig(config: OpenClawConfig): RuntimeConfig { + return config as RuntimeConfig; +} + +export function materializeRuntimeConfig( + config: OpenClawConfig, + mode: ConfigMaterializationMode, +): RuntimeConfig { + const profile = MATERIALIZATION_PROFILES[mode]; + let next = applyMessageDefaults(config); + if (profile.includeLoggingDefaults) { + next = applyLoggingDefaults(next); + } + next = applySessionDefaults(next); + next = applyAgentDefaults(next); + if (profile.includeContextPruningDefaults) { + next = applyContextPruningDefaults(next); + } + if (profile.includeCompactionDefaults) { + next = applyCompactionDefaults(next); + } + next = applyModelDefaults(next); + next = applyTalkConfigNormalization(next); + if (profile.includeTalkApiKey) { + next = applyTalkApiKey(next); + } + if (profile.normalizePaths) { + normalizeConfigPaths(next); + } + normalizeExecSafeBinProfilesInConfig(next); + return asRuntimeConfig(next); +} diff --git a/src/config/mutate.ts b/src/config/mutate.ts new file mode 100644 index 00000000000..fb029b0bd75 --- /dev/null +++ b/src/config/mutate.ts @@ -0,0 +1,82 @@ +import { + readConfigFileSnapshotForWrite, + resolveConfigSnapshotHash, + writeConfigFile, + type ConfigWriteOptions, +} from "./io.js"; +import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; + +export type ConfigMutationBase = "runtime" | "source"; + +export class ConfigMutationConflictError extends Error { + readonly currentHash: string | null; + + constructor(message: string, params: { currentHash: string | null }) { + super(message); + this.name = "ConfigMutationConflictError"; + this.currentHash = params.currentHash; + } +} + +export type ConfigReplaceResult = { + path: string; + previousHash: string | null; + snapshot: ConfigFileSnapshot; + nextConfig: OpenClawConfig; +}; + +function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null { + const currentHash = resolveConfigSnapshotHash(snapshot) ?? null; + if (expectedHash !== undefined && expectedHash !== currentHash) { + throw new ConfigMutationConflictError("config changed since last load", { + currentHash, + }); + } + return currentHash; +} + +export async function replaceConfigFile(params: { + nextConfig: OpenClawConfig; + baseHash?: string; + writeOptions?: ConfigWriteOptions; +}): Promise { + const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + const previousHash = assertBaseHashMatches(snapshot, params.baseHash); + await writeConfigFile(params.nextConfig, { + ...writeOptions, + ...params.writeOptions, + }); + return { + path: snapshot.path, + previousHash, + snapshot, + nextConfig: params.nextConfig, + }; +} + +export async function mutateConfigFile(params: { + base?: ConfigMutationBase; + baseHash?: string; + writeOptions?: ConfigWriteOptions; + mutate: ( + draft: OpenClawConfig, + context: { snapshot: ConfigFileSnapshot; previousHash: string | null }, + ) => Promise | T | void; +}): Promise { + const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + const previousHash = assertBaseHashMatches(snapshot, params.baseHash); + const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig; + const draft = structuredClone(baseConfig) as OpenClawConfig; + const result = (await params.mutate(draft, { snapshot, previousHash })) as T | undefined; + await writeConfigFile(draft, { + ...writeOptions, + ...params.writeOptions, + }); + return { + path: snapshot.path, + previousHash, + snapshot, + nextConfig: draft, + result, + }; +} diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 9997ecc6f84..2b74a315c18 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -124,6 +124,16 @@ export type OpenClawConfig = { mcp?: McpConfig; }; +declare const openClawConfigStateBrand: unique symbol; + +type BrandedConfigState = OpenClawConfig & { + readonly [openClawConfigStateBrand]?: TState; +}; + +export type SourceConfig = BrandedConfigState<"source">; +export type ResolvedSourceConfig = BrandedConfigState<"resolved-source">; +export type RuntimeConfig = BrandedConfigState<"runtime">; + export type ConfigValidationIssue = { path: string; message: string; @@ -141,14 +151,22 @@ export type ConfigFileSnapshot = { exists: boolean; raw: string | null; parsed: unknown; + /** + * Config authored on disk after $include resolution and ${ENV} substitution, + * but BEFORE runtime defaults are applied. + */ + sourceConfig: ResolvedSourceConfig; /** * Config after $include resolution and ${ENV} substitution, but BEFORE runtime * defaults are applied. Use this for config set/unset operations to avoid * leaking runtime defaults into the written config file. */ - resolved: OpenClawConfig; + resolved: ResolvedSourceConfig; valid: boolean; - config: OpenClawConfig; + /** Runtime-shaped config used by in-process readers. */ + runtimeConfig: RuntimeConfig; + /** @deprecated Prefer runtimeConfig. */ + config: RuntimeConfig; hash?: string; issues: ConfigValidationIssue[]; warnings: ConfigValidationIssue[]; diff --git a/src/config/validation.ts b/src/config/validation.ts index 3e2f14e6430..3731bee053e 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -21,14 +21,13 @@ import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; -import { getBundledChannelConfigSchemaMap } from "./bundled-channel-config-runtime.js"; import { collectChannelSchemaMetadata } from "./channel-config-metadata.js"; -import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { listLegacyWebSearchConfigPaths, normalizeLegacyWebSearchConfig, } from "./legacy-web-search.js"; import { findLegacyConfigIssues } from "./legacy.js"; +import { materializeRuntimeConfig } from "./materialize.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -41,7 +40,6 @@ type AllowedValuesCollection = { incomplete: boolean; hasValues: boolean; }; -type JsonSchemaNode = Record; const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i; @@ -66,128 +64,7 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string { return segments.join("."); } -function toJsonSchemaNode(value: unknown): JsonSchemaNode | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as JsonSchemaNode; -} - -function getSchemaCombinatorBranches(node: JsonSchemaNode): JsonSchemaNode[] { - const keys = ["anyOf", "oneOf", "allOf"] as const; - const branches: JsonSchemaNode[] = []; - for (const key of keys) { - const value = node[key]; - if (!Array.isArray(value)) { - continue; - } - for (const entry of value) { - const child = toJsonSchemaNode(entry); - if (child) { - branches.push(child); - } - } - } - return branches; -} - -function collectAllowedValuesFromSchemaNode(node: JsonSchemaNode): AllowedValuesCollection { - if (Object.prototype.hasOwnProperty.call(node, "const")) { - return { values: [node.const], incomplete: false, hasValues: true }; - } - - const enumValues = node.enum; - if (Array.isArray(enumValues)) { - return { values: enumValues, incomplete: false, hasValues: enumValues.length > 0 }; - } - - if (node.type === "boolean") { - return { values: [true, false], incomplete: false, hasValues: true }; - } - - const branches = getSchemaCombinatorBranches(node); - if (branches.length === 0) { - return { values: [], incomplete: true, hasValues: false }; - } - - const collected: unknown[] = []; - for (const branch of branches) { - const result = collectAllowedValuesFromSchemaNode(branch); - if (result.incomplete || !result.hasValues) { - return { values: [], incomplete: true, hasValues: false }; - } - collected.push(...result.values); - } - - return { values: collected, incomplete: false, hasValues: collected.length > 0 }; -} - -function advanceSchemaNodes(node: JsonSchemaNode, segment: ConfigPathSegment): JsonSchemaNode[] { - const branches = getSchemaCombinatorBranches(node); - if (branches.length > 0) { - return branches.flatMap((branch) => advanceSchemaNodes(branch, segment)); - } - - if (typeof segment === "number") { - const items = toJsonSchemaNode(node.items); - return items ? [items] : []; - } - - const properties = toJsonSchemaNode(node.properties); - const propertyNode = properties ? toJsonSchemaNode(properties[segment]) : null; - if (propertyNode) { - return [propertyNode]; - } - - const additionalProperties = toJsonSchemaNode(node.additionalProperties); - return additionalProperties ? [additionalProperties] : []; -} - -function collectAllowedValuesFromSchemaPath( - root: JsonSchemaNode, - path: readonly ConfigPathSegment[], -): AllowedValuesCollection { - let currentNodes = [root]; - for (const segment of path) { - currentNodes = currentNodes.flatMap((node) => advanceSchemaNodes(node, segment)); - if (currentNodes.length === 0) { - return { values: [], incomplete: false, hasValues: false }; - } - } - - const collected: unknown[] = []; - for (const node of currentNodes) { - const result = collectAllowedValuesFromSchemaNode(node); - if (result.incomplete || !result.hasValues) { - return { values: [], incomplete: true, hasValues: false }; - } - collected.push(...result.values); - } - - return { values: collected, incomplete: false, hasValues: collected.length > 0 }; -} - -function collectAllowedValuesFromConfigPath( - path: readonly ConfigPathSegment[], -): AllowedValuesCollection { - if (path[0] === "channels" && typeof path[1] === "string") { - const channelSchema = getBundledChannelConfigSchemaMap().get(path[1]); - const schemaRoot = toJsonSchemaNode(channelSchema?.schema); - if (schemaRoot) { - return collectAllowedValuesFromSchemaPath(schemaRoot, path.slice(2)); - } - } - - return { values: [], incomplete: false, hasValues: false }; -} - function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection { - const path = toConfigPathSegments(record.path); - const schemaValues = collectAllowedValuesFromConfigPath(path); - if (schemaValues.hasValues && !schemaValues.incomplete) { - return schemaValues; - } - const message = typeof record.message === "string" ? record.message : ""; const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE); if (expectedMatch?.[1]) { @@ -195,6 +72,9 @@ function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): Allowe return { values, incomplete: false, hasValues: values.length > 0 }; } + // Custom Zod issues usually come from superRefine rules, not enum schemas. + // Avoid bundled channel schema lookup here: it can pull in runtime plugin + // metadata during validation error formatting and hang on some bootstrap paths. return { values: [], incomplete: false, hasValues: false }; } @@ -438,7 +318,7 @@ export function validateConfigObject( } return { ok: true, - config: applyModelDefaults(applyAgentDefaults(applySessionDefaults(result.config))), + config: materializeRuntimeConfig(result.config, "snapshot"), }; } diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index c966d40cff0..6cfc838ba16 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -24,6 +24,7 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { }; const MISSING_CONFIG_RETRY_DELAY_MS = 150; const MISSING_CONFIG_MAX_RETRIES = 2; +const SELF_WRITE_WATCHER_SUPPRESSION_MS = 350; export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] { if (prev === next) { @@ -96,7 +97,8 @@ export function startGatewayConfigReloader(opts: { let restartQueued = false; let missingConfigRetries = 0; let pendingInProcessConfig: OpenClawConfig | null = null; - let suppressedWatchEventsRemaining = 0; + let lastAppliedWriteHash: string | null = null; + let ignoreWatcherEventsUntilMs = 0; const scheduleAfter = (wait: number) => { if (stopped) { @@ -211,6 +213,14 @@ export function startGatewayConfigReloader(opts: { return; } const snapshot = await opts.readSnapshot(); + if ( + lastAppliedWriteHash && + typeof snapshot.hash === "string" && + snapshot.hash === lastAppliedWriteHash + ) { + lastAppliedWriteHash = null; + return; + } if (handleMissingSnapshot(snapshot)) { return; } @@ -236,8 +246,7 @@ export function startGatewayConfigReloader(opts: { }); const scheduleFromWatcher = () => { - if (suppressedWatchEventsRemaining > 0) { - suppressedWatchEventsRemaining -= 1; + if (Date.now() < ignoreWatcherEventsUntilMs) { return; } schedule(); @@ -249,7 +258,11 @@ export function startGatewayConfigReloader(opts: { return; } pendingInProcessConfig = event.runtimeConfig; - suppressedWatchEventsRemaining = 2; + lastAppliedWriteHash = event.persistedHash; + ignoreWatcherEventsUntilMs = Math.max( + ignoreWatcherEventsUntilMs, + event.writtenAtMs + SELF_WRITE_WATCHER_SUPPRESSION_MS, + ); scheduleAfter(0); }) ?? (() => {}); diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index 7f72fbc4e3d..00d622beec9 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -3,7 +3,7 @@ import { type ModelCatalogEntry, resetModelCatalogCacheForTest, } from "../agents/model-catalog.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; export type GatewayModelChoice = ModelCatalogEntry; @@ -14,6 +14,8 @@ export function __resetModelCatalogCacheForTest() { resetModelCatalogCacheForTest(); } -export async function loadGatewayModelCatalog(): Promise { - return await loadModelCatalog({ config: loadConfig() }); +export async function loadGatewayModelCatalog(params?: { + getConfig?: () => ReturnType; +}): Promise { + return await loadModelCatalog({ config: (params?.getConfig ?? getRuntimeConfig)() }); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 711054edb8b..9c18f3c12be 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -13,6 +13,7 @@ import { type ConfigFileSnapshot, type OpenClawConfig, applyConfigOverrides, + getRuntimeConfig, isNixMode, loadConfig, migrateLegacyConfig, @@ -518,7 +519,7 @@ export async function startGatewayServer( } const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { - startDiagnosticHeartbeat(); + startDiagnosticHeartbeat(undefined, { getConfig: getRuntimeConfig }); } setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(cfgAtStart) }); setPreRestartDeferralCheck( diff --git a/src/infra/restart.ts b/src/infra/restart.ts index f671df382e2..f466f50e142 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -477,7 +477,7 @@ export function scheduleGatewaySigusr1Restart(opts?: { emitGatewayRestart(); return; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); deferGatewayRestartUntilIdle({ getPendingCount: pendingCheck, maxWaitMs: cfg.gateway?.reload?.deferralTimeoutMs, diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index 2fb2f2f6ed6..5ec6f11c10c 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { emitDiagnosticEvent } from "../infra/diagnostic-events.js"; import { @@ -330,7 +330,10 @@ export function logActiveRuns() { let heartbeatInterval: NodeJS.Timeout | null = null; -export function startDiagnosticHeartbeat(config?: OpenClawConfig) { +export function startDiagnosticHeartbeat( + config?: OpenClawConfig, + opts?: { getConfig?: () => OpenClawConfig }, +) { if (heartbeatInterval) { return; } @@ -338,7 +341,7 @@ export function startDiagnosticHeartbeat(config?: OpenClawConfig) { let heartbeatConfig = config; if (!heartbeatConfig) { try { - heartbeatConfig = loadConfig(); + heartbeatConfig = (opts?.getConfig ?? getRuntimeConfig)(); } catch { heartbeatConfig = undefined; }