From 9bb14527892fb451fbec032fe2f06d648c15932a Mon Sep 17 00:00:00 2001 From: momothemage Date: Fri, 3 Jul 2026 10:42:42 +0800 Subject: [PATCH] fix(config): archive matching config health legacy state --- src/infra/state-migrations.test.ts | 143 +++++++++++++++++++++++++++++ src/infra/state-migrations.ts | 88 +++++++++++++----- 2 files changed, 206 insertions(+), 25 deletions(-) diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index dae14a1d302c..dcd95ac0903d 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -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(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(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"); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index ac098fb50852..fbb56fa259ff 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -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; } }