mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user