Skip to content

Commit eb31b1f

Browse files
committed
fix(state): retire superseded plugin install index
1 parent 1a3ce7c commit eb31b1f

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

src/commands/doctor-state-migrations.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,83 @@ describe("doctor legacy state migrations", () => {
14081408
});
14091409
});
14101410

1411+
it("archives legacy plugin install index when current npm records supersede older records", async () => {
1412+
const root = await makeTempRoot();
1413+
await writeExistingPluginInstallIndex(root, {
1414+
codex: {
1415+
source: "npm",
1416+
spec: "@openclaw/codex",
1417+
installPath: "/state/npm/projects/openclaw-codex-current",
1418+
resolvedName: "@openclaw/codex",
1419+
resolvedVersion: "2026.6.1",
1420+
resolvedSpec: "@openclaw/codex@2026.6.1",
1421+
installedAt: "2026-06-01T21:04:35.000Z",
1422+
},
1423+
discord: {
1424+
source: "npm",
1425+
spec: "@openclaw/discord",
1426+
installPath: "/state/npm/projects/openclaw-discord-current",
1427+
resolvedName: "@openclaw/discord",
1428+
resolvedVersion: "2026.6.1",
1429+
resolvedSpec: "@openclaw/discord@2026.6.1",
1430+
installedAt: "2026-06-01T21:04:36.000Z",
1431+
},
1432+
});
1433+
const sourcePath = writeLegacyPluginInstallIndex(root, {
1434+
codex: {
1435+
source: "npm",
1436+
spec: "@openclaw/codex@2026.5.18",
1437+
version: "2026.5.18",
1438+
},
1439+
discord: {
1440+
source: "npm",
1441+
spec: "@openclaw/discord@2026.5.18",
1442+
version: "2026.5.18",
1443+
},
1444+
});
1445+
1446+
const result = await runLegacyStateMigrationsForRoot(root);
1447+
1448+
expect(result.warnings).toStrictEqual([]);
1449+
expect(fs.existsSync(sourcePath)).toBe(false);
1450+
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
1451+
await expect(readPersistedInstalledPluginIndex({ stateDir: root })).resolves.toMatchObject({
1452+
installRecords: {
1453+
codex: { resolvedVersion: "2026.6.1" },
1454+
discord: { resolvedVersion: "2026.6.1" },
1455+
},
1456+
});
1457+
});
1458+
1459+
it("keeps legacy plugin install index when npm records would replace newer metadata", async () => {
1460+
const root = await makeTempRoot();
1461+
await writeExistingPluginInstallIndex(root, {
1462+
codex: {
1463+
source: "npm",
1464+
spec: "@openclaw/codex",
1465+
installPath: "/state/npm/projects/openclaw-codex-current",
1466+
resolvedName: "@openclaw/codex",
1467+
resolvedVersion: "2026.5.18",
1468+
resolvedSpec: "@openclaw/codex@2026.5.18",
1469+
},
1470+
});
1471+
const sourcePath = writeLegacyPluginInstallIndex(root, {
1472+
codex: {
1473+
source: "npm",
1474+
spec: "@openclaw/codex@2026.6.1",
1475+
version: "2026.6.1",
1476+
},
1477+
});
1478+
1479+
const result = await runLegacyStateMigrationsForRoot(root);
1480+
1481+
expect(result.warnings).toStrictEqual([
1482+
"Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: codex",
1483+
]);
1484+
expect(fs.existsSync(sourcePath)).toBe(true);
1485+
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
1486+
});
1487+
14111488
for (const fixture of [
14121489
{
14131490
label: "name different packages",

src/infra/state-migrations.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ import {
6464
getNodeSqliteKysely,
6565
} from "./kysely-sync.js";
6666
import { requireNodeSqlite } from "./node-sqlite.js";
67+
import { compareOpenClawReleaseVersions, parseRegistryNpmSpec } from "./npm-registry-spec.js";
6768
import { isWithinDir } from "./path-safety.js";
69+
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
6870
import {
6971
ensureDir,
7072
existsDir,
@@ -403,13 +405,95 @@ function legacyInstallRecordHasCurrentResolvedIdentity(params: {
403405
return Boolean(legacyResolvedSpec && currentResolvedSpec === legacyResolvedSpec);
404406
}
405407

408+
function readNpmInstallRecordPackageName(
409+
record: InstalledPluginIndex["installRecords"][string],
410+
): string | undefined {
411+
const resolvedName = readInstallRecordStringField(record, "resolvedName")?.trim();
412+
if (resolvedName) {
413+
return resolvedName;
414+
}
415+
for (const key of ["resolvedSpec", "spec"]) {
416+
const spec = readInstallRecordStringField(record, key);
417+
if (!spec) {
418+
continue;
419+
}
420+
const parsed = parseRegistryNpmSpec(spec);
421+
if (parsed?.name) {
422+
return parsed.name;
423+
}
424+
}
425+
return undefined;
426+
}
427+
428+
function readNpmInstallRecordVersion(
429+
record: InstalledPluginIndex["installRecords"][string],
430+
): string | undefined {
431+
const resolvedVersion = readInstallRecordStringField(record, "resolvedVersion")?.trim();
432+
if (resolvedVersion) {
433+
return resolvedVersion;
434+
}
435+
const version = readInstallRecordStringField(record, "version")?.trim();
436+
if (version) {
437+
return version;
438+
}
439+
for (const key of ["resolvedSpec", "spec"]) {
440+
const spec = readInstallRecordStringField(record, key);
441+
const parsed = spec ? parseRegistryNpmSpec(spec) : null;
442+
if (parsed?.selectorKind === "exact-version" && parsed.selector) {
443+
return parsed.selector;
444+
}
445+
}
446+
return undefined;
447+
}
448+
449+
function compareInstallRecordVersions(
450+
currentVersion: string,
451+
legacyVersion: string,
452+
): number | null {
453+
const openClawVersionComparison = compareOpenClawReleaseVersions(currentVersion, legacyVersion);
454+
if (openClawVersionComparison !== null) {
455+
return openClawVersionComparison;
456+
}
457+
return compareComparableSemver(
458+
parseComparableSemver(currentVersion, { normalizeLegacyDotBeta: true }),
459+
parseComparableSemver(legacyVersion, { normalizeLegacyDotBeta: true }),
460+
);
461+
}
462+
463+
function legacyNpmInstallRecordSupersededByCurrent(params: {
464+
currentRecord: InstalledPluginIndex["installRecords"][string];
465+
legacyRecord: InstalledPluginIndex["installRecords"][string];
466+
}): boolean {
467+
const { currentRecord, legacyRecord } = params;
468+
if (currentRecord.source !== "npm" || legacyRecord.source !== "npm") {
469+
return false;
470+
}
471+
const currentPackageName = readNpmInstallRecordPackageName(currentRecord);
472+
const legacyPackageName = readNpmInstallRecordPackageName(legacyRecord);
473+
if (!currentPackageName || currentPackageName !== legacyPackageName) {
474+
return false;
475+
}
476+
if (!readInstallRecordStringField(currentRecord, "installPath")) {
477+
return false;
478+
}
479+
const currentVersion = readNpmInstallRecordVersion(currentRecord);
480+
const legacyVersion = readNpmInstallRecordVersion(legacyRecord);
481+
if (!currentVersion || !legacyVersion) {
482+
return false;
483+
}
484+
return compareInstallRecordVersions(currentVersion, legacyVersion) === 1;
485+
}
486+
406487
function legacyInstallRecordCoveredByCurrent(
407488
currentRecord: InstalledPluginIndex["installRecords"][string],
408489
legacyRecord: InstalledPluginIndex["installRecords"][string],
409490
): boolean {
410491
if (currentRecord.source !== legacyRecord.source) {
411492
return false;
412493
}
494+
if (legacyNpmInstallRecordSupersededByCurrent({ currentRecord, legacyRecord })) {
495+
return true;
496+
}
413497
for (const key of Object.keys(legacyRecord).toSorted()) {
414498
const currentValue = readInstallRecordField(currentRecord, key);
415499
if (currentValue === readInstallRecordField(legacyRecord, key)) {

0 commit comments

Comments
 (0)