fix(state): prefer SQLite plugin metadata during migration (#107961)

* fix(state): prefer SQLite plugin install metadata

* test(gateway): type hidden boundary metadata

* test(ci): prune retired QA Matrix line baseline

* test(ci): prune retired QA Matrix line baseline

* fix(state): ignore legacy plugin JSON after SQLite

* fix(state): prefer SQLite plugin install metadata

* fix(state): keep legacy JSON inert after SQLite migration

* fix(state): prefer SQLite plugin install metadata

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Patrick Erichsen
2026-07-14 21:41:19 -07:00
committed by GitHub
parent c508e4a2b8
commit ef110e9a0f
2 changed files with 154 additions and 104 deletions
+147 -69
View File
@@ -16,15 +16,11 @@ import {
} from "../plugin-state/plugin-state-store.js";
import { setMaxPluginStateEntriesPerPluginForTests } from "../plugin-state/plugin-state-store.sqlite.js";
import { seedPluginStateEntriesForTests } from "../plugin-state/plugin-state-store.test-helpers.js";
import { hashJson } from "../plugins/installed-plugin-index-hash.js";
import {
readPersistedInstalledPluginIndex,
writePersistedInstalledPluginIndex,
} from "../plugins/installed-plugin-index-store.js";
import type {
InstalledPluginIndexRecord,
InstalledPluginInstallRecordInfo,
} from "../plugins/installed-plugin-index.js";
import type { InstalledPluginInstallRecordInfo } from "../plugins/installed-plugin-index.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
@@ -397,32 +393,7 @@ function writeLegacyDebugProxyCaptureSidecar(
async function writeExistingPluginInstallIndex(
root: string,
installRecords: Record<string, InstalledPluginInstallRecordInfo>,
options: { canonicalPluginIds?: readonly string[] } = {},
): Promise<void> {
const canonicalPluginIds = new Set(options.canonicalPluginIds ?? []);
const plugins: InstalledPluginIndexRecord[] = Object.entries(installRecords).flatMap(
([pluginId, installRecord]) =>
canonicalPluginIds.has(pluginId)
? [
{
pluginId,
installRecordHash: hashJson(installRecord),
manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`,
manifestHash: "test",
rootDir: `/plugins/${pluginId}`,
origin: "global",
enabled: false,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
]
: [],
);
await writePersistedInstalledPluginIndex(
{
version: 1,
@@ -432,7 +403,7 @@ async function writeExistingPluginInstallIndex(
policyHash: "test",
generatedAtMs: 1,
installRecords,
plugins,
plugins: [],
diagnostics: [],
},
{ stateDir: root },
@@ -2373,7 +2344,7 @@ describe("doctor legacy state migrations", () => {
});
});
it("keeps exact legacy npm install record when SQLite lacks authoritative package identity", async () => {
it("archives conflicting legacy npm metadata when SQLite has the plugin install record", async () => {
const root = await makeTempRoot();
await writeExistingPluginInstallIndex(root, {
demo: {
@@ -2392,37 +2363,6 @@ describe("doctor legacy state migrations", () => {
const result = await runLegacyStateMigrationsForRoot(root);
expect(result.warnings).toStrictEqual([
"Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: demo",
]);
expect(result.notices).toBeUndefined();
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
});
it("archives differing legacy metadata for a disabled canonical plugin", async () => {
const root = await makeTempRoot();
await writeExistingPluginInstallIndex(
root,
{
demo: {
source: "npm",
spec: "demo@latest",
version: "1.0.0",
},
},
{ canonicalPluginIds: ["demo"] },
);
const sourcePath = writeLegacyPluginInstallIndex(root, {
demo: {
source: "npm",
spec: "demo@1.0.0",
version: "1.0.0",
},
});
const result = await runLegacyStateMigrationsForRoot(root);
expect(result.warnings).toStrictEqual([]);
expect(result.notices).toStrictEqual([
"Kept canonical shared SQLite plugin install metadata despite differing legacy records for: demo",
@@ -2440,6 +2380,144 @@ describe("doctor legacy state migrations", () => {
});
});
it("converges the reported plugin, update-check, and config-health conflicts", async () => {
const root = await makeTempRoot();
const env = { ...process.env, OPENCLAW_STATE_DIR: root };
const configPath = path.join(root, "openclaw.json");
const pluginSourcePath = writeLegacyPluginInstallIndex(root, {
demo: {
source: "npm",
spec: "demo@beta",
version: "2.0.0-beta.1",
},
});
const updateCheckSourcePath = path.join(root, "update-check.json");
const configHealthSourcePath = path.join(root, "logs", "config-health.json");
await writeExistingPluginInstallIndex(root, {
demo: {
source: "npm",
spec: "demo@1.0.0",
version: "1.0.0",
},
});
fs.writeFileSync(
updateCheckSourcePath,
JSON.stringify({
lastCheckedAt: "2026-07-01T00:00:00.000Z",
lastAvailableVersion: "2026.7.1",
}),
"utf8",
);
fs.mkdirSync(path.dirname(configHealthSourcePath), { recursive: true });
fs.writeFileSync(
configHealthSourcePath,
JSON.stringify({
entries: {
[configPath]: {
lastKnownGood: { hash: "legacy" },
lastPromotedGood: { hash: "legacy" },
lastObservedSuspiciousSignature: "legacy:size-drop",
},
},
}),
"utf8",
);
const { db } = openOpenClawStateDatabase({ env });
db.prepare(
`INSERT INTO update_check_state (
state_key, last_checked_at, last_available_version, updated_at_ms
) VALUES (?, ?, ?, ?)`,
).run("default", "2026-07-14T00:00:00.000Z", "2026.7.2", 1);
db.prepare(
`INSERT INTO config_health_entries (
config_path, last_known_good_json, last_promoted_good_json,
last_observed_suspicious_signature, updated_at_ms
) VALUES (?, ?, ?, ?, ?)`,
).run(
configPath,
JSON.stringify({ hash: "sqlite-known" }),
JSON.stringify({ hash: "sqlite-promoted" }),
"sqlite:size-drop",
1,
);
const result = await runLegacyStateMigrationsForRoot(root);
expect(result.warnings).toStrictEqual([]);
expect(result.notices).toEqual(
expect.arrayContaining([
"Kept canonical shared SQLite plugin install metadata despite differing legacy records for: demo",
expect.stringContaining(
"Kept shared SQLite update-check state because legacy cache differs",
),
]),
);
for (const sourcePath of [pluginSourcePath, updateCheckSourcePath, configHealthSourcePath]) {
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
}
await expect(readPersistedInstalledPluginIndex({ stateDir: root })).resolves.toMatchObject({
installRecords: {
demo: { source: "npm", spec: "demo@1.0.0", version: "1.0.0" },
},
});
expect(
db
.prepare(
"SELECT last_available_version FROM update_check_state WHERE state_key = 'default'",
)
.get(),
).toMatchObject({ last_available_version: "2026.7.2" });
expect(
db
.prepare("SELECT last_known_good_json FROM config_health_entries WHERE config_path = ?")
.get(configPath),
).toMatchObject({ last_known_good_json: JSON.stringify({ hash: "sqlite-known" }) });
const retry = await runLegacyStateMigrationsForRoot(root);
expect(retry).toMatchObject({ changes: [], warnings: [] });
expect(retry.notices).toBeUndefined();
});
it("keeps plugin install archive failures blocking after choosing SQLite metadata", async () => {
const root = await makeTempRoot();
await writeExistingPluginInstallIndex(root, {
demo: {
source: "npm",
spec: "demo@latest",
version: "1.0.0",
},
});
const sourcePath = writeLegacyPluginInstallIndex(root, {
demo: {
source: "npm",
spec: "demo@1.0.0",
version: "1.0.0",
},
});
const rename = failRenameOnce(sourcePath);
const result = await runLegacyStateMigrationsForRoot(root);
rename.mockRestore();
expect(result.warnings).toStrictEqual([
`Failed archiving plugin install index ${sourcePath}: Error: forced archive failure`,
]);
expect(result.notices).toStrictEqual([
"Kept canonical shared SQLite plugin install metadata despite differing legacy records for: demo",
]);
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
const retry = await runLegacyStateMigrationsForRoot(root);
expect(retry.warnings).toStrictEqual([]);
expect(retry.notices).toStrictEqual([
"Kept canonical shared SQLite plugin install metadata despite differing legacy records for: demo",
]);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
});
for (const fixture of [
{
label: "name different packages",
@@ -2543,19 +2621,19 @@ describe("doctor legacy state migrations", () => {
current: InstalledPluginInstallRecordInfo;
legacy: InstalledPluginInstallRecordInfo;
}>) {
it(`keeps legacy plugin install index when same-version npm records ${fixture.label}`, async () => {
it(`keeps SQLite plugin metadata when legacy npm records ${fixture.label}`, async () => {
const root = await makeTempRoot();
await writeExistingPluginInstallIndex(root, { demo: fixture.current });
const sourcePath = writeLegacyPluginInstallIndex(root, { demo: fixture.legacy });
const result = await runLegacyStateMigrationsForRoot(root);
expect(result.warnings).toStrictEqual([
"Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: demo",
expect(result.warnings).toStrictEqual([]);
expect(result.notices).toStrictEqual([
"Kept canonical shared SQLite plugin install metadata despite differing legacy records for: demo",
]);
expect(result.notices).toBeUndefined();
expect(fs.existsSync(sourcePath)).toBe(true);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
});
}
+7 -35
View File
@@ -6,13 +6,11 @@ import {
createPluginStateKeyedStore,
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
} from "../plugin-state/plugin-state-store.js";
import { hashJson } from "../plugins/installed-plugin-index-hash.js";
import {
readPersistedInstalledPluginIndexSync,
resolveLegacyInstalledPluginIndexStorePath,
writePersistedInstalledPluginIndexSync,
} from "../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import {
@@ -41,19 +39,6 @@ import type { MigrationMessages } from "./state-migrations.types.js";
type LegacyPluginStateImportDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_state_entries">;
function hasCanonicalInstallRecord(current: InstalledPluginIndex, pluginId: string): boolean {
const installRecord = current.installRecords[pluginId];
if (!installRecord) {
return false;
}
// A manifest record bound to this exact install record proves SQLite owns the plugin,
// even when disabled. Keep all other conflicts blocking so stale metadata is not accepted.
return current.plugins.some(
(plugin) =>
plugin.pluginId === pluginId && plugin.installRecordHash === hashJson(installRecord),
);
}
export async function migrateLegacyPluginStateSidecar(params: {
stateDir: string;
}): Promise<{ changes: string[]; warnings: string[] }> {
@@ -212,28 +197,15 @@ export async function migrateLegacyInstalledPluginIndex(params: {
}
}
if (merged.conflicts.length > 0) {
const acknowledged = merged.conflicts.filter((pluginId) =>
hasCanonicalInstallRecord(current, pluginId),
);
const unresolved = merged.conflicts.filter((pluginId) => !acknowledged.includes(pluginId));
if (unresolved.length === 0) {
archiveLegacyInstalledPluginIndex({ sourcePath, changes, warnings });
}
// SQLite owns the install ledger; discovery can omit disabled or currently unloadable plugins.
// Archive the retired JSON for recovery instead of blocking startup on conflicting metadata.
archiveLegacyInstalledPluginIndex({ sourcePath, changes, warnings });
return {
changes,
warnings:
unresolved.length > 0
? [
`Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: ${unresolved.join(", ")}`,
]
: [],
...(acknowledged.length > 0
? {
notices: [
`Kept canonical shared SQLite plugin install metadata despite differing legacy records for: ${acknowledged.join(", ")}`,
],
}
: {}),
warnings,
notices: [
`Kept canonical shared SQLite plugin install metadata despite differing legacy records for: ${merged.conflicts.join(", ")}`,
],
};
}
}