mirror of https://github.com/openclaw/openclaw.git
refactor(config): centralize runtime config state
This commit is contained in:
parent
b888741462
commit
89a4f2a34e
|
|
@ -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<void> {
|
|||
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}.`));
|
||||
|
|
|
|||
|
|
@ -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<OpenClawConfig> {
|
||||
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<LoadedModelsConfig> {
|
||||
const runtimeConfig = loadConfig();
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig);
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: runtimeConfig,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
237
src/config/io.ts
237
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<string, string | undefined>;
|
||||
};
|
||||
|
||||
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<ConfigIoDeps>,
|
||||
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<OpenClawConfig> {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
return snapshot.valid ? loadConfig() : snapshot.config;
|
||||
|
|
@ -2500,10 +2509,18 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||
return await createConfigIO().readConfigFileSnapshot();
|
||||
}
|
||||
|
||||
export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
return await readConfigFileSnapshot();
|
||||
}
|
||||
|
||||
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
||||
return await createConfigIO().readConfigFileSnapshotForWrite();
|
||||
}
|
||||
|
||||
export async function readSourceConfigSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ConfigMaterializationMode, MaterializationProfile> = {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<ConfigReplaceResult> {
|
||||
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<T = void>(params: {
|
||||
base?: ConfigMutationBase;
|
||||
baseHash?: string;
|
||||
writeOptions?: ConfigWriteOptions;
|
||||
mutate: (
|
||||
draft: OpenClawConfig,
|
||||
context: { snapshot: ConfigFileSnapshot; previousHash: string | null },
|
||||
) => Promise<T | void> | T | void;
|
||||
}): Promise<ConfigReplaceResult & { result: T | undefined }> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -124,6 +124,16 @@ export type OpenClawConfig = {
|
|||
mcp?: McpConfig;
|
||||
};
|
||||
|
||||
declare const openClawConfigStateBrand: unique symbol;
|
||||
|
||||
type BrandedConfigState<TState extends string> = 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[];
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}) ?? (() => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GatewayModelChoice[]> {
|
||||
return await loadModelCatalog({ config: loadConfig() });
|
||||
export async function loadGatewayModelCatalog(params?: {
|
||||
getConfig?: () => ReturnType<typeof getRuntimeConfig>;
|
||||
}): Promise<GatewayModelChoice[]> {
|
||||
return await loadModelCatalog({ config: (params?.getConfig ?? getRuntimeConfig)() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue