refactor: store config health in sqlite

This commit is contained in:
Peter Steinberger
2026-05-08 17:12:46 +01:00
parent c33adc344b
commit b2973e325b
9 changed files with 242 additions and 239 deletions

View File

@@ -398,6 +398,9 @@ The remaining cleanup is mostly consolidation and deletion:
- Path override handling now treats literal `undefined`/`null` environment
values as unset, preventing accidental repo-root `undefined/state/*.sqlite`
databases during tests or shell handoffs.
- Config health fingerprints now use shared SQLite KV instead of
`logs/config-health.json`, keeping the normal config file as the only
non-credential configuration document.
- Voice Wake trigger and routing settings now use shared SQLite KV instead of
`settings/voicewake.json` and `settings/voicewake-routing.json`; doctor imports
the legacy JSON files and removes them after a successful migration.
@@ -477,9 +480,9 @@ The remaining cleanup is mostly consolidation and deletion:
legacy store names with write-style filesystem APIs. Tests and migration,
doctor, import, and explicit export code remain allowed. The guard now also
covers runtime `cache/*.json` stores, generic `thread-bindings.json`
sidecars, cron state/run-log JSON, restart and lock sidecars, Voice Wake
settings, plugin binding approvals, installed plugin index JSON, File Transfer
audit JSONL, and Memory Wiki activity logs.
sidecars, cron state/run-log JSON, config health JSON, restart and lock
sidecars, Voice Wake settings, plugin binding approvals, installed plugin
index JSON, File Transfer audit JSONL, and Memory Wiki activity logs.
## Target Schema Shape

View File

