mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
refactor: store config health in sqlite
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
77
src/config/health-state.ts
Normal file
77
src/config/health-state.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user