Compare commits
7 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c6e1ed92 | ||
|
|
2d84590373 | ||
|
|
bc3565f5f4 | ||
|
|
c404d723c8 | ||
|
|
5395ec88d0 | ||
|
|
bcb8475691 | ||
|
|
c026374fb1 |
@ -15,6 +15,10 @@ OpenClaw has two log “surfaces”:
|
||||
- **Console output** (what you see in the terminal / Debug UI).
|
||||
- **File logs** (JSON lines) written by the gateway logger.
|
||||
|
||||
Supervised installs add a third surface:
|
||||
|
||||
- **Service stdout/stderr logs** written by the launcher itself (for example launchd or systemd).
|
||||
|
||||
## File-based logger
|
||||
|
||||
- Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log`
|
||||
@ -39,6 +43,17 @@ openclaw logs --follow
|
||||
raise the file log level.
|
||||
- To capture verbose-only details in file logs, set `logging.level` to `debug` or
|
||||
`trace`.
|
||||
- `openclaw logs --follow` tails this rolling file logger, not service stdout/stderr logs.
|
||||
|
||||
## Service stdout/stderr logs
|
||||
|
||||
When the gateway runs under a service manager, startup failures and uncaught stderr can also land in supervisor-managed logs:
|
||||
|
||||
- macOS CLI LaunchAgent: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `$OPENCLAW_STATE_DIR/logs/gateway.err.log`
|
||||
- macOS app bundle launcher: `/tmp/openclaw/openclaw-gateway.log`
|
||||
- Linux systemd user service: `journalctl --user -u openclaw-gateway.service -n 200 --no-pager`
|
||||
|
||||
These logs are separate from the rolling `/tmp/openclaw/openclaw-YYYY-MM-DD.log` file logger. If disk usage grows on macOS, inspect both surfaces.
|
||||
|
||||
## Console capture
|
||||
|
||||
|
||||
@ -2594,6 +2594,15 @@ Service/supervisor logs (when the gateway runs via launchd/systemd):
|
||||
- macOS: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `gateway.err.log` (default: `~/.openclaw/logs/...`; profiles use `~/.openclaw-<profile>/logs/...`)
|
||||
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
|
||||
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
|
||||
- macOS app-bundled launcher stdout/err: `/tmp/openclaw/openclaw-gateway.log`
|
||||
|
||||
If the macOS service logs have grown unexpectedly and you already fixed the underlying error, rotate/truncate them manually:
|
||||
|
||||
```bash
|
||||
mkdir -p "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs"
|
||||
: > "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.log"
|
||||
: > "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.err.log"
|
||||
```
|
||||
|
||||
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.
|
||||
|
||||
|
||||
149
src/config/io.logging.test.ts
Normal file
149
src/config/io.logging.test.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createConfigIO } from "./io.js";
|
||||
|
||||
async function withTempConfig(
|
||||
run: (params: {
|
||||
home: string;
|
||||
configPath: string;
|
||||
logger: { warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
loadConfig: () => Record<string, unknown>;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-log-"));
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const logger = { warn: vi.fn(), error: vi.fn() };
|
||||
const io = createConfigIO({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await run({
|
||||
home,
|
||||
configPath,
|
||||
logger,
|
||||
loadConfig: () => io.loadConfig() as Record<string, unknown>,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("config io warning/error logging", () => {
|
||||
it("dedupes identical warning payloads until the config changes", async () => {
|
||||
await withTempConfig(async ({ configPath, logger, loadConfig }) => {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
discord: { enabled: false, config: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
loadConfig();
|
||||
loadConfig();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
|
||||
loadConfig();
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
slack: { enabled: false, config: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
loadConfig();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn.mock.calls[1]?.[0]).toContain("plugins.entries.slack");
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes identical invalid config errors until the config becomes valid again", async () => {
|
||||
await withTempConfig(async ({ configPath, logger, loadConfig }) => {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2),
|
||||
);
|
||||
|
||||
expect(loadConfig()).toEqual({});
|
||||
expect(loadConfig()).toEqual({});
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify({ gateway: { port: 18789 } }, null, 2));
|
||||
expect(loadConfig()).toMatchObject({ gateway: { port: 18789 } });
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify({ gateway: { port: "still-bad" } }, null, 2));
|
||||
expect(loadConfig()).toEqual({});
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes config validation details before logging", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./validation.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./validation.js")>();
|
||||
return {
|
||||
...actual,
|
||||
validateConfigObjectWithPlugins: vi.fn(() => ({
|
||||
ok: false as const,
|
||||
issues: [
|
||||
{
|
||||
path: "plugins.entries.bad\nkey\u001b[31m",
|
||||
message: "invalid\tvalue\r\n\u001b[2J",
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const { createConfigIO: createConfigIOWithMock } = await import("./io.js");
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-log-sanitize-"));
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify({ gateway: { port: 18789 } }, null, 2));
|
||||
const logger = { warn: vi.fn(), error: vi.fn() };
|
||||
const io = createConfigIOWithMock({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(io.loadConfig()).toEqual({});
|
||||
const logged = logger.error.mock.calls[0]?.[0] ?? "";
|
||||
expect(logged).toContain("plugins.entries.bad\\nkey");
|
||||
expect(logged).toContain("invalid\\tvalue\\r\\n");
|
||||
expect(logged).not.toContain("\u001b");
|
||||
} finally {
|
||||
vi.doUnmock("./validation.js");
|
||||
vi.resetModules();
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
137
src/config/io.ts
137
src/config/io.ts
@ -13,6 +13,7 @@ import {
|
||||
shouldDeferShellEnvFallback,
|
||||
shouldEnableShellEnvFallback,
|
||||
} from "../infra/shell-env.js";
|
||||
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
||||
import { maintainConfigBackups } from "./backup-rotation.js";
|
||||
@ -81,7 +82,12 @@ const OPEN_DM_POLICY_ALLOW_FROM_RE =
|
||||
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
|
||||
|
||||
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
|
||||
const loggedInvalidConfigs = new Set<string>();
|
||||
type ConfigLogFingerprintState = {
|
||||
invalid?: string;
|
||||
warnings?: string;
|
||||
};
|
||||
|
||||
const configLogFingerprintByPath = new Map<string, ConfigLogFingerprintState>();
|
||||
|
||||
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
|
||||
|
||||
@ -544,19 +550,73 @@ export type ConfigIoDeps = {
|
||||
logger?: Pick<typeof console, "error" | "warn">;
|
||||
};
|
||||
|
||||
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
function getConfigLogFingerprintState(configPath: string): ConfigLogFingerprintState {
|
||||
let state = configLogFingerprintByPath.get(configPath);
|
||||
if (!state) {
|
||||
state = {};
|
||||
configLogFingerprintByPath.set(configPath, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function logConfigMessageOnce(params: {
|
||||
configPath: string;
|
||||
kind: keyof ConfigLogFingerprintState;
|
||||
message: string;
|
||||
logger: Pick<typeof console, "error" | "warn">;
|
||||
}): void {
|
||||
const state = getConfigLogFingerprintState(params.configPath);
|
||||
const fingerprint = hashConfigRaw(`${params.kind}:${params.message}`);
|
||||
if (state[params.kind] === fingerprint) {
|
||||
return;
|
||||
}
|
||||
state[params.kind] = fingerprint;
|
||||
if (params.kind === "invalid") {
|
||||
params.logger.error(params.message);
|
||||
return;
|
||||
}
|
||||
params.logger.warn(params.message);
|
||||
}
|
||||
|
||||
function clearConfigMessageFingerprint(
|
||||
configPath: string,
|
||||
kind: keyof ConfigLogFingerprintState,
|
||||
): void {
|
||||
const state = configLogFingerprintByPath.get(configPath);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
delete state[kind];
|
||||
if (!state.invalid && !state.warnings) {
|
||||
configLogFingerprintByPath.delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
function formatConfigIssueDetails(issues: Array<{ path: string; message: string }>): string {
|
||||
return issues
|
||||
.map((iss) => {
|
||||
const safePath = sanitizeTerminalText(iss.path || "<root>");
|
||||
const safeMessage = sanitizeTerminalText(iss.message);
|
||||
return `- ${safePath}: ${safeMessage}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function getConfigMiskeyWarnings(raw: unknown): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return warnings;
|
||||
}
|
||||
const gateway = (raw as Record<string, unknown>).gateway;
|
||||
if (!gateway || typeof gateway !== "object") {
|
||||
return;
|
||||
return warnings;
|
||||
}
|
||||
if ("token" in (gateway as Record<string, unknown>)) {
|
||||
logger.warn(
|
||||
warnings.push(
|
||||
'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.',
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
|
||||
@ -571,20 +631,19 @@ function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console, "warn">): void {
|
||||
function getFutureVersionWarning(cfg: OpenClawConfig): string | null {
|
||||
const touched = cfg.meta?.lastTouchedVersion;
|
||||
if (!touched) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const cmp = compareOpenClawVersions(VERSION, touched);
|
||||
if (cmp === null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (cmp < 0) {
|
||||
logger.warn(
|
||||
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
|
||||
);
|
||||
return `Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
||||
@ -683,6 +742,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
try {
|
||||
maybeLoadDotEnvForConfig(deps.env);
|
||||
if (!deps.fs.existsSync(configPath)) {
|
||||
clearConfigMessageFingerprint(configPath, "invalid");
|
||||
clearConfigMessageFingerprint(configPath, "warnings");
|
||||
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
|
||||
loadShellEnvFallback({
|
||||
enabled: true,
|
||||
@ -700,8 +761,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
resolveConfigIncludesForRead(parsed, configPath, deps),
|
||||
deps.env,
|
||||
);
|
||||
warnOnConfigMiskeys(resolvedConfig, deps.logger);
|
||||
const warningMessages = getConfigMiskeyWarnings(resolvedConfig);
|
||||
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
|
||||
clearConfigMessageFingerprint(configPath, "invalid");
|
||||
clearConfigMessageFingerprint(configPath, "warnings");
|
||||
return {};
|
||||
}
|
||||
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
|
||||
@ -713,25 +776,40 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfig);
|
||||
if (!validated.ok) {
|
||||
const details = validated.issues
|
||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||
.join("\n");
|
||||
if (!loggedInvalidConfigs.has(configPath)) {
|
||||
loggedInvalidConfigs.add(configPath);
|
||||
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
|
||||
}
|
||||
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
|
||||
const details = formatConfigIssueDetails(validated.issues);
|
||||
clearConfigMessageFingerprint(configPath, "warnings");
|
||||
logConfigMessageOnce({
|
||||
configPath,
|
||||
kind: "invalid",
|
||||
message: `Invalid config at ${sanitizeTerminalText(configPath)}:\\n${details}`,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const error = new Error(
|
||||
`Invalid config at ${sanitizeTerminalText(configPath)}:\n${details}`,
|
||||
);
|
||||
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||
(error as { code?: string; details?: string }).details = details;
|
||||
throw error;
|
||||
}
|
||||
clearConfigMessageFingerprint(configPath, "invalid");
|
||||
if (validated.warnings.length > 0) {
|
||||
const details = validated.warnings
|
||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||
.join("\n");
|
||||
deps.logger.warn(`Config warnings:\\n${details}`);
|
||||
const details = formatConfigIssueDetails(validated.warnings);
|
||||
warningMessages.push(`Config warnings:\\n${details}`);
|
||||
}
|
||||
const futureVersionWarning = getFutureVersionWarning(validated.config);
|
||||
if (futureVersionWarning) {
|
||||
warningMessages.push(futureVersionWarning);
|
||||
}
|
||||
if (warningMessages.length > 0) {
|
||||
logConfigMessageOnce({
|
||||
configPath,
|
||||
kind: "warnings",
|
||||
message: warningMessages.join("\n"),
|
||||
logger: deps.logger,
|
||||
});
|
||||
} else {
|
||||
clearConfigMessageFingerprint(configPath, "warnings");
|
||||
}
|
||||
warnIfConfigFromFuture(validated.config, deps.logger);
|
||||
const cfg = applyTalkConfigNormalization(
|
||||
applyModelDefaults(
|
||||
applyCompactionDefaults(
|
||||
@ -948,7 +1026,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
warnIfConfigFromFuture(validated.config, deps.logger);
|
||||
const futureVersionWarning = getFutureVersionWarning(validated.config);
|
||||
const snapshotConfig = normalizeConfigPaths(
|
||||
applyTalkApiKey(
|
||||
applyTalkConfigNormalization(
|
||||
@ -974,7 +1052,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: snapshotConfig,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: validated.warnings,
|
||||
warnings: futureVersionWarning
|
||||
? [
|
||||
...validated.warnings,
|
||||
{ path: "meta.lastTouchedVersion", message: futureVersionWarning },
|
||||
]
|
||||
: validated.warnings,
|
||||
legacyIssues,
|
||||
},
|
||||
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
// launchd applies ThrottleInterval to any rapid relaunch, including
|
||||
// intentional gateway restarts. Keep it low so CLI restarts and forced
|
||||
// reinstalls do not stall for a full minute.
|
||||
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 1;
|
||||
// launchd applies ThrottleInterval to any rapid relaunch, including config-crash
|
||||
// loops and clean supervised exits. Keep KeepAlive=true so intentional restarts
|
||||
// still come back up, and use a higher throttle to slow unhealthy restart storms.
|
||||
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 30;
|
||||
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
|
||||
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
|
||||
|
||||
|
||||
@ -189,7 +189,7 @@ describe("launchd install", () => {
|
||||
expect(plist).toContain(`<string>${tmpDir}</string>`);
|
||||
});
|
||||
|
||||
it("writes KeepAlive=true policy with restrictive umask", async () => {
|
||||
it("writes KeepAlive policy with restrictive umask", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
await installLaunchAgent({
|
||||
env,
|
||||
@ -201,7 +201,6 @@ describe("launchd install", () => {
|
||||
const plist = state.files.get(plistPath) ?? "";
|
||||
expect(plist).toContain("<key>KeepAlive</key>");
|
||||
expect(plist).toContain("<true/>");
|
||||
expect(plist).not.toContain("<key>SuccessfulExit</key>");
|
||||
expect(plist).toContain("<key>Umask</key>");
|
||||
expect(plist).toContain(`<integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>`);
|
||||
expect(plist).toContain("<key>ThrottleInterval</key>");
|
||||
|
||||
@ -171,7 +171,10 @@ async function auditLaunchdPlist(
|
||||
}
|
||||
|
||||
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
|
||||
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
||||
const hasKeepAlive =
|
||||
/<key>KeepAlive<\/key>\s*(?:<true\s*\/>|<dict>[\s\S]*?<key>SuccessfulExit<\/key>\s*<false\s*\/>[\s\S]*?<\/dict>)/i.test(
|
||||
content,
|
||||
);
|
||||
if (!hasRunAtLoad) {
|
||||
issues.push({
|
||||
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
|
||||
|
||||
@ -21,6 +21,16 @@ describe("buildSystemdUnit", () => {
|
||||
expect(unit).toContain("KillMode=control-group");
|
||||
});
|
||||
|
||||
it("keeps restart always for supervised restarts", () => {
|
||||
const unit = buildSystemdUnit({
|
||||
description: "OpenClaw Gateway",
|
||||
programArguments: ["/usr/bin/openclaw", "gateway", "run"],
|
||||
environment: {},
|
||||
});
|
||||
expect(unit).toContain("Restart=always");
|
||||
expect(unit).not.toContain("Restart=on-failure");
|
||||
});
|
||||
|
||||
it("rejects environment values with line breaks", () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit({
|
||||
|
||||
@ -58,6 +58,8 @@ export function buildSystemdUnit({
|
||||
"[Service]",
|
||||
`ExecStart=${execStart}`,
|
||||
"Restart=always",
|
||||
// systemd already has burst protection; keep a shorter retry than launchd so
|
||||
// supervised restart requests still relaunch promptly on Linux.
|
||||
"RestartSec=5",
|
||||
// Keep service children in the same lifecycle so restarts do not leave
|
||||
// orphan ACP/runtime workers behind.
|
||||
|
||||
@ -415,4 +415,56 @@ describe("startGatewayConfigReloader", () => {
|
||||
await reloader.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes unchanged invalid snapshots and re-logs when the invalid payload changes", async () => {
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
.mockResolvedValueOnce(
|
||||
makeSnapshot({
|
||||
valid: false,
|
||||
issues: [
|
||||
{ path: "plugins.entries.discord", message: "plugin disabled but config is present" },
|
||||
],
|
||||
hash: "invalid-1",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeSnapshot({
|
||||
valid: false,
|
||||
issues: [
|
||||
{ path: "plugins.entries.discord", message: "plugin disabled but config is present" },
|
||||
],
|
||||
hash: "invalid-1",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeSnapshot({
|
||||
valid: false,
|
||||
issues: [
|
||||
{ path: "plugins.entries.slack", message: "plugin disabled but config is present" },
|
||||
],
|
||||
hash: "invalid-2",
|
||||
}),
|
||||
);
|
||||
const { watcher, log, reloader } = createReloaderHarness(readSnapshot);
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(log.warn).toHaveBeenCalledTimes(2);
|
||||
expect(log.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"config reload skipped (invalid config): plugins.entries.discord: plugin disabled but config is present",
|
||||
);
|
||||
expect(log.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"config reload skipped (invalid config): plugins.entries.slack: plugin disabled but config is present",
|
||||
);
|
||||
|
||||
await reloader.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@ -89,6 +89,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
let stopped = false;
|
||||
let restartQueued = false;
|
||||
let missingConfigRetries = 0;
|
||||
let invalidSnapshotFingerprint: string | null = null;
|
||||
|
||||
const scheduleAfter = (wait: number) => {
|
||||
if (stopped) {
|
||||
@ -140,10 +141,15 @@ export function startGatewayConfigReloader(opts: {
|
||||
|
||||
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
|
||||
if (snapshot.valid) {
|
||||
invalidSnapshotFingerprint = null;
|
||||
return false;
|
||||
}
|
||||
const issues = formatConfigIssueLines(snapshot.issues, "").join(", ");
|
||||
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
|
||||
const fingerprint = snapshot.hash ?? issues;
|
||||
if (invalidSnapshotFingerprint !== fingerprint) {
|
||||
invalidSnapshotFingerprint = fingerprint;
|
||||
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user