import chokidar from "chokidar"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import type { ClawdbotConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js"; export type GatewayReloadSettings = { mode: GatewayReloadMode; debounceMs: number; }; export type ChannelKind = ChannelId; export type GatewayReloadPlan = { changedPaths: string[]; restartGateway: boolean; restartReasons: string[]; hotReasons: string[]; reloadHooks: boolean; restartGmailWatcher: boolean; restartBrowserControl: boolean; restartCron: boolean; restartHeartbeat: boolean; restartChannels: Set; noopPaths: string[]; }; type ReloadRule = { prefix: string; kind: "restart" | "hot" | "none"; actions?: ReloadAction[]; }; type ReloadAction = | "reload-hooks" | "restart-gmail-watcher" | "restart-browser-control" | "restart-cron" | "restart-heartbeat" | `restart-channel:${ChannelId}`; const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { mode: "hybrid", debounceMs: 300, }; const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, { prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] }, { prefix: "hooks", kind: "hot", actions: ["reload-hooks"] }, { prefix: "agents.defaults.heartbeat", kind: "hot", actions: ["restart-heartbeat"], }, { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "browser", kind: "hot", actions: ["restart-browser-control"], }, ]; const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ { prefix: "identity", kind: "none" }, { prefix: "wizard", kind: "none" }, { prefix: "logging", kind: "none" }, { prefix: "models", kind: "none" }, { prefix: "agents", kind: "none" }, { prefix: "tools", kind: "none" }, { prefix: "bindings", kind: "none" }, { prefix: "audio", kind: "none" }, { prefix: "agent", kind: "none" }, { prefix: "routing", kind: "none" }, { prefix: "messages", kind: "none" }, { prefix: "session", kind: "none" }, { prefix: "talk", kind: "none" }, { prefix: "skills", kind: "none" }, { prefix: "plugins", kind: "restart" }, { prefix: "ui", kind: "none" }, { prefix: "gateway", kind: "restart" }, { prefix: "bridge", kind: "restart" }, { prefix: "discovery", kind: "restart" }, { prefix: "canvasHost", kind: "restart" }, ]; let cachedReloadRules: ReloadRule[] | null = null; function listReloadRules(): ReloadRule[] { if (cachedReloadRules) return cachedReloadRules; // Channel docking: plugins contribute hot reload/no-op prefixes here. const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [ ...(plugin.reload?.configPrefixes ?? []).map( (prefix): ReloadRule => ({ prefix, kind: "hot", actions: [`restart-channel:${plugin.id}` as ReloadAction], }), ), ...(plugin.reload?.noopPrefixes ?? []).map( (prefix): ReloadRule => ({ prefix, kind: "none", }), ), ]); const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL]; cachedReloadRules = rules; return rules; } function matchRule(path: string): ReloadRule | null { for (const rule of listReloadRules()) { if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) return rule; } return null; } function isPlainObject(value: unknown): value is Record { return Boolean( value && typeof value === "object" && !Array.isArray(value) && Object.prototype.toString.call(value) === "[object Object]", ); } 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)) { if (prev.length === next.length && prev.every((val, idx) => val === next[idx])) { return []; } } return [prefix || ""]; } export function resolveGatewayReloadSettings(cfg: ClawdbotConfig): 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 function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan { const plan: GatewayReloadPlan = { changedPaths, restartGateway: false, restartReasons: [], hotReasons: [], reloadHooks: false, restartGmailWatcher: false, restartBrowserControl: false, restartCron: false, restartHeartbeat: false, restartChannels: new Set(), noopPaths: [], }; const applyAction = (action: ReloadAction) => { if (action.startsWith("restart-channel:")) { const channel = action.slice("restart-channel:".length) as ChannelId; plan.restartChannels.add(channel); return; } switch (action) { case "reload-hooks": plan.reloadHooks = true; break; case "restart-gmail-watcher": plan.restartGmailWatcher = true; break; case "restart-browser-control": plan.restartBrowserControl = true; break; case "restart-cron": plan.restartCron = true; break; case "restart-heartbeat": plan.restartHeartbeat = true; break; default: break; } }; for (const path of changedPaths) { const rule = matchRule(path); if (!rule) { plan.restartGateway = true; plan.restartReasons.push(path); continue; } if (rule.kind === "restart") { plan.restartGateway = true; plan.restartReasons.push(path); continue; } if (rule.kind === "none") { plan.noopPaths.push(path); continue; } plan.hotReasons.push(path); for (const action of rule.actions ?? []) { applyAction(action); } } if (plan.restartGmailWatcher) { plan.reloadHooks = true; } return plan; } export type GatewayConfigReloader = { stop: () => Promise; }; export function startGatewayConfigReloader(opts: { initialConfig: ClawdbotConfig; readSnapshot: () => Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: ClawdbotConfig) => Promise; onRestart: (plan: GatewayReloadPlan, nextConfig: ClawdbotConfig) => 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 | null = null; let pending = false; let running = false; let stopped = false; let restartQueued = false; const schedule = () => { if (stopped) return; if (debounceTimer) clearTimeout(debounceTimer); const wait = settings.debounceMs; debounceTimer = setTimeout(() => { void runReload(); }, wait); }; 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 (!snapshot.valid) { const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", "); opts.log.warn(`config reload skipped (invalid config): ${issues}`); return; } const nextConfig = snapshot.config; 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") { if (!restartQueued) { restartQueued = true; opts.onRestart(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; } if (!restartQueued) { restartQueued = true; opts.onRestart(plan, nextConfig); } return; } await opts.onHotReload(plan, nextConfig); } 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(() => {}); }, }; }