feat(gateway): add config hot reload
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
- 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.
|
- 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.
|
- 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.
|
- 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: document Discord `sendMessage` media attachments and `to` format clarification.
|
||||||
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
||||||
- Gateway: document port configuration + multi-instance isolation.
|
- Gateway: document port configuration + multi-instance isolation.
|
||||||
|
- Gateway: document config hot reload + reload matrix.
|
||||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
||||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
- 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
|
### Multi-instance isolation
|
||||||
|
|
||||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
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):
|
# dev loop (auto-reload on TS changes):
|
||||||
pnpm gateway:watch
|
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).
|
- 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.
|
- 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`.
|
- 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;
|
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 = {
|
export type GatewayConfig = {
|
||||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -464,6 +473,7 @@ export type GatewayConfig = {
|
|||||||
auth?: GatewayAuthConfig;
|
auth?: GatewayAuthConfig;
|
||||||
tailscale?: GatewayTailscaleConfig;
|
tailscale?: GatewayTailscaleConfig;
|
||||||
remote?: GatewayRemoteConfig;
|
remote?: GatewayRemoteConfig;
|
||||||
|
reload?: GatewayReloadConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillConfig = {
|
export type SkillConfig = {
|
||||||
@@ -1308,6 +1318,19 @@ export const ClawdisSchema = z.object({
|
|||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
})
|
})
|
||||||
.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(),
|
.optional(),
|
||||||
skills: z
|
skills: z
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"gateway.auth.token": "Gateway Token",
|
"gateway.auth.token": "Gateway Token",
|
||||||
"gateway.auth.password": "Gateway Password",
|
"gateway.auth.password": "Gateway Password",
|
||||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||||
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
|
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||||
"agent.workspace": "Workspace",
|
"agent.workspace": "Workspace",
|
||||||
"agent.model": "Default Model",
|
"agent.model": "Default Model",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
@@ -100,6 +102,10 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
"Optional URL prefix where the Control UI is served (e.g. /clawdis).",
|
"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> = {
|
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(
|
export function createHooksRequestHandler(
|
||||||
opts: {
|
opts: {
|
||||||
hooksConfig: HooksConfigResolved | null;
|
getHooksConfig: () => HooksConfigResolved | null;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
port: number;
|
port: number;
|
||||||
logHooks: SubsystemLogger;
|
logHooks: SubsystemLogger;
|
||||||
} & HookDispatchers,
|
} & HookDispatchers,
|
||||||
): HooksRequestHandler {
|
): HooksRequestHandler {
|
||||||
const {
|
const {
|
||||||
hooksConfig,
|
getHooksConfig,
|
||||||
bindHost,
|
bindHost,
|
||||||
port,
|
port,
|
||||||
logHooks,
|
logHooks,
|
||||||
@@ -73,6 +73,7 @@ export function createHooksRequestHandler(
|
|||||||
dispatchWakeHook,
|
dispatchWakeHook,
|
||||||
} = opts;
|
} = opts;
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
|
const hooksConfig = getHooksConfig();
|
||||||
if (!hooksConfig) return false;
|
if (!hooksConfig) return false;
|
||||||
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
|
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
|
||||||
const basePath = hooksConfig.basePath;
|
const basePath = hooksConfig.basePath;
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ import {
|
|||||||
import { createBridgeHandlers } from "./server-bridge.js";
|
import { createBridgeHandlers } from "./server-bridge.js";
|
||||||
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
|
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
|
||||||
import { startBrowserControlServerIfEnabled } from "./server-browser.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 { createAgentEventHandler, createChatRunState } from "./server-chat.js";
|
||||||
import {
|
import {
|
||||||
DEDUPE_MAX,
|
DEDUPE_MAX,
|
||||||
@@ -133,6 +138,7 @@ const logProviders = log.child("providers");
|
|||||||
const logBrowser = log.child("browser");
|
const logBrowser = log.child("browser");
|
||||||
const logHealth = log.child("health");
|
const logHealth = log.child("health");
|
||||||
const logCron = log.child("cron");
|
const logCron = log.child("cron");
|
||||||
|
const logReload = log.child("reload");
|
||||||
const logHooks = log.child("hooks");
|
const logHooks = log.child("hooks");
|
||||||
const logWsControl = log.child("ws");
|
const logWsControl = log.child("ws");
|
||||||
const logWhatsApp = logProviders.child("whatsapp");
|
const logWhatsApp = logProviders.child("whatsapp");
|
||||||
@@ -408,7 +414,7 @@ export async function startGatewayServer(
|
|||||||
password,
|
password,
|
||||||
allowTailscale,
|
allowTailscale,
|
||||||
};
|
};
|
||||||
const hooksConfig = resolveHooksConfig(cfgAtStart);
|
let hooksConfig = resolveHooksConfig(cfgAtStart);
|
||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
||||||
cfgAtStart.canvasHost?.enabled !== false;
|
cfgAtStart.canvasHost?.enabled !== false;
|
||||||
@@ -554,7 +560,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleHooksRequest = createHooksRequestHandler({
|
const handleHooksRequest = createHooksRequestHandler({
|
||||||
hooksConfig,
|
getHooksConfig: () => hooksConfig,
|
||||||
bindHost,
|
bindHost,
|
||||||
port,
|
port,
|
||||||
logHooks,
|
logHooks,
|
||||||
@@ -650,57 +656,61 @@ export async function startGatewayServer(
|
|||||||
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
|
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
|
||||||
setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1);
|
setCommandLaneConcurrency("main", cfgAtStart.agent?.maxConcurrent ?? 1);
|
||||||
|
|
||||||
const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store);
|
|
||||||
const cronLogger = getChildLogger({
|
const cronLogger = getChildLogger({
|
||||||
module: "cron",
|
module: "cron",
|
||||||
storePath: cronStorePath,
|
|
||||||
});
|
});
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
const cronEnabled =
|
const buildCronService = (cfg: ReturnType<typeof loadConfig>) => {
|
||||||
process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled !== false;
|
const storePath = resolveCronStorePath(cfg.cron?.store);
|
||||||
const cron = new CronService({
|
const cronEnabled =
|
||||||
storePath: cronStorePath,
|
process.env.CLAWDIS_SKIP_CRON !== "1" && cfg.cron?.enabled !== false;
|
||||||
cronEnabled,
|
const cron = new CronService({
|
||||||
enqueueSystemEvent,
|
storePath,
|
||||||
requestHeartbeatNow,
|
cronEnabled,
|
||||||
runIsolatedAgentJob: async ({ job, message }) => {
|
enqueueSystemEvent,
|
||||||
const cfg = loadConfig();
|
requestHeartbeatNow,
|
||||||
return await runCronIsolatedAgentTurn({
|
runIsolatedAgentJob: async ({ job, message }) => {
|
||||||
cfg,
|
const runtimeConfig = loadConfig();
|
||||||
deps,
|
return await runCronIsolatedAgentTurn({
|
||||||
job,
|
cfg: runtimeConfig,
|
||||||
message,
|
deps,
|
||||||
sessionKey: `cron:${job.id}`,
|
job,
|
||||||
lane: "cron",
|
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,
|
|
||||||
});
|
});
|
||||||
void appendCronRunLog(logPath, {
|
},
|
||||||
ts: Date.now(),
|
log: getChildLogger({ module: "cron", storePath }),
|
||||||
jobId: evt.jobId,
|
onEvent: (evt) => {
|
||||||
action: "finished",
|
broadcast("cron", evt, { dropIfSlow: true });
|
||||||
status: evt.status,
|
if (evt.action === "finished") {
|
||||||
error: evt.error,
|
const logPath = resolveCronRunLogPath({
|
||||||
summary: evt.summary,
|
storePath,
|
||||||
runAtMs: evt.runAtMs,
|
jobId: evt.jobId,
|
||||||
durationMs: evt.durationMs,
|
});
|
||||||
nextRunAtMs: evt.nextRunAtMs,
|
void appendCronRunLog(logPath, {
|
||||||
}).catch((err) => {
|
ts: Date.now(),
|
||||||
cronLogger.warn(
|
jobId: evt.jobId,
|
||||||
{ err: String(err), logPath },
|
action: "finished",
|
||||||
"cron: run log append failed",
|
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({
|
const providerManager = createProviderManager({
|
||||||
loadConfig,
|
loadConfig,
|
||||||
@@ -719,6 +729,10 @@ export async function startGatewayServer(
|
|||||||
getRuntimeSnapshot,
|
getRuntimeSnapshot,
|
||||||
startProviders,
|
startProviders,
|
||||||
startWhatsAppProvider,
|
startWhatsAppProvider,
|
||||||
|
startTelegramProvider,
|
||||||
|
startDiscordProvider,
|
||||||
|
startSignalProvider,
|
||||||
|
startIMessageProvider,
|
||||||
stopWhatsAppProvider,
|
stopWhatsAppProvider,
|
||||||
stopTelegramProvider,
|
stopTelegramProvider,
|
||||||
stopDiscordProvider,
|
stopDiscordProvider,
|
||||||
@@ -1122,7 +1136,7 @@ export async function startGatewayServer(
|
|||||||
broadcast("heartbeat", evt, { dropIfSlow: true });
|
broadcast("heartbeat", evt, { dropIfSlow: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
|
let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
|
||||||
|
|
||||||
void cron
|
void cron
|
||||||
.start()
|
.start()
|
||||||
@@ -1585,6 +1599,160 @@ export async function startGatewayServer(
|
|||||||
logProviders.info("skipping provider start (CLAWDIS_SKIP_PROVIDERS=1)");
|
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 {
|
return {
|
||||||
close: async (opts) => {
|
close: async (opts) => {
|
||||||
const reasonRaw =
|
const reasonRaw =
|
||||||
@@ -1664,6 +1832,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clients.clear();
|
clients.clear();
|
||||||
|
await configReloader.stop().catch(() => {});
|
||||||
if (browserControl) {
|
if (browserControl) {
|
||||||
await browserControl.stop().catch(() => {});
|
await browserControl.stop().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user