mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(", ")}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user