|
| 1 | +import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; |
| 2 | +import { getActivePluginRegistry } from "../plugins/runtime.js"; |
| 3 | + |
| 4 | +export type ChannelKind = ChannelId; |
| 5 | + |
| 6 | +export type GatewayReloadPlan = { |
| 7 | + changedPaths: string[]; |
| 8 | + restartGateway: boolean; |
| 9 | + restartReasons: string[]; |
| 10 | + hotReasons: string[]; |
| 11 | + reloadHooks: boolean; |
| 12 | + restartGmailWatcher: boolean; |
| 13 | + restartBrowserControl: boolean; |
| 14 | + restartCron: boolean; |
| 15 | + restartHeartbeat: boolean; |
| 16 | + restartHealthMonitor: boolean; |
| 17 | + restartChannels: Set<ChannelKind>; |
| 18 | + noopPaths: string[]; |
| 19 | +}; |
| 20 | + |
| 21 | +type ReloadRule = { |
| 22 | + prefix: string; |
| 23 | + kind: "restart" | "hot" | "none"; |
| 24 | + actions?: ReloadAction[]; |
| 25 | +}; |
| 26 | + |
| 27 | +type ReloadAction = |
| 28 | + | "reload-hooks" |
| 29 | + | "restart-gmail-watcher" |
| 30 | + | "restart-browser-control" |
| 31 | + | "restart-cron" |
| 32 | + | "restart-heartbeat" |
| 33 | + | "restart-health-monitor" |
| 34 | + | `restart-channel:${ChannelId}`; |
| 35 | + |
| 36 | +const BASE_RELOAD_RULES: ReloadRule[] = [ |
| 37 | + { prefix: "gateway.remote", kind: "none" }, |
| 38 | + { prefix: "gateway.reload", kind: "none" }, |
| 39 | + { |
| 40 | + prefix: "gateway.channelHealthCheckMinutes", |
| 41 | + kind: "hot", |
| 42 | + actions: ["restart-health-monitor"], |
| 43 | + }, |
| 44 | + // Stuck-session warning threshold is read by the diagnostics heartbeat loop. |
| 45 | + { prefix: "diagnostics.stuckSessionWarnMs", kind: "none" }, |
| 46 | + { prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] }, |
| 47 | + { prefix: "hooks", kind: "hot", actions: ["reload-hooks"] }, |
| 48 | + { |
| 49 | + prefix: "agents.defaults.heartbeat", |
| 50 | + kind: "hot", |
| 51 | + actions: ["restart-heartbeat"], |
| 52 | + }, |
| 53 | + { |
| 54 | + prefix: "agents.defaults.model", |
| 55 | + kind: "hot", |
| 56 | + actions: ["restart-heartbeat"], |
| 57 | + }, |
| 58 | + { |
| 59 | + prefix: "models", |
| 60 | + kind: "hot", |
| 61 | + actions: ["restart-heartbeat"], |
| 62 | + }, |
| 63 | + { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, |
| 64 | + { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, |
| 65 | + { |
| 66 | + prefix: "browser", |
| 67 | + kind: "hot", |
| 68 | + actions: ["restart-browser-control"], |
| 69 | + }, |
| 70 | +]; |
| 71 | + |
| 72 | +const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ |
| 73 | + { prefix: "meta", kind: "none" }, |
| 74 | + { prefix: "identity", kind: "none" }, |
| 75 | + { prefix: "wizard", kind: "none" }, |
| 76 | + { prefix: "logging", kind: "none" }, |
| 77 | + { prefix: "agents", kind: "none" }, |
| 78 | + { prefix: "tools", kind: "none" }, |
| 79 | + { prefix: "bindings", kind: "none" }, |
| 80 | + { prefix: "audio", kind: "none" }, |
| 81 | + { prefix: "agent", kind: "none" }, |
| 82 | + { prefix: "routing", kind: "none" }, |
| 83 | + { prefix: "messages", kind: "none" }, |
| 84 | + { prefix: "session", kind: "none" }, |
| 85 | + { prefix: "talk", kind: "none" }, |
| 86 | + { prefix: "skills", kind: "none" }, |
| 87 | + { prefix: "secrets", kind: "none" }, |
| 88 | + { prefix: "plugins", kind: "restart" }, |
| 89 | + { prefix: "ui", kind: "none" }, |
| 90 | + { prefix: "gateway", kind: "restart" }, |
| 91 | + { prefix: "discovery", kind: "restart" }, |
| 92 | + { prefix: "canvasHost", kind: "restart" }, |
| 93 | +]; |
| 94 | + |
| 95 | +let cachedReloadRules: ReloadRule[] | null = null; |
| 96 | +let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null; |
| 97 | + |
| 98 | +function listReloadRules(): ReloadRule[] { |
| 99 | + const registry = getActivePluginRegistry(); |
| 100 | + if (registry !== cachedRegistry) { |
| 101 | + cachedReloadRules = null; |
| 102 | + cachedRegistry = registry; |
| 103 | + } |
| 104 | + if (cachedReloadRules) { |
| 105 | + return cachedReloadRules; |
| 106 | + } |
| 107 | + // Channel docking: plugins contribute hot reload/no-op prefixes here. |
| 108 | + const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [ |
| 109 | + ...(plugin.reload?.configPrefixes ?? []).map( |
| 110 | + (prefix): ReloadRule => ({ |
| 111 | + prefix, |
| 112 | + kind: "hot", |
| 113 | + actions: [`restart-channel:${plugin.id}` as ReloadAction], |
| 114 | + }), |
| 115 | + ), |
| 116 | + ...(plugin.reload?.noopPrefixes ?? []).map( |
| 117 | + (prefix): ReloadRule => ({ |
| 118 | + prefix, |
| 119 | + kind: "none", |
| 120 | + }), |
| 121 | + ), |
| 122 | + ]); |
| 123 | + const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL]; |
| 124 | + cachedReloadRules = rules; |
| 125 | + return rules; |
| 126 | +} |
| 127 | + |
| 128 | +function matchRule(path: string): ReloadRule | null { |
| 129 | + for (const rule of listReloadRules()) { |
| 130 | + if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) { |
| 131 | + return rule; |
| 132 | + } |
| 133 | + } |
| 134 | + return null; |
| 135 | +} |
| 136 | + |
| 137 | +export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan { |
| 138 | + const plan: GatewayReloadPlan = { |
| 139 | + changedPaths, |
| 140 | + restartGateway: false, |
| 141 | + restartReasons: [], |
| 142 | + hotReasons: [], |
| 143 | + reloadHooks: false, |
| 144 | + restartGmailWatcher: false, |
| 145 | + restartBrowserControl: false, |
| 146 | + restartCron: false, |
| 147 | + restartHeartbeat: false, |
| 148 | + restartHealthMonitor: false, |
| 149 | + restartChannels: new Set(), |
| 150 | + noopPaths: [], |
| 151 | + }; |
| 152 | + |
| 153 | + const applyAction = (action: ReloadAction) => { |
| 154 | + if (action.startsWith("restart-channel:")) { |
| 155 | + const channel = action.slice("restart-channel:".length) as ChannelId; |
| 156 | + plan.restartChannels.add(channel); |
| 157 | + return; |
| 158 | + } |
| 159 | + switch (action) { |
| 160 | + case "reload-hooks": |
| 161 | + plan.reloadHooks = true; |
| 162 | + break; |
| 163 | + case "restart-gmail-watcher": |
| 164 | + plan.restartGmailWatcher = true; |
| 165 | + break; |
| 166 | + case "restart-browser-control": |
| 167 | + plan.restartBrowserControl = true; |
| 168 | + break; |
| 169 | + case "restart-cron": |
| 170 | + plan.restartCron = true; |
| 171 | + break; |
| 172 | + case "restart-heartbeat": |
| 173 | + plan.restartHeartbeat = true; |
| 174 | + break; |
| 175 | + case "restart-health-monitor": |
| 176 | + plan.restartHealthMonitor = true; |
| 177 | + break; |
| 178 | + default: |
| 179 | + break; |
| 180 | + } |
| 181 | + }; |
| 182 | + |
| 183 | + for (const path of changedPaths) { |
| 184 | + const rule = matchRule(path); |
| 185 | + if (!rule) { |
| 186 | + plan.restartGateway = true; |
| 187 | + plan.restartReasons.push(path); |
| 188 | + continue; |
| 189 | + } |
| 190 | + if (rule.kind === "restart") { |
| 191 | + plan.restartGateway = true; |
| 192 | + plan.restartReasons.push(path); |
| 193 | + continue; |
| 194 | + } |
| 195 | + if (rule.kind === "none") { |
| 196 | + plan.noopPaths.push(path); |
| 197 | + continue; |
| 198 | + } |
| 199 | + plan.hotReasons.push(path); |
| 200 | + for (const action of rule.actions ?? []) { |
| 201 | + applyAction(action); |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + if (plan.restartGmailWatcher) { |
| 206 | + plan.reloadHooks = true; |
| 207 | + } |
| 208 | + |
| 209 | + return plan; |
| 210 | +} |
0 commit comments