mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
fix(config): log observe recovery write failures
This commit is contained in:
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc.
|
||||
- Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx.
|
||||
- Discord/doctor: migrate unsupported per-channel `agentId` entries under guild channel config into top-level `bindings[]` routes, so `openclaw doctor --fix` preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit.
|
||||
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
|
||||
|
||||
## 2026.4.30
|
||||
|
||||
|
||||
@@ -159,6 +159,47 @@ describe("config observe recovery", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function withAsyncHealthWriteFailure(
|
||||
deps: ObserveRecoveryDeps,
|
||||
healthPath: string,
|
||||
): ObserveRecoveryDeps {
|
||||
const writeFile = deps.fs.promises.writeFile.bind(deps.fs.promises);
|
||||
return {
|
||||
...deps,
|
||||
fs: {
|
||||
...deps.fs,
|
||||
promises: {
|
||||
...deps.fs.promises,
|
||||
writeFile: async (target, data, options) => {
|
||||
if (target === healthPath) {
|
||||
throw new Error("health write failed");
|
||||
}
|
||||
return await writeFile(target, data, options);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withSyncHealthWriteFailure(
|
||||
deps: ObserveRecoveryDeps,
|
||||
healthPath: string,
|
||||
): ObserveRecoveryDeps {
|
||||
const writeFileSync = deps.fs.writeFileSync.bind(deps.fs);
|
||||
return {
|
||||
...deps,
|
||||
fs: {
|
||||
...deps.fs,
|
||||
writeFileSync: (target, data, options) => {
|
||||
if (target === healthPath) {
|
||||
throw new Error("health write failed");
|
||||
}
|
||||
return writeFileSync(target, data, options);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("auto-restores suspicious update-channel-only roots from backup", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath, warn } = makeDeps(home);
|
||||
@@ -383,6 +424,48 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("logs async health-state write failures", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, warn } = makeDeps(home);
|
||||
const snapshot = await makeSnapshot(configPath, recoverableTelegramConfig);
|
||||
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
|
||||
|
||||
await expect(
|
||||
promoteConfigSnapshotToLastKnownGood({
|
||||
deps: withAsyncHealthWriteFailure(deps, healthPath),
|
||||
snapshot,
|
||||
logger: deps.logger,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Config health-state write failed: ${healthPath}: health write failed`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs sync health-state write failures", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, warn } = makeDeps(home);
|
||||
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
|
||||
await seedConfigBackup(configPath, recoverableTelegramConfig);
|
||||
await writeClobberedUpdateChannel(configPath);
|
||||
|
||||
recoverClobberedUpdateChannelSync({
|
||||
deps: withSyncHealthWriteFailure(deps, healthPath),
|
||||
configPath,
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Config health-state write failed: ${healthPath}: health write failed`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes a valid startup config and restores it after an invalid direct edit", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath, warn } = makeDeps(home);
|
||||
|
||||
@@ -313,6 +313,10 @@ function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => str
|
||||
return path.join(resolveStateDir(env, homedir), "logs", "config-health.json");
|
||||
}
|
||||
|
||||
function formatObserveRecoveryError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function readConfigHealthState(deps: ObserveRecoveryDeps): Promise<ConfigHealthState> {
|
||||
try {
|
||||
const raw = await deps.fs.promises.readFile(
|
||||
@@ -340,25 +344,33 @@ async function writeConfigHealthState(
|
||||
deps: ObserveRecoveryDeps,
|
||||
state: ConfigHealthState,
|
||||
): Promise<void> {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
try {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
||||
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
deps.logger.warn(
|
||||
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfigHealthStateSync(deps: ObserveRecoveryDeps, state: ConfigHealthState): void {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
try {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
||||
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
deps.logger.warn(
|
||||
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry {
|
||||
|
||||
@@ -441,28 +441,28 @@ async function writeConfigHealthState(
|
||||
deps: Required<ConfigIoDeps>,
|
||||
state: ConfigHealthState,
|
||||
): Promise<void> {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
try {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
||||
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
try {
|
||||
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
||||
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
||||
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
} catch (err) {
|
||||
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fsNode from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -103,6 +104,65 @@ describe("config io write", () => {
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
function withHealthStateWriteFailure(healthPath: string): typeof fsNode {
|
||||
const writeFile = fsNode.promises.writeFile.bind(fsNode.promises);
|
||||
const writeFileSync = fsNode.writeFileSync.bind(fsNode);
|
||||
return {
|
||||
...fsNode,
|
||||
promises: {
|
||||
...fsNode.promises,
|
||||
writeFile: async (target, data, options) => {
|
||||
if (target === healthPath) {
|
||||
throw new Error("health write failed");
|
||||
}
|
||||
return await writeFile(target, data, options);
|
||||
},
|
||||
},
|
||||
writeFileSync: (target, data, options) => {
|
||||
if (target === healthPath) {
|
||||
throw new Error("health write failed");
|
||||
}
|
||||
return writeFileSync(target, data, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("logs health-state write failures through public config reads", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const warn = vi.fn();
|
||||
const io = createConfigIO({
|
||||
configPath,
|
||||
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
|
||||
fs: withHealthStateWriteFailure(healthPath),
|
||||
homedir: () => home,
|
||||
logger: { warn, error: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true });
|
||||
expect(() => io.loadConfig()).not.toThrow();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Config health-state write failed: ${healthPath}: health write failed`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
warn.mock.calls.filter(
|
||||
([message]) =>
|
||||
typeof message === "string" && message.includes("Config health-state write failed:"),
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates shipped plugin install config records into the plugin index", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
Reference in New Issue
Block a user