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 <josh@martian.engineering>
This commit is contained in:
Josh Avant
2026-07-04 00:59:24 -05:00
committed by GitHub
parent 9aa4250c58
commit 57513baa27
2 changed files with 243 additions and 38 deletions
+176
View File
@@ -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<ConfigHealthDatabase>(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");
+67 -38
View File
@@ -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<LegacyConfigHealthImportDatabase>(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,
});