Skip to content

Commit 6daabd2

Browse files
committed
fix(deadcode): move config health state to sqlite
1 parent 2f33999 commit 6daabd2

9 files changed

Lines changed: 592 additions & 242 deletions

src/commands/doctor.e2e-harness.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ function createLegacyStateMigrationDetectionResult(params?: {
251251
sourcePath: "/tmp/state/update-check.json",
252252
hasLegacy: false,
253253
},
254+
configHealth: {
255+
sourcePath: "/tmp/state/logs/config-health.json",
256+
hasLegacy: false,
257+
},
254258
pluginBindingApprovals: {
255259
sourcePath: "/tmp/state/plugin-binding-approvals.json",
256260
hasLegacy: false,

src/config/io.best-effort.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// Covers best-effort config IO reads and warning behavior.
22
import fs from "node:fs/promises";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
5+
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
6+
import {
7+
closeOpenClawStateDatabaseForTest,
8+
openOpenClawStateDatabase,
9+
} from "../state/openclaw-state-db.js";
410
import { withEnvAsync } from "../test-utils/env.js";
511
import {
612
readBestEffortConfig,
@@ -10,10 +16,28 @@ import {
1016
} from "./config.js";
1117
import { withTempHome, writeOpenClawConfig } from "./test-helpers.js";
1218

19+
type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">;
20+
21+
function readConfigHealthRow(env: NodeJS.ProcessEnv, configPath: string) {
22+
const { db } = openOpenClawStateDatabase({ env });
23+
const healthDb = getNodeSqliteKysely<ConfigHealthDatabase>(db);
24+
return executeSqliteQueryTakeFirstSync(
25+
db,
26+
healthDb
27+
.selectFrom("config_health_entries")
28+
.select(["config_path", "last_known_good_json"])
29+
.where("config_path", "=", configPath),
30+
);
31+
}
32+
1333
describe("readBestEffortConfig", () => {
34+
afterEach(() => {
35+
closeOpenClawStateDatabaseForTest();
36+
});
37+
1438
it("can read snapshots without updating config observation state", async () => {
1539
await withTempHome(async (home) => {
16-
await writeOpenClawConfig(home, {
40+
const configPath = await writeOpenClawConfig(home, {
1741
gateway: { mode: "local" },
1842
});
1943

@@ -24,7 +48,11 @@ describe("readBestEffortConfig", () => {
2448

2549
await readConfigFileSnapshot();
2650

27-
await expect(fs.stat(healthPath)).resolves.toMatchObject({ isFile: expect.any(Function) });
51+
await expect(fs.stat(healthPath)).rejects.toMatchObject({ code: "ENOENT" });
52+
expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toMatchObject({
53+
config_path: configPath,
54+
last_known_good_json: expect.any(String),
55+
});
2856
});
2957
});
3058

src/config/io.health-state.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
2+
// Stores config health fingerprints in shared SQLite state.
3+
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
4+
import {
5+
openOpenClawStateDatabase,
6+
runOpenClawStateWriteTransaction,
7+
} from "../state/openclaw-state-db.js";
8+
9+
export type ConfigHealthFingerprint = {
10+
hash: string;
11+
bytes: number;
12+
mtimeMs: number | null;
13+
ctimeMs: number | null;
14+
dev: string | null;
15+
ino: string | null;
16+
mode: number | null;
17+
nlink: number | null;
18+
uid: number | null;
19+
gid: number | null;
20+
hasMeta: boolean;
21+
gatewayMode: string | null;
22+
observedAt: string;
23+
};
24+
25+
export type ConfigHealthEntry = {
26+
lastKnownGood?: ConfigHealthFingerprint;
27+
lastPromotedGood?: ConfigHealthFingerprint;
28+
lastObservedSuspiciousSignature?: string | null;
29+
};
30+
31+
export type ConfigHealthState = {
32+
entries?: Record<string, ConfigHealthEntry>;
33+
};
34+
35+
type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">;
36+
37+
export type ConfigHealthStateDeps = {
38+
env: NodeJS.ProcessEnv;
39+
homedir: () => string;
40+
logger: Pick<typeof console, "warn">;
41+
};
42+
43+
function resolveConfigHealthStateEnv(deps: ConfigHealthStateDeps): NodeJS.ProcessEnv {
44+
if (deps.env.OPENCLAW_HOME || deps.env.HOME || deps.env.USERPROFILE || deps.env.PREFIX) {
45+
return deps.env;
46+
}
47+
return { ...deps.env, HOME: deps.homedir() };
48+
}
49+
50+
function parseConfigHealthFingerprint(value: string | null): ConfigHealthFingerprint | undefined {
51+
if (!value) {
52+
return undefined;
53+
}
54+
try {
55+
const parsed = JSON.parse(value) as ConfigHealthFingerprint;
56+
return parsed && typeof parsed === "object" ? parsed : undefined;
57+
} catch {
58+
return undefined;
59+
}
60+
}
61+
62+
function stringifyConfigHealthFingerprint(
63+
value: ConfigHealthFingerprint | undefined,
64+
): string | null {
65+
return value ? JSON.stringify(value) : null;
66+
}
67+
68+
function formatConfigHealthStateError(error: unknown): string {
69+
return error instanceof Error ? error.message : String(error);
70+
}
71+
72+
export function readConfigHealthStateFromStore(deps: ConfigHealthStateDeps): ConfigHealthState {
73+
try {
74+
const database = openOpenClawStateDatabase({ env: resolveConfigHealthStateEnv(deps) });
75+
const healthDb = getNodeSqliteKysely<ConfigHealthDatabase>(database.db);
76+
const rows = executeSqliteQuerySync(
77+
database.db,
78+
healthDb
79+
.selectFrom("config_health_entries")
80+
.select([
81+
"config_path",
82+
"last_known_good_json",
83+
"last_promoted_good_json",
84+
"last_observed_suspicious_signature",
85+
])
86+
.orderBy("config_path", "asc"),
87+
).rows;
88+
return {
89+
entries: Object.fromEntries(
90+
rows.map((row) => [
91+
row.config_path,
92+
{
93+
lastKnownGood: parseConfigHealthFingerprint(row.last_known_good_json),
94+
lastPromotedGood: parseConfigHealthFingerprint(row.last_promoted_good_json),
95+
lastObservedSuspiciousSignature: row.last_observed_suspicious_signature,
96+
} satisfies ConfigHealthEntry,
97+
]),
98+
),
99+
};
100+
} catch {
101+
return {};
102+
}
103+
}
104+
105+
export function writeConfigHealthStateToStore(
106+
deps: ConfigHealthStateDeps,
107+
state: ConfigHealthState,
108+
): void {
109+
try {
110+
const entries = Object.entries(state.entries ?? {});
111+
if (entries.length === 0) {
112+
return;
113+
}
114+
const updatedAtMs = Date.now();
115+
runOpenClawStateWriteTransaction(
116+
({ db }) => {
117+
const healthDb = getNodeSqliteKysely<ConfigHealthDatabase>(db);
118+
executeSqliteQuerySync(
119+
db,
120+
healthDb
121+
.insertInto("config_health_entries")
122+
.values(
123+
entries.map(([configPath, entry]) => ({
124+
config_path: configPath,
125+
last_known_good_json: stringifyConfigHealthFingerprint(entry.lastKnownGood),
126+
last_promoted_good_json: stringifyConfigHealthFingerprint(entry.lastPromotedGood),
127+
last_observed_suspicious_signature: entry.lastObservedSuspiciousSignature ?? null,
128+
updated_at_ms: updatedAtMs,
129+
})),
130+
)
131+
.onConflict((conflict) =>
132+
conflict.column("config_path").doUpdateSet({
133+
last_known_good_json: (eb) => eb.ref("excluded.last_known_good_json"),
134+
last_promoted_good_json: (eb) => eb.ref("excluded.last_promoted_good_json"),
135+
last_observed_suspicious_signature: (eb) =>
136+
eb.ref("excluded.last_observed_suspicious_signature"),
137+
updated_at_ms: (eb) => eb.ref("excluded.updated_at_ms"),
138+
}),
139+
),
140+
);
141+
},
142+
{ env: resolveConfigHealthStateEnv(deps) },
143+
);
144+
} catch (error) {
145+
deps.logger.warn(`Config health-state write failed: ${formatConfigHealthStateError(error)}`);
146+
}
147+
}

src/config/io.observe-recovery.test.ts

Lines changed: 50 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import fsp from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
66
import JSON5 from "json5";
7-
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
7+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
8+
import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
9+
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
10+
import {
11+
closeOpenClawStateDatabaseForTest,
12+
openOpenClawStateDatabase,
13+
} from "../state/openclaw-state-db.js";
814
import { createConfigIO } from "./io.js";
915
import {
1016
maybeRecoverSuspiciousConfigRead,
@@ -17,6 +23,7 @@ import {
1723
import type { ConfigFileSnapshot } from "./types.js";
1824

1925
const CONFIG_CLOBBER_SNAPSHOT_LIMIT = 32;
26+
type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">;
2027

2128
describe("config observe recovery", () => {
2229
let fixtureRoot = "";
@@ -41,9 +48,31 @@ describe("config observe recovery", () => {
4148
});
4249

4350
afterAll(async () => {
51+
closeOpenClawStateDatabaseForTest();
4452
await fsp.rm(fixtureRoot, { recursive: true, force: true });
4553
});
4654

55+
afterEach(() => {
56+
closeOpenClawStateDatabaseForTest();
57+
});
58+
59+
function readConfigHealthRow(home: string, configPath: string) {
60+
const { db } = openOpenClawStateDatabase({ env: { HOME: home } as NodeJS.ProcessEnv });
61+
const healthDb = getNodeSqliteKysely<ConfigHealthDatabase>(db);
62+
return executeSqliteQueryTakeFirstSync(
63+
db,
64+
healthDb
65+
.selectFrom("config_health_entries")
66+
.select([
67+
"config_path",
68+
"last_known_good_json",
69+
"last_promoted_good_json",
70+
"last_observed_suspicious_signature",
71+
])
72+
.where("config_path", "=", configPath),
73+
);
74+
}
75+
4776
async function seedConfig(configPath: string, config: Record<string, unknown>): Promise<void> {
4877
await fsp.mkdir(path.dirname(configPath), { recursive: true });
4978
await fsp.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
@@ -243,47 +272,6 @@ describe("config observe recovery", () => {
243272
};
244273
}
245274

246-
function withAsyncHealthWriteFailure(
247-
deps: ObserveRecoveryDeps,
248-
healthPath: string,
249-
): ObserveRecoveryDeps {
250-
const writeFile = deps.fs.promises.writeFile.bind(deps.fs.promises);
251-
return {
252-
...deps,
253-
fs: {
254-
...deps.fs,
255-
promises: {
256-
...deps.fs.promises,
257-
writeFile: async (target, data, options) => {
258-
if (target === healthPath) {
259-
throw new Error("health write failed");
260-
}
261-
return await writeFile(target, data, options);
262-
},
263-
},
264-
},
265-
};
266-
}
267-
268-
function withSyncHealthWriteFailure(
269-
deps: ObserveRecoveryDeps,
270-
healthPath: string,
271-
): ObserveRecoveryDeps {
272-
const writeFileSync = deps.fs.writeFileSync.bind(deps.fs);
273-
return {
274-
...deps,
275-
fs: {
276-
...deps.fs,
277-
writeFileSync: (target, data, options) => {
278-
if (target === healthPath) {
279-
throw new Error("health write failed");
280-
}
281-
return writeFileSync(target, data, options);
282-
},
283-
},
284-
};
285-
}
286-
287275
it("auto-restores suspicious update-channel-only roots from backup", async () => {
288276
await withSuiteHome(async (home) => {
289277
const { deps, configPath, auditPath, warn } = makeDeps(home);
@@ -953,43 +941,42 @@ describe("config observe recovery", () => {
953941
});
954942
});
955943

956-
it("logs async health-state write failures", async () => {
944+
it("writes async health state to SQLite", async () => {
957945
await withSuiteHome(async (home) => {
958946
const { deps, configPath, warn } = makeDeps(home);
959947
const snapshot = await makeSnapshot(configPath, recoverableTelegramConfig);
960-
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
961948

962949
await expect(
963-
promoteConfigSnapshotToLastKnownGood({
964-
deps: withAsyncHealthWriteFailure(deps, healthPath),
965-
snapshot,
966-
logger: deps.logger,
967-
}),
950+
promoteConfigSnapshotToLastKnownGood({ deps, snapshot, logger: deps.logger }),
968951
).resolves.toBe(true);
969952

970-
expectWarnContaining(
971-
warn,
972-
`Config health-state write failed: ${healthPath}: health write failed`,
973-
);
953+
await expectPathMissing(path.join(home, ".openclaw", "logs", "config-health.json"));
954+
const row = readConfigHealthRow(home, configPath);
955+
expect(row).toMatchObject({
956+
config_path: configPath,
957+
last_known_good_json: expect.any(String),
958+
last_promoted_good_json: expect.any(String),
959+
last_observed_suspicious_signature: null,
960+
});
961+
expectWarnNotContaining(warn, "Config health-state write failed");
974962
});
975963
});
976964

977-
it("logs sync health-state write failures", async () => {
965+
it("writes sync health state to SQLite", async () => {
978966
await withSuiteHome(async (home) => {
979967
const { deps, configPath, warn } = makeDeps(home);
980-
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
981968
await seedConfigBackup(configPath, recoverableTelegramConfig);
982969
await writeClobberedUpdateChannel(configPath);
983970

984-
recoverClobberedUpdateChannelSync({
985-
deps: withSyncHealthWriteFailure(deps, healthPath),
986-
configPath,
987-
});
971+
recoverClobberedUpdateChannelSync({ deps, configPath });
988972

989-
expectWarnContaining(
990-
warn,
991-
`Config health-state write failed: ${healthPath}: health write failed`,
992-
);
973+
await expectPathMissing(path.join(home, ".openclaw", "logs", "config-health.json"));
974+
const row = readConfigHealthRow(home, configPath);
975+
expect(row).toMatchObject({
976+
config_path: configPath,
977+
last_observed_suspicious_signature: expect.any(String),
978+
});
979+
expectWarnNotContaining(warn, "Config health-state write failed");
993980
});
994981
});
995982

0 commit comments

Comments
 (0)