mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 09:30:21 +00:00
248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
import { isDeepStrictEqual } from "node:util";
|
|
import chokidar from "chokidar";
|
|
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
|
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
|
import { isPlainObject } from "../utils.js";
|
|
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
|
|
|
|
export { buildGatewayReloadPlan };
|
|
export type { GatewayReloadPlan } from "./config-reload-plan.js";
|
|
|
|
export type GatewayReloadSettings = {
|
|
mode: GatewayReloadMode;
|
|
debounceMs: number;
|
|
};
|
|
|
|
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
|
|
mode: "hybrid",
|
|
debounceMs: 300,
|
|
};
|
|
const MISSING_CONFIG_RETRY_DELAY_MS = 150;
|
|
const MISSING_CONFIG_MAX_RETRIES = 2;
|
|
|
|
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
|
|
if (prev === next) {
|
|
return [];
|
|
}
|
|
if (isPlainObject(prev) && isPlainObject(next)) {
|
|
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
const paths: string[] = [];
|
|
for (const key of keys) {
|
|
const prevValue = prev[key];
|
|
const nextValue = next[key];
|
|
if (prevValue === undefined && nextValue === undefined) {
|
|
continue;
|
|
}
|
|
const childPrefix = prefix ? `${prefix}.${key}` : key;
|
|
const childPaths = diffConfigPaths(prevValue, nextValue, childPrefix);
|
|
if (childPaths.length > 0) {
|
|
paths.push(...childPaths);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
// Arrays can contain object entries (for example memory.qmd.paths/scope.rules);
|
|
// compare structurally so identical values are not reported as changed.
|
|
if (isDeepStrictEqual(prev, next)) {
|
|
return [];
|
|
}
|
|
}
|
|
return [prefix || "<root>"];
|
|
}
|
|
|
|
export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReloadSettings {
|
|
const rawMode = cfg.gateway?.reload?.mode;
|
|
const mode =
|
|
rawMode === "off" || rawMode === "restart" || rawMode === "hot" || rawMode === "hybrid"
|
|
? rawMode
|
|
: DEFAULT_RELOAD_SETTINGS.mode;
|
|
const debounceRaw = cfg.gateway?.reload?.debounceMs;
|
|
const debounceMs =
|
|
typeof debounceRaw === "number" && Number.isFinite(debounceRaw)
|
|
? Math.max(0, Math.floor(debounceRaw))
|
|
: DEFAULT_RELOAD_SETTINGS.debounceMs;
|
|
return { mode, debounceMs };
|
|
}
|
|
|
|
export type GatewayConfigReloader = {
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
export function startGatewayConfigReloader(opts: {
|
|
initialConfig: OpenClawConfig;
|
|
readSnapshot: () => Promise<ConfigFileSnapshot>;
|
|
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
|
|
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
|
|
log: {
|
|
info: (msg: string) => void;
|
|
warn: (msg: string) => void;
|
|
error: (msg: string) => void;
|
|
};
|
|
watchPath: string;
|
|
}): GatewayConfigReloader {
|
|
let currentConfig = opts.initialConfig;
|
|
let settings = resolveGatewayReloadSettings(currentConfig);
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let pending = false;
|
|
let running = false;
|
|
let stopped = false;
|
|
let restartQueued = false;
|
|
let missingConfigRetries = 0;
|
|
|
|
const scheduleAfter = (wait: number) => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
debounceTimer = setTimeout(() => {
|
|
void runReload();
|
|
}, wait);
|
|
};
|
|
const schedule = () => {
|
|
scheduleAfter(settings.debounceMs);
|
|
};
|
|
const queueRestart = (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
|
|
if (restartQueued) {
|
|
return;
|
|
}
|
|
restartQueued = true;
|
|
void (async () => {
|
|
try {
|
|
await opts.onRestart(plan, nextConfig);
|
|
} catch (err) {
|
|
// Restart checks can fail (for example unresolved SecretRefs). Keep the
|
|
// reloader alive and allow a future change to retry restart scheduling.
|
|
restartQueued = false;
|
|
opts.log.error(`config restart failed: ${String(err)}`);
|
|
}
|
|
})();
|
|
};
|
|
|
|
const handleMissingSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
|
|
if (snapshot.exists) {
|
|
missingConfigRetries = 0;
|
|
return false;
|
|
}
|
|
if (missingConfigRetries < MISSING_CONFIG_MAX_RETRIES) {
|
|
missingConfigRetries += 1;
|
|
opts.log.info(
|
|
`config reload retry (${missingConfigRetries}/${MISSING_CONFIG_MAX_RETRIES}): config file not found`,
|
|
);
|
|
scheduleAfter(MISSING_CONFIG_RETRY_DELAY_MS);
|
|
return true;
|
|
}
|
|
opts.log.warn("config reload skipped (config file not found)");
|
|
return true;
|
|
};
|
|
|
|
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
|
|
if (snapshot.valid) {
|
|
return false;
|
|
}
|
|
const issues = formatConfigIssueLines(snapshot.issues, "").join(", ");
|
|
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
|
|
return true;
|
|
};
|
|
|
|
const applySnapshot = async (nextConfig: OpenClawConfig) => {
|
|
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
|
|
currentConfig = nextConfig;
|
|
settings = resolveGatewayReloadSettings(nextConfig);
|
|
if (changedPaths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
|
|
const plan = buildGatewayReloadPlan(changedPaths);
|
|
if (settings.mode === "off") {
|
|
opts.log.info("config reload disabled (gateway.reload.mode=off)");
|
|
return;
|
|
}
|
|
if (settings.mode === "restart") {
|
|
queueRestart(plan, nextConfig);
|
|
return;
|
|
}
|
|
if (plan.restartGateway) {
|
|
if (settings.mode === "hot") {
|
|
opts.log.warn(
|
|
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
|
|
", ",
|
|
)})`,
|
|
);
|
|
return;
|
|
}
|
|
queueRestart(plan, nextConfig);
|
|
return;
|
|
}
|
|
|
|
await opts.onHotReload(plan, nextConfig);
|
|
};
|
|
|
|
const runReload = async () => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (running) {
|
|
pending = true;
|
|
return;
|
|
}
|
|
running = true;
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
try {
|
|
const snapshot = await opts.readSnapshot();
|
|
if (handleMissingSnapshot(snapshot)) {
|
|
return;
|
|
}
|
|
if (handleInvalidSnapshot(snapshot)) {
|
|
return;
|
|
}
|
|
await applySnapshot(snapshot.config);
|
|
} catch (err) {
|
|
opts.log.error(`config reload failed: ${String(err)}`);
|
|
} finally {
|
|
running = false;
|
|
if (pending) {
|
|
pending = false;
|
|
schedule();
|
|
}
|
|
}
|
|
};
|
|
|
|
const watcher = chokidar.watch(opts.watchPath, {
|
|
ignoreInitial: true,
|
|
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
usePolling: Boolean(process.env.VITEST),
|
|
});
|
|
|
|
watcher.on("add", schedule);
|
|
watcher.on("change", schedule);
|
|
watcher.on("unlink", schedule);
|
|
let watcherClosed = false;
|
|
watcher.on("error", (err) => {
|
|
if (watcherClosed) {
|
|
return;
|
|
}
|
|
watcherClosed = true;
|
|
opts.log.warn(`config watcher error: ${String(err)}`);
|
|
void watcher.close().catch(() => {});
|
|
});
|
|
|
|
return {
|
|
stop: async () => {
|
|
stopped = true;
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
debounceTimer = null;
|
|
watcherClosed = true;
|
|
await watcher.close().catch(() => {});
|
|
},
|
|
};
|
|
}
|