@@ -54,6 +54,7 @@ const legacyStoreMarkers = [
{ label: "Crestodian audit JSONL", pattern: /\bcrestodian\.jsonl\b/u },
{ label: "File Transfer audit JSONL", pattern: /\bfile-transfer\.jsonl\b/u },
{ label: "Config audit JSONL", pattern: /\bconfig-audit\.jsonl\b/u },
{ label: "Config health JSON", pattern: /\bconfig-health\.json\b/u },
{
label: "Crestodian rescue pending JSON",
pattern: /\bcrestodian[/\\]rescue-pending[/\\][A-Za-z0-9._-]+\.json\b/u,

View File

@@ -223,6 +223,32 @@ describe("maybeRepairLegacyRuntimeStateFiles", () => {
})}\n`,
"utf8",
);
await fs.mkdir(path.join(stateDir, "logs"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "logs", "config-health.json"),
`${JSON.stringify({
entries: {
"/tmp/openclaw.json": {
lastKnownGood: {
hash: "legacy-health",
bytes: 42,
mtimeMs: 1,
ctimeMs: 1,
dev: null,
ino: null,
mode: 384,
nlink: 1,
uid: null,
gid: null,
hasMeta: true,
gatewayMode: "local",
observedAt: "2026-01-17T10:00:00.000Z",
},
},
},
})}\n`,
"utf8",
);
const mediaRecordsDir = path.join(stateDir, "media", "outgoing", "records");
await fs.mkdir(mediaRecordsDir, { recursive: true });
await fs.writeFile(
@@ -430,6 +456,19 @@ describe("maybeRepairLegacyRuntimeStateFiles", () => {
await expect(fs.stat(path.join(stateDir, "update-check.json"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(readOpenClawStateKvJson("config.health", "current", { env })).toMatchObject({
entries: {
"/tmp/openclaw.json": {
lastKnownGood: {
hash: "legacy-health",
gatewayMode: "local",
},
},
},
});
await expect(
fs.stat(path.join(stateDir, "logs", "config-health.json")),
).rejects.toMatchObject({ code: "ENOENT" });
expect(
readOpenClawStateKvJson(
"managed_outgoing_image_records",

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
discoverLegacyAuthProfileStateAgentDirs,
importLegacyAuthProfileStateFileToSqlite,
@@ -15,6 +17,7 @@ import {
legacyCommitmentStoreFileExists,
} from "../commitments/store.js";
import type { OpenClawConfig } from "../config/config.js";
import { writeConfigHealthStateToSqlite, type ConfigHealthState } from "../config/health-state.js";
import { resolveStateDir } from "../config/paths.js";
import {
importLegacyManagedOutgoingImageRecordFilesToSqlite,
@@ -106,6 +109,7 @@ type LegacyStateProbe = {
webPush: boolean;
apns: boolean;
updateCheck: boolean;
configHealth: boolean;
managedImages: boolean;
mediaFiles: boolean;
pluginState: boolean;
@@ -122,6 +126,50 @@ type LegacyStateProbe = {
memoryCoreDreamingState: boolean;
};
function resolveLegacyConfigHealthPath(baseDir: string): string {
return path.join(baseDir, "logs", "config-health.json");
}
async function legacyConfigHealthFileExists(baseDir: string): Promise<boolean> {
try {
return (await fs.stat(resolveLegacyConfigHealthPath(baseDir))).isFile();
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return false;
}
throw error;
}
}
async function importLegacyConfigHealthFileToSqlite(params: {
env: NodeJS.ProcessEnv;
baseDir: string;
}): Promise<{ imported: boolean; entries: number }> {
const filePath = resolveLegacyConfigHealthPath(params.baseDir);
let parsed: unknown;
try {
parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return { imported: false, entries: 0 };
}
throw error;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { imported: false, entries: 0 };
}
const state = parsed as ConfigHealthState;
writeConfigHealthStateToSqlite(params.env, () => params.baseDir, state);
await fs.rm(filePath, { force: true }).catch(() => undefined);
return {
imported: true,
entries:
state.entries && typeof state.entries === "object" && !Array.isArray(state.entries)
? Object.keys(state.entries).length
: 0,
};
}
async function probeLegacyRuntimeStateFiles(params: {
env: NodeJS.ProcessEnv;
cfg?: OpenClawConfig;
@@ -141,6 +189,7 @@ async function probeLegacyRuntimeStateFiles(params: {
webPush: await legacyWebPushFilesExist(baseDir),
apns: await legacyApnsRegistrationFileExists(baseDir),
updateCheck: await legacyUpdateCheckFileExists(env),
configHealth: await legacyConfigHealthFileExists(baseDir),
managedImages: await legacyManagedOutgoingImageRecordFilesExist(baseDir),
mediaFiles: await legacyMediaFilesExist(env),
pluginState: legacyPluginStateSidecarExists(env),
@@ -297,6 +346,16 @@ export async function maybeRepairLegacyRuntimeStateFiles(params: {
}
});
}
if (probe.configHealth) {
await runImport("Config health", async () => {
const result = await importLegacyConfigHealthFileToSqlite({ env, baseDir });
if (result.imported) {
changes.push(
`- Imported ${result.entries} config health entr${result.entries === 1 ? "y" : "ies"} into SQLite.`,
);
}
});
}
if (probe.managedImages) {
await runImport("Managed outgoing image records", async () => {
const result = await importLegacyManagedOutgoingImageRecordFilesToSqlite(baseDir);

View File

@@ -0,0 +1,77 @@
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import {
readOpenClawStateKvJson,
writeOpenClawStateKvJson,
type OpenClawStateJsonValue,
} from "../state/openclaw-state-kv.js";
import { isRecord } from "../utils.js";
export type ConfigHealthFingerprint = {
hash: string;
bytes: number;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
observedAt: string;
};
export type ConfigHealthEntry = {
lastKnownGood?: ConfigHealthFingerprint;
lastPromotedGood?: ConfigHealthFingerprint;
lastObservedSuspiciousSignature?: string | null;
};
export type ConfigHealthState = {
entries?: Record<string, ConfigHealthEntry>;
};
const CONFIG_HEALTH_SCOPE = "config.health";
const CONFIG_HEALTH_KEY = "current";
function configHealthDbOptions(
env: NodeJS.ProcessEnv,
homedir: () => string,
): OpenClawStateDatabaseOptions {
return {
env: {
...env,
HOME: env.HOME ?? homedir(),
},
};
}
export function readConfigHealthStateFromSqlite(
env: NodeJS.ProcessEnv,
homedir: () => string,
): ConfigHealthState {
try {
const parsed = readOpenClawStateKvJson(
CONFIG_HEALTH_SCOPE,
CONFIG_HEALTH_KEY,
configHealthDbOptions(env, homedir),
);
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
}
export function writeConfigHealthStateToSqlite(
env: NodeJS.ProcessEnv,
homedir: () => string,
state: ConfigHealthState,
): void {
writeOpenClawStateKvJson<OpenClawStateJsonValue>(
CONFIG_HEALTH_SCOPE,
CONFIG_HEALTH_KEY,
state as unknown as OpenClawStateJsonValue,
configHealthDbOptions(env, homedir),
);
}

View File

@@ -5,6 +5,8 @@ import path from "node:path";
import JSON5 from "json5";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { readConfigHealthStateFromSqlite } from "./health-state.js";
import { listConfigAuditRecordsForTests } from "./io.audit.js";
import { CONFIG_CLOBBER_SNAPSHOT_LIMIT } from "./io.clobber-snapshot.js";
import {
@@ -45,6 +47,7 @@ describe("config observe recovery", () => {
afterEach(() => {
resetPluginStateStoreForTests();
closeOpenClawStateDatabaseForTest();
});
async function seedConfig(configPath: string, config: Record<string, unknown>): Promise<void> {
@@ -217,47 +220,6 @@ 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);
@@ -529,42 +491,32 @@ describe("config observe recovery", () => {
});
});
it("logs async health-state write failures", async () => {
it("stores promoted config health state in SQLite instead of config-health.json", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, warn } = makeDeps(home);
const { deps, configPath } = 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),
deps,
snapshot,
logger: deps.logger,
}),
).resolves.toBe(true);
expectWarnContaining(
warn,
`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,
});
expectWarnContaining(
warn,
`Config health-state write failed: ${healthPath}: health write failed`,
await expect(fsp.stat(healthPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(readConfigHealthStateFromSqlite(deps.env, deps.homedir).entries?.[configPath]).toEqual(
expect.objectContaining({
lastKnownGood: expect.objectContaining({
hash: expect.any(String),
gatewayMode: "local",
}),
lastPromotedGood: expect.objectContaining({
hash: expect.any(String),
gatewayMode: "local",
}),
}),
);
});
});

