feat(gateway): add config hot reload
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:<port>` (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://<gateway-host>:18793/__clawdis__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
|
||||
63
src/gateway/config-reload.test.ts
Normal file
63
src/gateway/config-reload.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
365
src/gateway/config-reload.ts
Normal file
365
src/gateway/config-reload.ts
Normal file
@@ -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<ProviderKind>;
|
||||
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<string, unknown> {
|
||||
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 || "<root>"];
|
||||
}
|
||||
|
||||
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<void>;
|
||||
};
|
||||
|
||||
export function startGatewayConfigReloader(opts: {
|
||||
initialConfig: ClawdisConfig;
|
||||
readSnapshot: () => Promise<ConfigFileSnapshot>;
|
||||
onHotReload: (
|
||||
plan: GatewayReloadPlan,
|
||||
nextConfig: ClawdisConfig,
|
||||
) => Promise<void>;
|
||||
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<typeof setTimeout> | 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(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof loadConfig>) => {
|
||||
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<typeof loadConfig>,
|
||||
) => {
|
||||
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<void>,
|
||||
start: () => Promise<void>,
|
||||
) => {
|
||||
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<typeof loadConfig>,
|
||||
) => {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user