openskynet / src /gateway /config-reload.test.ts
Darochin's picture
Mirror OpenSkyNet workspace snapshot from Git HEAD
fc93158 verified
import chokidar from "chokidar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ConfigFileSnapshot } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
buildGatewayReloadPlan,
diffConfigPaths,
resolveGatewayReloadSettings,
startGatewayConfigReloader,
} 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 = { messages: { groupChat: { mentionPatterns: ["a"] } } };
const next = { messages: { groupChat: { mentionPatterns: ["b"] } } };
const paths = diffConfigPaths(prev, next);
expect(paths).toContain("messages.groupChat.mentionPatterns");
});
it("does not report unchanged arrays of objects as changed", () => {
const prev = {
memory: {
qmd: {
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
scope: {
rules: [{ when: { channel: "slack" }, include: ["docs"] }],
},
},
},
};
const next = {
memory: {
qmd: {
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
scope: {
rules: [{ when: { channel: "slack" }, include: ["docs"] }],
},
},
},
};
expect(diffConfigPaths(prev, next)).toEqual([]);
});
it("reports changed arrays of objects", () => {
const prev = {
memory: {
qmd: {
paths: [{ path: "~/docs", pattern: "**/*.md", name: "docs" }],
},
},
};
const next = {
memory: {
qmd: {
paths: [{ path: "~/docs", pattern: "**/*.txt", name: "docs" }],
},
},
};
expect(diffConfigPaths(prev, next)).toContain("memory.qmd.paths");
});
});
describe("buildGatewayReloadPlan", () => {
const emptyRegistry = createTestRegistry([]);
const telegramPlugin: ChannelPlugin = {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
reload: { configPrefixes: ["channels.telegram"] },
};
const whatsappPlugin: ChannelPlugin = {
id: "whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
};
const registry = createTestRegistry([
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
]);
beforeEach(() => {
setActivePluginRegistry(registry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
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 when provider config prefixes change", () => {
const changedPaths = ["web.enabled", "channels.telegram.botToken"];
const plan = buildGatewayReloadPlan(changedPaths);
expect(plan.restartGateway).toBe(false);
const expected = new Set(
listChannelPlugins()
.filter((plugin) =>
(plugin.reload?.configPrefixes ?? []).some((prefix) =>
changedPaths.some((path) => path === prefix || path.startsWith(`${prefix}.`)),
),
)
.map((plugin) => plugin.id),
);
expect(expected.size).toBeGreaterThan(0);
expect(plan.restartChannels).toEqual(expected);
});
it("restarts heartbeat when model-related config changes", () => {
const plan = buildGatewayReloadPlan([
"models.providers.openai.models",
"agents.defaults.model",
]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartHeartbeat).toBe(true);
expect(plan.hotReasons).toEqual(
expect.arrayContaining(["models.providers.openai.models", "agents.defaults.model"]),
);
});
it("restarts heartbeat when agents.defaults.models allowlist changes", () => {
const plan = buildGatewayReloadPlan(["agents.defaults.models"]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartHeartbeat).toBe(true);
expect(plan.hotReasons).toContain("agents.defaults.models");
expect(plan.noopPaths).toEqual([]);
});
it("hot-reloads health monitor when channelHealthCheckMinutes changes", () => {
const plan = buildGatewayReloadPlan(["gateway.channelHealthCheckMinutes"]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartHealthMonitor).toBe(true);
expect(plan.hotReasons).toContain("gateway.channelHealthCheckMinutes");
});
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("treats secrets config changes as no-op for gateway restart planning", () => {
const plan = buildGatewayReloadPlan(["secrets.providers.default.path"]);
expect(plan.restartGateway).toBe(false);
expect(plan.noopPaths).toContain("secrets.providers.default.path");
});
it("treats diagnostics.stuckSessionWarnMs as no-op for gateway restart planning", () => {
const plan = buildGatewayReloadPlan(["diagnostics.stuckSessionWarnMs"]);
expect(plan.restartGateway).toBe(false);
expect(plan.noopPaths).toContain("diagnostics.stuckSessionWarnMs");
});
it("defaults unknown paths to restart", () => {
const plan = buildGatewayReloadPlan(["unknownField"]);
expect(plan.restartGateway).toBe(true);
});
it.each([
{
path: "gateway.channelHealthCheckMinutes",
expectRestartGateway: false,
expectHotPath: "gateway.channelHealthCheckMinutes",
expectRestartHealthMonitor: true,
},
{
path: "hooks.gmail.account",
expectRestartGateway: false,
expectHotPath: "hooks.gmail.account",
expectRestartGmailWatcher: true,
expectReloadHooks: true,
},
{
path: "gateway.remote.url",
expectRestartGateway: false,
expectNoopPath: "gateway.remote.url",
},
{
path: "unknownField",
expectRestartGateway: true,
expectRestartReason: "unknownField",
},
])("classifies reload path: $path", (testCase) => {
const plan = buildGatewayReloadPlan([testCase.path]);
expect(plan.restartGateway).toBe(testCase.expectRestartGateway);
if (testCase.expectHotPath) {
expect(plan.hotReasons).toContain(testCase.expectHotPath);
}
if (testCase.expectNoopPath) {
expect(plan.noopPaths).toContain(testCase.expectNoopPath);
}
if (testCase.expectRestartReason) {
expect(plan.restartReasons).toContain(testCase.expectRestartReason);
}
if (testCase.expectRestartHealthMonitor) {
expect(plan.restartHealthMonitor).toBe(true);
}
if (testCase.expectRestartGmailWatcher) {
expect(plan.restartGmailWatcher).toBe(true);
}
if (testCase.expectReloadHooks) {
expect(plan.reloadHooks).toBe(true);
}
});
});
describe("resolveGatewayReloadSettings", () => {
it("uses defaults when unset", () => {
const settings = resolveGatewayReloadSettings({});
expect(settings.mode).toBe("hybrid");
expect(settings.debounceMs).toBe(300);
});
});
type WatcherHandler = () => void;
type WatcherEvent = "add" | "change" | "unlink" | "error";
function createWatcherMock() {
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
return {
on(event: WatcherEvent, handler: WatcherHandler) {
const existing = handlers.get(event) ?? [];
existing.push(handler);
handlers.set(event, existing);
return this;
},
emit(event: WatcherEvent) {
for (const handler of handlers.get(event) ?? []) {
handler();
}
},
close: vi.fn(async () => {}),
};
}
function makeSnapshot(partial: Partial<ConfigFileSnapshot> = {}): ConfigFileSnapshot {
return {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: true,
config: {},
issues: [],
warnings: [],
legacyIssues: [],
...partial,
};
}
function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>) {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const onHotReload = vi.fn(async () => {});
const onRestart = vi.fn();
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
readSnapshot,
onHotReload,
onRestart,
log,
watchPath: "/tmp/openclaw.json",
});
return { watcher, onHotReload, onRestart, log, reloader };
}
describe("startGatewayConfigReloader", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("retries missing snapshots and reloads once config file reappears", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(makeSnapshot({ exists: false, raw: null, hash: "missing-1" }))
.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
},
hash: "next-1",
}),
);
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
watcher.emit("unlink");
await vi.runOnlyPendingTimersAsync();
await vi.advanceTimersByTimeAsync(150);
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(onHotReload).toHaveBeenCalledTimes(1);
expect(onRestart).not.toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith("config reload retry (1/2): config file not found");
expect(log.warn).not.toHaveBeenCalledWith("config reload skipped (config file not found)");
await reloader.stop();
});
it("caps missing-file retries and skips reload after retry budget is exhausted", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValue(makeSnapshot({ exists: false, raw: null, hash: "missing" }));
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
watcher.emit("unlink");
await vi.runAllTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(3);
expect(onHotReload).not.toHaveBeenCalled();
expect(onRestart).not.toHaveBeenCalled();
expect(log.warn).toHaveBeenCalledWith("config reload skipped (config file not found)");
await reloader.stop();
});
it("contains restart callback failures and retries on subsequent changes", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 }, port: 18790 },
},
hash: "restart-1",
}),
)
.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 }, port: 18791 },
},
hash: "restart-2",
}),
);
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
onRestart.mockRejectedValueOnce(new Error("restart-check failed"));
onRestart.mockResolvedValueOnce(undefined);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(onHotReload).not.toHaveBeenCalled();
expect(onRestart).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith("config restart failed: Error: restart-check failed");
expect(unhandled).toEqual([]);
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(onRestart).toHaveBeenCalledTimes(2);
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
await reloader.stop();
}
});
});