View File

@@ -1,6 +1,12 @@
import crypto from "node:crypto";
import path from "node:path";
import { isRecord } from "../utils.js";
import {
readConfigHealthStateFromSqlite,
writeConfigHealthStateToSqlite,
type ConfigHealthEntry,
type ConfigHealthFingerprint,
type ConfigHealthState,
} from "./health-state.js";
import {
appendConfigAuditRecord,
appendConfigAuditRecordSync,
@@ -12,7 +18,6 @@ import {
persistBoundedClobberedConfigSnapshotSync,
} from "./io.clobber-snapshot.js";
import { formatConfigIssueSummary } from "./issue-format.js";
import { resolveStateDir } from "./paths.js";
import {
isPluginLocalInvalidConfigSnapshot,
shouldAttemptLastKnownGoodRecovery,
@@ -85,22 +90,6 @@ export type ObserveRecoveryDeps = {
logger: Pick<typeof console, "warn">;
};
type ConfigHealthFingerprint = {
hash: string;
bytes: number;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
observedAt: string;
};
type ConfigStatMetadataSource =
| ({
mtimeMs?: number;
@@ -114,16 +103,6 @@ type ConfigStatMetadataSource =
} & Record<string, unknown>)
| null;
type ConfigHealthEntry = {
lastKnownGood?: ConfigHealthFingerprint;
lastPromotedGood?: ConfigHealthFingerprint;
lastObservedSuspiciousSignature?: string | null;
};
type ConfigHealthState = {
entries?: Record<string, ConfigHealthEntry>;
};
function createConfigObserveAuditRecord(params: {
ts: string;
configPath: string;
@@ -317,67 +296,34 @@ function parseConfigRawOrEmpty(deps: ObserveRecoveryDeps, raw: string): unknown
}
}
function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => string): string {
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(
resolveConfigHealthStatePath(deps.env, deps.homedir),
"utf-8",
);
const parsed = deps.json5.parse(raw);
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
return readConfigHealthStateFromSqlite(deps.env, deps.homedir);
}
function readConfigHealthStateSync(deps: ObserveRecoveryDeps): ConfigHealthState {
try {
const raw = deps.fs.readFileSync(resolveConfigHealthStatePath(deps.env, deps.homedir), "utf-8");
const parsed = deps.json5.parse(raw);
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
return readConfigHealthStateFromSqlite(deps.env, deps.homedir);
}
async function writeConfigHealthState(
deps: ObserveRecoveryDeps,
state: ConfigHealthState,
): Promise<void> {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
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,
});
writeConfigHealthStateToSqlite(deps.env, deps.homedir, state);
} catch (err) {
deps.logger.warn(
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
);
deps.logger.warn(`Config health-state write failed: ${formatObserveRecoveryError(err)}`);
}
}
function writeConfigHealthStateSync(deps: ObserveRecoveryDeps, state: ConfigHealthState): void {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
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,
});
writeConfigHealthStateToSqlite(deps.env, deps.homedir, state);
} catch (err) {
deps.logger.warn(
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
);
deps.logger.warn(`Config health-state write failed: ${formatObserveRecoveryError(err)}`);
}
}

