From e9d7ac8e84fe56effd8e90f8fd2923e4cf3e6ab9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 19:52:24 +0000 Subject: [PATCH] feat(gateway): add config hot reload --- CHANGELOG.md | 2 + docs/configuration.md | 42 ++++ docs/gateway.md | 4 + src/config/config.ts | 23 ++ src/config/schema.ts | 6 + src/gateway/config-reload.test.ts | 63 ++++++ src/gateway/config-reload.ts | 365 ++++++++++++++++++++++++++++++ src/gateway/server-http.ts | 5 +- src/gateway/server.ts | 267 ++++++++++++++++++---- 9 files changed, 726 insertions(+), 51 deletions(-) create mode 100644 src/gateway/config-reload.test.ts create mode 100644 src/gateway/config-reload.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e083c1b..54f7911e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Features - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. +- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. @@ -45,6 +46,7 @@ - Skills: document Discord `sendMessage` media attachments and `to` format clarification. - Skills: add tmux skill + interactive coding guidance in coding-agent. - Gateway: document port configuration + multi-instance isolation. +- Gateway: document config hot reload + reload matrix. - Onboarding/Config: add protocol notes for wizard + schema RPC. - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. diff --git a/docs/configuration.md b/docs/configuration.md index 65a0d3c6a..b607c04c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -696,6 +696,48 @@ Remote client defaults (CLI): } ``` +### `gateway.reload` (Config hot reload) + +The Gateway watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`) and applies changes automatically. + +Modes: +- `hybrid` (default): hot-apply safe changes; restart the Gateway for critical changes. +- `hot`: only apply hot-safe changes; log when a restart is required. +- `restart`: restart the Gateway on any config change. +- `off`: disable hot reload. + +```json5 +{ + gateway: { + reload: { + mode: "hybrid", + debounceMs: 300 + } + } +} +``` + +#### Hot reload matrix (files + impact) + +Files watched: +- `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`) + +Hot-applied (no full gateway restart): +- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted) +- `browser` (browser control server restart) +- `cron` (cron service restart + concurrency update) +- `agent.heartbeat` (heartbeat runner restart) +- `web` (WhatsApp web provider restart) +- `telegram`, `discord`, `signal`, `imessage` (provider restarts) +- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads) + +Requires full Gateway restart: +- `gateway` (port/bind/auth/control UI/tailscale) +- `bridge` +- `discovery` +- `canvasHost` +- Any unknown/unsupported config path (defaults to restart for safety) + ### Multi-instance isolation To run multiple gateways on one host, isolate per-instance state + config and use unique ports: diff --git a/docs/gateway.md b/docs/gateway.md index a00e6d087..2761b6e56 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -22,6 +22,10 @@ pnpm clawdis gateway --force # dev loop (auto-reload on TS changes): pnpm gateway:watch ``` +- Config hot reload watches `~/.clawdis/clawdis.json` (or `CLAWDIS_CONFIG_PATH`). + - Default mode: `gateway.reload.mode="hybrid"` (hot-apply safe changes, restart on critical). + - Hot reload uses in-process restart via **SIGUSR1** when needed. + - Disable with `gateway.reload.mode="off"`. - Binds WebSocket control plane to `127.0.0.1:` (default 18789). - The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex. - Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. diff --git a/src/config/config.ts b/src/config/config.ts index d798598de..fc6676c5f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -447,6 +447,15 @@ export type GatewayRemoteConfig = { password?: string; }; +export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid"; + +export type GatewayReloadConfig = { + /** Reload strategy for config changes (default: hybrid). */ + mode?: GatewayReloadMode; + /** Debounce window for config reloads (ms). Default: 300. */ + debounceMs?: number; +}; + export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; @@ -464,6 +473,7 @@ export type GatewayConfig = { auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; + reload?: GatewayReloadConfig; }; export type SkillConfig = { @@ -1308,6 +1318,19 @@ export const ClawdisSchema = z.object({ password: z.string().optional(), }) .optional(), + reload: z + .object({ + mode: z + .union([ + z.literal("off"), + z.literal("restart"), + z.literal("hot"), + z.literal("hybrid"), + ]) + .optional(), + debounceMs: z.number().int().min(0).optional(), + }) + .optional(), }) .optional(), skills: z diff --git a/src/config/schema.ts b/src/config/schema.ts index e6ffeef47..208ecbf5b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -82,6 +82,8 @@ const FIELD_LABELS: Record = { "gateway.auth.token": "Gateway Token", "gateway.auth.password": "Gateway Password", "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", "agent.workspace": "Workspace", "agent.model": "Default Model", "ui.seamColor": "Accent Color", @@ -100,6 +102,10 @@ const FIELD_HELP: Record = { "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /clawdis).", + "gateway.reload.mode": + 'Hot reload strategy for config changes ("hybrid" recommended).', + "gateway.reload.debounceMs": + "Debounce window (ms) before applying config changes.", }; const FIELD_PLACEHOLDERS: Record = { diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts new file mode 100644 index 000000000..62df6d3c4 --- /dev/null +++ b/src/gateway/config-reload.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + buildGatewayReloadPlan, + diffConfigPaths, + resolveGatewayReloadSettings, +} from "./config-reload.js"; + +describe("diffConfigPaths", () => { + it("captures nested config changes", () => { + const prev = { hooks: { gmail: { account: "a" } } }; + const next = { hooks: { gmail: { account: "b" } } }; + const paths = diffConfigPaths(prev, next); + expect(paths).toContain("hooks.gmail.account"); + }); + + it("captures array changes", () => { + const prev = { routing: { groupChat: { mentionPatterns: ["a"] } } }; + const next = { routing: { groupChat: { mentionPatterns: ["b"] } } }; + const paths = diffConfigPaths(prev, next); + expect(paths).toContain("routing.groupChat.mentionPatterns"); + }); +}); + +describe("buildGatewayReloadPlan", () => { + it("marks gateway changes as restart required", () => { + const plan = buildGatewayReloadPlan(["gateway.port"]); + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toContain("gateway.port"); + }); + + it("restarts the Gmail watcher for hooks.gmail changes", () => { + const plan = buildGatewayReloadPlan(["hooks.gmail.account"]); + expect(plan.restartGateway).toBe(false); + expect(plan.restartGmailWatcher).toBe(true); + expect(plan.reloadHooks).toBe(true); + }); + + it("restarts providers for web/telegram changes", () => { + const plan = buildGatewayReloadPlan(["web.enabled", "telegram.botToken"]); + expect(plan.restartGateway).toBe(false); + expect(plan.restartProviders.has("whatsapp")).toBe(true); + expect(plan.restartProviders.has("telegram")).toBe(true); + }); + + it("treats gateway.remote as no-op", () => { + const plan = buildGatewayReloadPlan(["gateway.remote.url"]); + expect(plan.restartGateway).toBe(false); + expect(plan.noopPaths).toContain("gateway.remote.url"); + }); + + it("defaults unknown paths to restart", () => { + const plan = buildGatewayReloadPlan(["unknownField"]); + expect(plan.restartGateway).toBe(true); + }); +}); + +describe("resolveGatewayReloadSettings", () => { + it("uses defaults when unset", () => { + const settings = resolveGatewayReloadSettings({}); + expect(settings.mode).toBe("hybrid"); + expect(settings.debounceMs).toBe(300); + }); +}); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts new file mode 100644 index 000000000..bcc4fc826 --- /dev/null +++ b/src/gateway/config-reload.ts @@ -0,0 +1,365 @@ +import chokidar from "chokidar"; + +import type { + ClawdisConfig, + ConfigFileSnapshot, + GatewayReloadMode, +} from "../config/config.js"; + +export type GatewayReloadSettings = { + mode: GatewayReloadMode; + debounceMs: number; +}; + +export type ProviderKind = + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; + +export type GatewayReloadPlan = { + changedPaths: string[]; + restartGateway: boolean; + restartReasons: string[]; + hotReasons: string[]; + reloadHooks: boolean; + restartGmailWatcher: boolean; + restartBrowserControl: boolean; + restartCron: boolean; + restartHeartbeat: boolean; + restartProviders: 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-provider:whatsapp" + | "restart-provider:telegram" + | "restart-provider:discord" + | "restart-provider:signal" + | "restart-provider:imessage"; + +const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { + mode: "hybrid", + debounceMs: 300, +}; + +const 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: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, + { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, + { + prefix: "browser", + kind: "hot", + actions: ["restart-browser-control"], + }, + { prefix: "web", kind: "hot", actions: ["restart-provider:whatsapp"] }, + { prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] }, + { prefix: "discord", kind: "hot", actions: ["restart-provider:discord"] }, + { prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] }, + { prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] }, + { prefix: "identity", kind: "none" }, + { prefix: "wizard", kind: "none" }, + { prefix: "logging", kind: "none" }, + { prefix: "models", kind: "none" }, + { prefix: "agent", kind: "none" }, + { prefix: "routing", kind: "none" }, + { prefix: "messages", kind: "none" }, + { prefix: "session", kind: "none" }, + { prefix: "whatsapp", kind: "none" }, + { prefix: "talk", kind: "none" }, + { prefix: "skills", kind: "none" }, + { prefix: "ui", kind: "none" }, + { prefix: "gateway", kind: "restart" }, + { prefix: "bridge", kind: "restart" }, + { prefix: "discovery", kind: "restart" }, + { prefix: "canvasHost", kind: "restart" }, +]; + +function matchRule(path: string): ReloadRule | null { + for (const rule of RELOAD_RULES) { + 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: ClawdisConfig, +): 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, + restartProviders: new Set(), + noopPaths: [], + }; + + const applyAction = (action: ReloadAction) => { + 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; + case "restart-provider:whatsapp": + plan.restartProviders.add("whatsapp"); + break; + case "restart-provider:telegram": + plan.restartProviders.add("telegram"); + break; + case "restart-provider:discord": + plan.restartProviders.add("discord"); + break; + case "restart-provider:signal": + plan.restartProviders.add("signal"); + break; + case "restart-provider:imessage": + plan.restartProviders.add("imessage"); + 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: ClawdisConfig; + readSnapshot: () => Promise; + onHotReload: ( + plan: GatewayReloadPlan, + nextConfig: ClawdisConfig, + ) => Promise; + onRestart: (plan: GatewayReloadPlan, nextConfig: ClawdisConfig) => 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; + + 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 }, + }); + + watcher.on("add", schedule); + watcher.on("change", schedule); + watcher.on("unlink", schedule); + watcher.on("error", (err) => { + opts.log.warn(`config watcher error: ${String(err)}`); + }); + + return { + stop: async () => { + stopped = true; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = null; + await watcher.close().catch(() => {}); + }, + }; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 9979d00c2..9fbdfe114 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -58,14 +58,14 @@ export type HooksRequestHandler = ( export function createHooksRequestHandler( opts: { - hooksConfig: HooksConfigResolved | null; + getHooksConfig: () => HooksConfigResolved | null; bindHost: string; port: number; logHooks: SubsystemLogger; } & HookDispatchers, ): HooksRequestHandler { const { - hooksConfig, + getHooksConfig, bindHost, port, logHooks, @@ -73,6 +73,7 @@ export function createHooksRequestHandler( dispatchWakeHook, } = opts; return async (req, res) => { + const hooksConfig = getHooksConfig(); if (!hooksConfig) return false; const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); const basePath = hooksConfig.basePath; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index f462edead..225a65f27 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -96,6 +96,11 @@ import { import { createBridgeHandlers } from "./server-bridge.js"; import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js"; +import { + startGatewayConfigReloader, + type GatewayReloadPlan, + type ProviderKind, +} from "./config-reload.js"; import { createAgentEventHandler, createChatRunState } from "./server-chat.js"; import { DEDUPE_MAX, @@ -133,6 +138,7 @@ const logProviders = log.child("providers"); const logBrowser = log.child("browser"); const logHealth = log.child("health"); const logCron = log.child("cron"); +const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logWsControl = log.child("ws"); const logWhatsApp = logProviders.child("whatsapp"); @@ -408,7 +414,7 @@ export async function startGatewayServer( password, allowTailscale, }; - const hooksConfig = resolveHooksConfig(cfgAtStart); + let hooksConfig = resolveHooksConfig(cfgAtStart); const canvasHostEnabled = process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && cfgAtStart.canvasHost?.enabled !== false; @@ -554,7 +560,7 @@ export async function startGatewayServer( } const handleHooksRequest = createHooksRequestHandler({ - hooksConfig, + getHooksConfig: () => hooksConfig, bindHost, port, logHooks, @@ -650,57 +656,61 @@ export async function startGatewayServer( setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1); - const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store); const cronLogger = getChildLogger({ module: "cron", - storePath: cronStorePath, }); const deps = createDefaultDeps(); - const cronEnabled = - process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled !== false; - const cron = new CronService({ - storePath: cronStorePath, - cronEnabled, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: async ({ job, message }) => { - const cfg = loadConfig(); - return await runCronIsolatedAgentTurn({ - cfg, - deps, - job, - message, - sessionKey: `cron:${job.id}`, - lane: "cron", - }); - }, - log: cronLogger, - onEvent: (evt) => { - broadcast("cron", evt, { dropIfSlow: true }); - if (evt.action === "finished") { - const logPath = resolveCronRunLogPath({ - storePath: cronStorePath, - jobId: evt.jobId, + const buildCronService = (cfg: ReturnType) => { + const storePath = resolveCronStorePath(cfg.cron?.store); + const cronEnabled = + process.env.CLAWDIS_SKIP_CRON !== "1" && cfg.cron?.enabled !== false; + const cron = new CronService({ + storePath, + cronEnabled, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: async ({ job, message }) => { + const runtimeConfig = loadConfig(); + return await runCronIsolatedAgentTurn({ + cfg: runtimeConfig, + deps, + job, + message, + sessionKey: `cron:${job.id}`, + lane: "cron", }); - void appendCronRunLog(logPath, { - ts: Date.now(), - jobId: evt.jobId, - action: "finished", - status: evt.status, - error: evt.error, - summary: evt.summary, - runAtMs: evt.runAtMs, - durationMs: evt.durationMs, - nextRunAtMs: evt.nextRunAtMs, - }).catch((err) => { - cronLogger.warn( - { err: String(err), logPath }, - "cron: run log append failed", - ); - }); - } - }, - }); + }, + log: getChildLogger({ module: "cron", storePath }), + onEvent: (evt) => { + broadcast("cron", evt, { dropIfSlow: true }); + if (evt.action === "finished") { + const logPath = resolveCronRunLogPath({ + storePath, + jobId: evt.jobId, + }); + void appendCronRunLog(logPath, { + ts: Date.now(), + jobId: evt.jobId, + action: "finished", + status: evt.status, + error: evt.error, + summary: evt.summary, + runAtMs: evt.runAtMs, + durationMs: evt.durationMs, + nextRunAtMs: evt.nextRunAtMs, + }).catch((err) => { + cronLogger.warn( + { err: String(err), logPath }, + "cron: run log append failed", + ); + }); + } + }, + }); + return { cron, storePath, cronEnabled }; + }; + + let { cron, storePath: cronStorePath } = buildCronService(cfgAtStart); const providerManager = createProviderManager({ loadConfig, @@ -719,6 +729,10 @@ export async function startGatewayServer( getRuntimeSnapshot, startProviders, startWhatsAppProvider, + startTelegramProvider, + startDiscordProvider, + startSignalProvider, + startIMessageProvider, stopWhatsAppProvider, stopTelegramProvider, stopDiscordProvider, @@ -1122,7 +1136,7 @@ export async function startGatewayServer( broadcast("heartbeat", evt, { dropIfSlow: true }); }); - const heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart }); + let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart }); void cron .start() @@ -1585,6 +1599,160 @@ export async function startGatewayServer( logProviders.info("skipping provider start (CLAWDIS_SKIP_PROVIDERS=1)"); } + const applyHotReload = async ( + plan: GatewayReloadPlan, + nextConfig: ReturnType, + ) => { + if (plan.reloadHooks) { + try { + hooksConfig = resolveHooksConfig(nextConfig); + } catch (err) { + logHooks.warn(`hooks config reload failed: ${String(err)}`); + } + } + + if (plan.restartHeartbeat) { + heartbeatRunner.stop(); + heartbeatRunner = startHeartbeatRunner({ cfg: nextConfig }); + } + + if (plan.restartCron) { + cron.stop(); + const next = buildCronService(nextConfig); + cron = next.cron; + cronStorePath = next.storePath; + void cron + .start() + .catch((err) => logCron.error(`failed to start: ${String(err)}`)); + } + + if (plan.restartBrowserControl) { + if (browserControl) { + await browserControl.stop().catch(() => {}); + } + try { + browserControl = await startBrowserControlServerIfEnabled(); + } catch (err) { + logBrowser.error(`server failed to start: ${String(err)}`); + } + } + + if (plan.restartGmailWatcher) { + await stopGmailWatcher().catch(() => {}); + if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") { + try { + const gmailResult = await startGmailWatcher(nextConfig); + if (gmailResult.started) { + logHooks.info("gmail watcher started"); + } else if ( + gmailResult.reason && + gmailResult.reason !== "hooks not enabled" && + gmailResult.reason !== "no gmail account configured" + ) { + logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`); + } + } catch (err) { + logHooks.error(`gmail watcher failed to start: ${String(err)}`); + } + } else { + logHooks.info( + "skipping gmail watcher restart (CLAWDIS_SKIP_GMAIL_WATCHER=1)", + ); + } + } + + if (plan.restartProviders.size > 0) { + if (process.env.CLAWDIS_SKIP_PROVIDERS === "1") { + logProviders.info("skipping provider reload (CLAWDIS_SKIP_PROVIDERS=1)"); + } else { + const restartProvider = async ( + name: ProviderKind, + stop: () => Promise, + start: () => Promise, + ) => { + logProviders.info(`restarting ${name} provider`); + await stop(); + await start(); + }; + if (plan.restartProviders.has("whatsapp")) { + await restartProvider( + "whatsapp", + stopWhatsAppProvider, + startWhatsAppProvider, + ); + } + if (plan.restartProviders.has("telegram")) { + await restartProvider( + "telegram", + stopTelegramProvider, + startTelegramProvider, + ); + } + if (plan.restartProviders.has("discord")) { + await restartProvider( + "discord", + stopDiscordProvider, + startDiscordProvider, + ); + } + if (plan.restartProviders.has("signal")) { + await restartProvider( + "signal", + stopSignalProvider, + startSignalProvider, + ); + } + if (plan.restartProviders.has("imessage")) { + await restartProvider( + "imessage", + stopIMessageProvider, + startIMessageProvider, + ); + } + } + } + + setCommandLaneConcurrency( + "cron", + nextConfig.cron?.maxConcurrentRuns ?? 1, + ); + setCommandLaneConcurrency("main", nextConfig.agent?.maxConcurrent ?? 1); + + if (plan.hotReasons.length > 0) { + logReload.info( + `config hot reload applied (${plan.hotReasons.join(", ")})`, + ); + } + }; + + const requestGatewayRestart = ( + plan: GatewayReloadPlan, + _nextConfig: ReturnType, + ) => { + const reasons = plan.restartReasons.length + ? plan.restartReasons.join(", ") + : plan.changedPaths.join(", "); + logReload.warn(`config change requires gateway restart (${reasons})`); + if (process.listenerCount("SIGUSR1") === 0) { + logReload.warn("no SIGUSR1 listener found; restart skipped"); + return; + } + process.emit("SIGUSR1"); + }; + + const configReloader = startGatewayConfigReloader({ + initialConfig: cfgAtStart, + readSnapshot: readConfigFileSnapshot, + onHotReload: applyHotReload, + onRestart: requestGatewayRestart, + log: { + info: (msg) => logReload.info(msg), + warn: (msg) => logReload.warn(msg), + error: (msg) => logReload.error(msg), + }, + watchPath: CONFIG_PATH_CLAWDIS, + }); + return { close: async (opts) => { const reasonRaw = @@ -1664,6 +1832,7 @@ export async function startGatewayServer( } } clients.clear(); + await configReloader.stop().catch(() => {}); if (browserControl) { await browserControl.stop().catch(() => {}); }