diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 018eb19f70c..1270bcf73c5 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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 diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 952d391c1ad..1a22c6071e6 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -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, diff --git a/src/commands/doctor-sqlite-state.test.ts b/src/commands/doctor-sqlite-state.test.ts index 2bcabb2150b..eed616a76a9 100644 --- a/src/commands/doctor-sqlite-state.test.ts +++ b/src/commands/doctor-sqlite-state.test.ts @@ -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", diff --git a/src/commands/doctor-sqlite-state.ts b/src/commands/doctor-sqlite-state.ts index d71349e542f..962eca666b5 100644 --- a/src/commands/doctor-sqlite-state.ts +++ b/src/commands/doctor-sqlite-state.ts @@ -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 { + 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); diff --git a/src/config/health-state.ts b/src/config/health-state.ts new file mode 100644 index 00000000000..f23ba5582ec --- /dev/null +++ b/src/config/health-state.ts @@ -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; +}; + +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( + CONFIG_HEALTH_SCOPE, + CONFIG_HEALTH_KEY, + state as unknown as OpenClawStateJsonValue, + configHealthDbOptions(env, homedir), + ); +} diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index 2640d142b7f..3eb10497856 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -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): Promise { @@ -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", + }), + }), ); }); }); diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index db5b7edf6be..651196a1efc 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -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; }; -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) | null; -type ConfigHealthEntry = { - lastKnownGood?: ConfigHealthFingerprint; - lastPromotedGood?: ConfigHealthFingerprint; - lastObservedSuspiciousSignature?: string | null; -}; - -type ConfigHealthState = { - entries?: Record; -}; - 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 { - 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 { - 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)}`); } } diff --git a/src/config/io.ts b/src/config/io.ts index eb7e74bbee0..f533f228145 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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(); const warnedFutureTouchedVersions = new Set(); -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; -}; - 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): 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): Promise { - 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): 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, state: ConfigHealthState, ): Promise { - 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, 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)}`); } } diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index c45e9448967..79c1a623a90 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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", + }); }); });