View File

@@ -42,6 +42,13 @@ import {
resolveConfigEnvVars,
} from "./env-substitution.js";
import { applyConfigEnvVars } from "./env-vars.js";
import {
readConfigHealthStateFromSqlite,
writeConfigHealthStateToSqlite,
type ConfigHealthEntry,
type ConfigHealthFingerprint,
type ConfigHealthState,
} from "./health-state.js";
import {
ConfigIncludeError,
readConfigIncludeFileWithGuards,
@@ -164,36 +171,9 @@ type ShippedPluginInstallConfigReadMigration = {
persistedRootRaw?: string;
};
const CONFIG_HEALTH_STATE_FILENAME = "config-health.json";
const loggedInvalidConfigs = new Set<string>();
const warnedFutureTouchedVersions = new Set<string>();
type ConfigHealthFingerprint = {
hash: string;
bytes: number;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
observedAt: string;
};
type ConfigHealthEntry = {
lastKnownGood?: ConfigHealthFingerprint;
lastPromotedGood?: ConfigHealthFingerprint;
lastObservedSuspiciousSignature?: string | null;
};
type ConfigHealthState = {
entries?: Record<string, ConfigHealthEntry>;
};
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
export type ConfigWriteOptions = {
/**
@@ -371,10 +351,6 @@ function collectEnvRefPaths(value: unknown, path: string, output: Map<string, st
}
}
function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_HEALTH_STATE_FILENAME);
}
function normalizeStatNumber(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
@@ -443,53 +419,29 @@ function resolveConfigWriteBlockingReasons(
}
async function readConfigHealthState(deps: Required<ConfigIoDeps>): Promise<ConfigHealthState> {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
const raw = await deps.fs.promises.readFile(healthPath, "utf-8");
const parsed = JSON.parse(raw);
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
return readConfigHealthStateFromSqlite(deps.env, deps.homedir);
}
function readConfigHealthStateSync(deps: Required<ConfigIoDeps>): ConfigHealthState {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
const raw = deps.fs.readFileSync(healthPath, "utf-8");
const parsed = JSON.parse(raw);
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
return readConfigHealthStateFromSqlite(deps.env, deps.homedir);
}
async function writeConfigHealthState(
deps: Required<ConfigIoDeps>,
state: ConfigHealthState,
): Promise<void> {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
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,
});
writeConfigHealthStateToSqlite(deps.env, deps.homedir, state);
} catch (err) {
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
deps.logger.warn(`Config health-state write failed: ${formatErrorMessage(err)}`);
}
}
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
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,
});
writeConfigHealthStateToSqlite(deps.env, deps.homedir, state);
} catch (err) {
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
deps.logger.warn(`Config health-state write failed: ${formatErrorMessage(err)}`);
}
}

View File

@@ -1,10 +1,11 @@
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";
import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { readConfigHealthStateFromSqlite } from "./health-state.js";
import { CONFIG_CLOBBER_SNAPSHOT_LIMIT } from "./io.clobber-snapshot.js";
import {
createConfigIO,
@@ -86,6 +87,7 @@ describe("config io write", () => {
afterEach(() => {
resetConfigRuntimeState();
closeOpenClawStateDatabaseForTest();
mockMaintainConfigBackups.mockReset();
mockMaintainConfigBackups.mockResolvedValue(undefined);
});
@@ -153,30 +155,7 @@ 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 () => {
it("stores config health state in SQLite instead of config-health.json", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
@@ -186,30 +165,25 @@ describe("config io write", () => {
`${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() },
logger: { warn: vi.fn(), error: vi.fn() },
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.exists).toBe(true);
expect(io.loadConfig().gateway).toEqual({ mode: "local" });
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
`Config health-state write failed: ${healthPath}: health write failed`,
),
);
await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true });
expect(io.loadConfig()).toMatchObject({ gateway: { mode: "local" } });
await expect(fs.stat(healthPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(
warn.mock.calls.filter(
([message]) =>
typeof message === "string" && message.includes("Config health-state write failed:"),
),
).toHaveLength(2);
readConfigHealthStateFromSqlite(
{ OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
() => home,
).entries?.[configPath]?.lastKnownGood,
).toMatchObject({
hash: expect.any(String),
gatewayMode: "local",
});
});
});