From 57513baa27822e39cb44c6230bfa4955fb0294dc Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:59:24 -0500 Subject: [PATCH] fix(config): preserve recovery state during config-health migration (#99728) * fix(config): reconcile legacy config health state * fix(config): reconcile legacy config health state --------- Co-authored-by: Josh Lehman --- src/infra/state-migrations.test.ts | 176 +++++++++++++++++++++++++++++ src/infra/state-migrations.ts | 105 ++++++++++------- 2 files changed, 243 insertions(+), 38 deletions(-) diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index dae14a1d302c..76cba74dfcf2 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -241,6 +241,26 @@ function readConfigHealthRows(env: NodeJS.ProcessEnv): Array<{ ).rows; } +function insertConfigHealthRow( + env: NodeJS.ProcessEnv, + row: { + config_path: string; + last_known_good_json: string | null; + last_promoted_good_json: string | null; + last_observed_suspicious_signature: string | null; + }, +): void { + const { db } = openOpenClawStateDatabase({ env }); + const stateDb = getNodeSqliteKysely(db); + executeSqliteQuerySync( + db, + stateDb.insertInto("config_health_entries").values({ + ...row, + updated_at_ms: Date.now(), + }), + ); +} + function readCurrentConversationBindingRows(env: NodeJS.ProcessEnv): Array<{ binding_key: string; binding_id: string; @@ -1980,6 +2000,162 @@ describe("state migrations", () => { await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("abc123"); }); + it("reconciles missing promoted config health state without replacing current SQLite fields", 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 importedConfigPath = path.join(stateDir, "imported.json"); + const sourcePath = path.join(stateDir, "logs", "config-health.json"); + const legacyFingerprint = { hash: "legacy", bytes: 10 }; + const currentFingerprint = { hash: "current", bytes: 20 }; + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + entries: { + [configPath]: { + lastKnownGood: legacyFingerprint, + lastPromotedGood: legacyFingerprint, + lastObservedSuspiciousSignature: "legacy:size-drop", + }, + [importedConfigPath]: { + lastKnownGood: legacyFingerprint, + lastPromotedGood: legacyFingerprint, + }, + }, + }), + "utf8", + ); + insertConfigHealthRow(env, { + config_path: configPath, + last_known_good_json: JSON.stringify(currentFingerprint), + last_promoted_good_json: null, + last_observed_suspicious_signature: null, + }); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain("Migrated 1 config health entry → shared SQLite state"); + expect(result.changes).toContain("Reconciled 1 config health entry → shared SQLite state"); + expect(readConfigHealthRows(env)).toEqual([ + { + config_path: importedConfigPath, + last_known_good_json: JSON.stringify(legacyFingerprint), + last_promoted_good_json: JSON.stringify(legacyFingerprint), + last_observed_suspicious_signature: null, + }, + { + config_path: configPath, + last_known_good_json: JSON.stringify(currentFingerprint), + last_promoted_good_json: JSON.stringify(legacyFingerprint), + last_observed_suspicious_signature: null, + }, + ]); + await expectMissingPath(sourcePath); + await expect(fs.readFile(`${sourcePath}.migrated`, "utf8")).resolves.toContain("legacy"); + }); + + it("keeps complete SQLite config health state when legacy fingerprints differ", 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 sourcePath = path.join(stateDir, "logs", "config-health.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + entries: { + [configPath]: { + lastKnownGood: { hash: "legacy-known" }, + lastPromotedGood: { hash: "legacy-promoted" }, + lastObservedSuspiciousSignature: "legacy:size-drop", + }, + }, + }), + "utf8", + ); + insertConfigHealthRow(env, { + config_path: configPath, + last_known_good_json: JSON.stringify({ hash: "current-known" }), + last_promoted_good_json: JSON.stringify({ hash: "current-promoted" }), + last_observed_suspicious_signature: "current:size-drop", + }); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes.some((change) => change.startsWith("Reconciled "))).toBe(false); + expect(readConfigHealthRows(env)).toEqual([ + { + config_path: configPath, + last_known_good_json: JSON.stringify({ hash: "current-known" }), + last_promoted_good_json: JSON.stringify({ hash: "current-promoted" }), + last_observed_suspicious_signature: "current:size-drop", + }, + ]); + await expectMissingPath(sourcePath); + await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined(); + }); + + it("removes a regenerated config health source when its archive already exists", 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 sourcePath = path.join(stateDir, "logs", "config-health.json"); + const archivedPath = `${sourcePath}.migrated`; + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ entries: { [configPath]: { lastKnownGood: { hash: "legacy" } } } }), + "utf8", + ); + await fs.writeFile(archivedPath, "existing archive", "utf8"); + insertConfigHealthRow(env, { + config_path: configPath, + last_known_good_json: JSON.stringify({ hash: "current" }), + last_promoted_good_json: JSON.stringify({ hash: "promoted" }), + last_observed_suspicious_signature: null, + }); + + const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain("Removed regenerated config health legacy source"); + await expectMissingPath(sourcePath); + await expect(fs.readFile(archivedPath, "utf8")).resolves.toBe("existing archive"); + }); + + it("leaves malformed legacy config health state in place", async () => { + const root = await createTempDir(); + const stateDir = path.join(root, ".openclaw"); + const cfg = createConfig(); + const sourcePath = path.join(stateDir, "logs", "config-health.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile(sourcePath, "{ malformed", "utf8"); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: createEnv(stateDir), + homedir: () => root, + }); + const result = await runLegacyStateMigrations({ detected, config: cfg }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed reading legacy config health state"); + await expect(fs.access(sourcePath)).resolves.toBeUndefined(); + await expectMissingPath(`${sourcePath}.migrated`); + }); + 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..6b8c8f1e1099 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2087,14 +2087,30 @@ 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, - }); +function retireLegacyConfigHealthSource(params: { + sourcePath: string; + changes: string[]; + warnings: string[]; +}): void { + const archivedPath = `${params.sourcePath}.migrated`; + if (!fileExists(archivedPath)) { + archiveLegacyImportSource({ + sourcePath: params.sourcePath, + label: "config health state", + changes: params.changes, + warnings: params.warnings, + }); + return; + } + + // Released macOS builds can recreate this source after it was archived. + // Once reconciled into SQLite, retaining it causes every run to warn again. + try { + fs.rmSync(params.sourcePath, { force: true }); + params.changes.push("Removed regenerated config health legacy source"); + } catch (err) { + params.warnings.push(`Failed removing regenerated config health legacy source: ${String(err)}`); + } } function migrateLegacyConfigHealth(params: { @@ -2117,9 +2133,10 @@ function migrateLegacyConfigHealth(params: { } let importedCount = 0; - let shouldArchive = entries.length === 0; + let reconciledCount = 0; + let shouldArchive = false; try { - runOpenClawStateWriteTransaction( + const result = runOpenClawStateWriteTransaction( ({ db }) => { const stateDb = getNodeSqliteKysely(db); const existing = executeSqliteQuerySync( @@ -2133,29 +2150,37 @@ 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; + let transactionReconciledCount = 0; for (const entry of entries) { - const existingEntryJson = existingByPath.get(entry.configPath); - if (existingEntryJson === undefined) { + const existingEntry = existingByPath.get(entry.configPath); + if (!existingEntry) { entriesToInsert.push(entry); - } else if (existingEntryJson !== configHealthComparable(entry)) { - conflictCount += 1; + continue; } + + const lastKnownGoodJson = existingEntry.last_known_good_json ?? entry.lastKnownGoodJson; + const lastPromotedGoodJson = + existingEntry.last_promoted_good_json ?? entry.lastPromotedGoodJson; + if ( + lastKnownGoodJson === existingEntry.last_known_good_json && + lastPromotedGoodJson === existingEntry.last_promoted_good_json + ) { + continue; + } + executeSqliteQuerySync( + db, + stateDb + .updateTable("config_health_entries") + .set({ + last_known_good_json: lastKnownGoodJson, + last_promoted_good_json: lastPromotedGoodJson, + updated_at_ms: Date.now(), + }) + .where("config_path", "=", entry.configPath), + ); + transactionReconciledCount += 1; } if (entriesToInsert.length > 0) { executeSqliteQuerySync( @@ -2164,17 +2189,17 @@ function migrateLegacyConfigHealth(params: { .insertInto("config_health_entries") .values(entriesToInsert.map(configHealthRow)), ); - importedCount = entriesToInsert.length; - } - shouldArchive = conflictCount === 0; - if (conflictCount > 0) { - warnings.push( - `Left legacy config health state in place because ${conflictCount} ${conflictCount === 1 ? "entry conflicts" : "entries conflict"} with shared SQLite state: ${params.detected.sourcePath}`, - ); } + return { + importedCount: entriesToInsert.length, + reconciledCount: transactionReconciledCount, + }; }, { env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } }, ); + importedCount = result.importedCount; + reconciledCount = result.reconciledCount; + shouldArchive = true; } catch (err) { warnings.push(`Failed migrating legacy config health state: ${String(err)}`); } @@ -2183,10 +2208,14 @@ function migrateLegacyConfigHealth(params: { `Migrated ${importedCount} config health ${importedCount === 1 ? "entry" : "entries"} → shared SQLite state`, ); } + if (reconciledCount > 0) { + changes.push( + `Reconciled ${reconciledCount} config health ${reconciledCount === 1 ? "entry" : "entries"} → shared SQLite state`, + ); + } if (shouldArchive) { - archiveLegacyImportSource({ + retireLegacyConfigHealthSource({ sourcePath: params.detected.sourcePath, - label: "config health state", changes, warnings, });