fix(config): archive matching config health legacy state

This commit is contained in:
momothemage
2026-07-03 10:42:42 +08:00
parent 8d535fb039
commit 9bb1452789
2 changed files with 206 additions and 25 deletions

View File

@@ -1980,6 +1980,149 @@ describe("state migrations", () => {
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("abc123");
});
it("archives legacy config health JSON when SQLite already has the same config fingerprint", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const configPath = path.join(stateDir, "openclaw.json");
const logsDir = path.join(stateDir, "logs");
const sourcePath = path.join(logsDir, "config-health.json");
const sqliteFingerprint = {
hash: "abc123",
bytes: 42,
mtimeMs: 100,
ctimeMs: 200,
dev: "3",
ino: "4",
mode: 0o100600,
nlink: 1,
uid: 501,
gid: 20,
hasMeta: true,
gatewayMode: "local",
observedAt: "2026-01-17T09:30:00.000Z",
};
const legacyFingerprint = {
...sqliteFingerprint,
mtimeMs: 101,
ctimeMs: 201,
mode: 0o600,
observedAt: "2026-01-17T10:30:00.000Z",
};
const { db } = openOpenClawStateDatabase({ env });
const stateDb = getNodeSqliteKysely<ConfigHealthDatabase>(db);
executeSqliteQuerySync(
db,
stateDb.insertInto("config_health_entries").values({
config_path: configPath,
last_known_good_json: JSON.stringify(sqliteFingerprint),
last_promoted_good_json: JSON.stringify(sqliteFingerprint),
last_observed_suspicious_signature: "abc123:size-drop",
updated_at_ms: 1,
}),
);
await fs.mkdir(logsDir, { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
entries: {
[configPath]: {
lastKnownGood: legacyFingerprint,
lastPromotedGood: legacyFingerprint,
lastObservedSuspiciousSignature: "abc123:size-drop",
},
},
}),
"utf8",
);
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
const result = await runLegacyStateMigrations({ detected, config: cfg });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).not.toContain("Migrated 1 config health entry → shared SQLite state");
expect(result.changes).toContain(
`Archived config health state legacy source → ${sourcePath}.migrated`,
);
expect(readConfigHealthRows(env)).toEqual([
{
config_path: configPath,
last_known_good_json: JSON.stringify(sqliteFingerprint),
last_promoted_good_json: JSON.stringify(sqliteFingerprint),
last_observed_suspicious_signature: "abc123:size-drop",
},
]);
await expectMissingPath(sourcePath);
await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("abc123");
});
it("keeps legacy config health JSON when the stored config hash differs", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const configPath = path.join(stateDir, "openclaw.json");
const logsDir = path.join(stateDir, "logs");
const sourcePath = path.join(logsDir, "config-health.json");
const sqliteFingerprint = {
hash: "abc123",
bytes: 42,
mtimeMs: 100,
ctimeMs: 200,
dev: "3",
ino: "4",
mode: 0o100600,
nlink: 1,
uid: 501,
gid: 20,
hasMeta: true,
gatewayMode: "local",
observedAt: "2026-01-17T09:30:00.000Z",
};
const legacyFingerprint = {
...sqliteFingerprint,
hash: "def456",
mode: 0o600,
observedAt: "2026-01-17T10:30:00.000Z",
};
const { db } = openOpenClawStateDatabase({ env });
const stateDb = getNodeSqliteKysely<ConfigHealthDatabase>(db);
executeSqliteQuerySync(
db,
stateDb.insertInto("config_health_entries").values({
config_path: configPath,
last_known_good_json: JSON.stringify(sqliteFingerprint),
last_promoted_good_json: JSON.stringify(sqliteFingerprint),
last_observed_suspicious_signature: "abc123:size-drop",
updated_at_ms: 1,
}),
);
await fs.mkdir(logsDir, { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
entries: {
[configPath]: {
lastKnownGood: legacyFingerprint,
lastPromotedGood: legacyFingerprint,
lastObservedSuspiciousSignature: "abc123:size-drop",
},
},
}),
"utf8",
);
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
const result = await runLegacyStateMigrations({ detected, config: cfg });
expect(result.warnings).toEqual([
`Left legacy config health state in place because 1 entry conflicts with shared SQLite state: ${sourcePath}`,
]);
expect(result.changes).toStrictEqual([]);
await expect(fs.stat(sourcePath)).resolves.toMatchObject({ isFile: expect.any(Function) });
});
it("migrates legacy current-conversation bindings JSON into shared SQLite state", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");

View File

@@ -2087,14 +2087,65 @@ function configHealthRow(entry: LegacyConfigHealthEntry): {
};
}
function configHealthComparable(entry: LegacyConfigHealthEntry): string {
const row = configHealthRow(entry);
return JSON.stringify({
config_path: row.config_path,
last_known_good_json: row.last_known_good_json,
last_promoted_good_json: row.last_promoted_good_json,
last_observed_suspicious_signature: row.last_observed_suspicious_signature,
});
type ConfigHealthComparableRow = {
config_path: string;
last_known_good_json: string | null;
last_promoted_good_json: string | null;
last_observed_suspicious_signature: string | null;
};
type ConfigHealthComparableFingerprint = {
hash?: unknown;
bytes?: unknown;
mode?: unknown;
hasMeta?: unknown;
gatewayMode?: unknown;
};
function configHealthFingerprintKey(value: string | null): string | null {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const fingerprint = parsed as ConfigHealthComparableFingerprint;
const mode =
typeof fingerprint.mode === "number" && Number.isFinite(fingerprint.mode)
? fingerprint.mode & 0o777
: fingerprint.mode;
return JSON.stringify({
hash: fingerprint.hash,
bytes: fingerprint.bytes,
mode,
hasMeta: fingerprint.hasMeta,
gatewayMode: fingerprint.gatewayMode,
});
} catch {
return null;
}
}
function sameConfigHealthFingerprintJson(left: string | null, right: string | null): boolean {
if (left === right) {
return true;
}
const leftKey = configHealthFingerprintKey(left);
return leftKey !== null && leftKey === configHealthFingerprintKey(right);
}
function configHealthEntryMatchesRow(
row: ConfigHealthComparableRow,
entry: LegacyConfigHealthEntry,
): boolean {
return (
row.config_path === entry.configPath &&
sameConfigHealthFingerprintJson(row.last_known_good_json, entry.lastKnownGoodJson) &&
sameConfigHealthFingerprintJson(row.last_promoted_good_json, entry.lastPromotedGoodJson) &&
row.last_observed_suspicious_signature === entry.lastObservedSuspiciousSignature
);
}
function migrateLegacyConfigHealth(params: {
@@ -2133,27 +2184,14 @@ function migrateLegacyConfigHealth(params: {
"last_observed_suspicious_signature",
]),
).rows;
const existingByPath = new Map(
existing.map(
(row) =>
[
row.config_path,
JSON.stringify({
config_path: row.config_path,
last_known_good_json: row.last_known_good_json,
last_promoted_good_json: row.last_promoted_good_json,
last_observed_suspicious_signature: row.last_observed_suspicious_signature,
}),
] as const,
),
);
const existingByPath = new Map(existing.map((row) => [row.config_path, row] as const));
const entriesToInsert: LegacyConfigHealthEntry[] = [];
let conflictCount = 0;
for (const entry of entries) {
const existingEntryJson = existingByPath.get(entry.configPath);
if (existingEntryJson === undefined) {
const existingEntry = existingByPath.get(entry.configPath);
if (existingEntry === undefined) {
entriesToInsert.push(entry);
} else if (existingEntryJson !== configHealthComparable(entry)) {
} else if (!configHealthEntryMatchesRow(existingEntry, entry)) {
conflictCount += 1;
}
}