@@ -15,6 +15,7 @@ import {
1515 readPersistedInstalledPluginIndex ,
1616 writePersistedInstalledPluginIndex ,
1717} from "../plugins/installed-plugin-index-store.js" ;
18+ import type { InstalledPluginInstallRecordInfo } from "../plugins/installed-plugin-index.js" ;
1819import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js" ;
1920import { loadTaskFlowRegistryStateFromSqlite } from "../tasks/task-flow-registry.store.sqlite.js" ;
2021import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js" ;
@@ -299,6 +300,44 @@ function writeLegacyPluginStateSidecar(root: string): string {
299300 return sourcePath ;
300301}
301302
303+ async function writeExistingPluginInstallIndex (
304+ root : string ,
305+ installRecords : Record < string , InstalledPluginInstallRecordInfo > ,
306+ ) : Promise < void > {
307+ await writePersistedInstalledPluginIndex (
308+ {
309+ version : 1 ,
310+ hostContractVersion : "test" ,
311+ compatRegistryVersion : "test" ,
312+ migrationVersion : 1 ,
313+ policyHash : "test" ,
314+ generatedAtMs : 1 ,
315+ installRecords,
316+ plugins : [ ] ,
317+ diagnostics : [ ] ,
318+ } ,
319+ { stateDir : root } ,
320+ ) ;
321+ }
322+
323+ function writeLegacyPluginInstallIndex (
324+ root : string ,
325+ records : Record < string , InstalledPluginInstallRecordInfo > ,
326+ ) : string {
327+ const sourcePath = path . join ( root , "plugins" , "installs.json" ) ;
328+ fs . mkdirSync ( path . dirname ( sourcePath ) , { recursive : true } ) ;
329+ fs . writeFileSync ( sourcePath , JSON . stringify ( { records } ) , "utf8" ) ;
330+ return sourcePath ;
331+ }
332+
333+ async function runLegacyStateMigrationsForRoot ( root : string ) {
334+ const detected = await detectLegacyStateMigrations ( {
335+ cfg : { } ,
336+ env : { OPENCLAW_STATE_DIR : root } as NodeJS . ProcessEnv ,
337+ } ) ;
338+ return await runLegacyStateMigrations ( { detected } ) ;
339+ }
340+
302341function writeLegacyTaskStateSidecars ( root : string ) : {
303342 taskRunsPath : string ;
304343 flowRunsPath : string ;
@@ -1302,45 +1341,20 @@ describe("doctor legacy state migrations", () => {
13021341
13031342 it ( "merges missing legacy plugin install records into an existing SQLite index" , async ( ) => {
13041343 const root = await makeTempRoot ( ) ;
1305- await writePersistedInstalledPluginIndex (
1306- {
1307- version : 1 ,
1308- hostContractVersion : "test" ,
1309- compatRegistryVersion : "test" ,
1310- migrationVersion : 1 ,
1311- policyHash : "test" ,
1312- generatedAtMs : 1 ,
1313- installRecords : {
1314- existing : {
1315- source : "npm" ,
1316- spec : "existing@1.0.0" ,
1317- } ,
1318- } ,
1319- plugins : [ ] ,
1320- diagnostics : [ ] ,
1344+ await writeExistingPluginInstallIndex ( root , {
1345+ existing : {
1346+ source : "npm" ,
1347+ spec : "existing@1.0.0" ,
1348+ } ,
1349+ } ) ;
1350+ const sourcePath = writeLegacyPluginInstallIndex ( root , {
1351+ legacy : {
1352+ source : "git" ,
1353+ spec : "git:file:///tmp/legacy" ,
13211354 } ,
1322- { stateDir : root } ,
1323- ) ;
1324- const sourcePath = path . join ( root , "plugins" , "installs.json" ) ;
1325- fs . mkdirSync ( path . dirname ( sourcePath ) , { recursive : true } ) ;
1326- fs . writeFileSync (
1327- sourcePath ,
1328- JSON . stringify ( {
1329- records : {
1330- legacy : {
1331- source : "git" ,
1332- spec : "git:file:///tmp/legacy" ,
1333- } ,
1334- } ,
1335- } ) ,
1336- "utf8" ,
1337- ) ;
1338-
1339- const detected = await detectLegacyStateMigrations ( {
1340- cfg : { } ,
1341- env : { OPENCLAW_STATE_DIR : root } as NodeJS . ProcessEnv ,
13421355 } ) ;
1343- const result = await runLegacyStateMigrations ( { detected } ) ;
1356+
1357+ const result = await runLegacyStateMigrationsForRoot ( root ) ;
13441358
13451359 expect ( result . warnings ) . toStrictEqual ( [ ] ) ;
13461360 expect ( result . changes ) . toContain ( "Merged 1 legacy plugin install record → shared SQLite state" ) ;
@@ -1355,53 +1369,28 @@ describe("doctor legacy state migrations", () => {
13551369
13561370 it ( "archives legacy plugin install index when SQLite already has richer matching records" , async ( ) => {
13571371 const root = await makeTempRoot ( ) ;
1358- await writePersistedInstalledPluginIndex (
1359- {
1360- version : 1 ,
1361- hostContractVersion : "test" ,
1362- compatRegistryVersion : "test" ,
1363- migrationVersion : 1 ,
1364- policyHash : "test" ,
1365- generatedAtMs : 1 ,
1366- installRecords : {
1367- demo : {
1368- source : "npm" ,
1369- spec : "demo@1.0.0" ,
1370- version : "1.0.0" ,
1371- resolvedName : "demo" ,
1372- resolvedVersion : "1.0.0" ,
1373- resolvedSpec : "demo@1.0.0" ,
1374- integrity : "sha512-current" ,
1375- shasum : "current" ,
1376- installedAt : "2026-06-01T21:04:35.000Z" ,
1377- } ,
1378- } ,
1379- plugins : [ ] ,
1380- diagnostics : [ ] ,
1372+ await writeExistingPluginInstallIndex ( root , {
1373+ demo : {
1374+ source : "npm" ,
1375+ spec : "demo@1.0.0" ,
1376+ version : "1.0.0" ,
1377+ resolvedName : "demo" ,
1378+ resolvedVersion : "1.0.0" ,
1379+ resolvedSpec : "demo@1.0.0" ,
1380+ integrity : "sha512-current" ,
1381+ shasum : "current" ,
1382+ installedAt : "2026-06-01T21:04:35.000Z" ,
13811383 } ,
1382- { stateDir : root } ,
1383- ) ;
1384- const sourcePath = path . join ( root , "plugins" , "installs.json" ) ;
1385- fs . mkdirSync ( path . dirname ( sourcePath ) , { recursive : true } ) ;
1386- fs . writeFileSync (
1387- sourcePath ,
1388- JSON . stringify ( {
1389- records : {
1390- demo : {
1391- source : "npm" ,
1392- spec : "demo@beta" ,
1393- version : "1.0.0" ,
1394- } ,
1395- } ,
1396- } ) ,
1397- "utf8" ,
1398- ) ;
1399-
1400- const detected = await detectLegacyStateMigrations ( {
1401- cfg : { } ,
1402- env : { OPENCLAW_STATE_DIR : root } as NodeJS . ProcessEnv ,
14031384 } ) ;
1404- const result = await runLegacyStateMigrations ( { detected } ) ;
1385+ const sourcePath = writeLegacyPluginInstallIndex ( root , {
1386+ demo : {
1387+ source : "npm" ,
1388+ spec : "demo@beta" ,
1389+ version : "1.0.0" ,
1390+ } ,
1391+ } ) ;
1392+
1393+ const result = await runLegacyStateMigrationsForRoot ( root ) ;
14051394
14061395 expect ( result . warnings ) . toStrictEqual ( [ ] ) ;
14071396 expect ( fs . existsSync ( sourcePath ) ) . toBe ( false ) ;
@@ -1418,111 +1407,56 @@ describe("doctor legacy state migrations", () => {
14181407 } ) ;
14191408 } ) ;
14201409
1421- it ( "keeps legacy plugin install index when same-version npm records name different packages" , async ( ) => {
1422- const root = await makeTempRoot ( ) ;
1423- await writePersistedInstalledPluginIndex (
1424- {
1425- version : 1 ,
1426- hostContractVersion : "test" ,
1427- compatRegistryVersion : "test" ,
1428- migrationVersion : 1 ,
1429- policyHash : "test" ,
1430- generatedAtMs : 1 ,
1431- installRecords : {
1432- demo : {
1433- source : "npm" ,
1434- spec : "@openclaw/demo@1.0.0" ,
1435- version : "1.0.0" ,
1436- resolvedName : "@openclaw/demo" ,
1437- resolvedVersion : "1.0.0" ,
1438- resolvedSpec : "@openclaw/demo@1.0.0" ,
1439- } ,
1440- } ,
1441- plugins : [ ] ,
1442- diagnostics : [ ] ,
1410+ for ( const fixture of [
1411+ {
1412+ label : "name different packages" ,
1413+ current : {
1414+ source : "npm" ,
1415+ spec : "@openclaw/demo@1.0.0" ,
1416+ version : "1.0.0" ,
1417+ resolvedName : "@openclaw/demo" ,
1418+ resolvedVersion : "1.0.0" ,
1419+ resolvedSpec : "@openclaw/demo@1.0.0" ,
14431420 } ,
1444- { stateDir : root } ,
1445- ) ;
1446- const sourcePath = path . join ( root , "plugins" , "installs.json" ) ;
1447- fs . mkdirSync ( path . dirname ( sourcePath ) , { recursive : true } ) ;
1448- fs . writeFileSync (
1449- sourcePath ,
1450- JSON . stringify ( {
1451- records : {
1452- demo : {
1453- source : "npm" ,
1454- spec : "@vendor/demo@1.0.0" ,
1455- version : "1.0.0" ,
1456- } ,
1457- } ,
1458- } ) ,
1459- "utf8" ,
1460- ) ;
1461-
1462- const detected = await detectLegacyStateMigrations ( {
1463- cfg : { } ,
1464- env : { OPENCLAW_STATE_DIR : root } as NodeJS . ProcessEnv ,
1465- } ) ;
1466- const result = await runLegacyStateMigrations ( { detected } ) ;
1467-
1468- expect ( result . warnings ) . toStrictEqual ( [
1469- "Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: demo" ,
1470- ] ) ;
1471- expect ( fs . existsSync ( sourcePath ) ) . toBe ( true ) ;
1472- expect ( fs . existsSync ( `${ sourcePath } .migrated` ) ) . toBe ( false ) ;
1473- } ) ;
1474-
1475- it ( "keeps legacy plugin install index when same-version npm specs are unparseable" , async ( ) => {
1476- const root = await makeTempRoot ( ) ;
1477- await writePersistedInstalledPluginIndex (
1478- {
1479- version : 1 ,
1480- hostContractVersion : "test" ,
1481- compatRegistryVersion : "test" ,
1482- migrationVersion : 1 ,
1483- policyHash : "test" ,
1484- generatedAtMs : 1 ,
1485- installRecords : {
1486- demo : {
1487- source : "npm" ,
1488- spec : "file:../current-demo" ,
1489- version : "1.0.0" ,
1490- resolvedVersion : "1.0.0" ,
1491- } ,
1492- } ,
1493- plugins : [ ] ,
1494- diagnostics : [ ] ,
1421+ legacy : {
1422+ source : "npm" ,
1423+ spec : "@vendor/demo@1.0.0" ,
1424+ version : "1.0.0" ,
14951425 } ,
1496- { stateDir : root } ,
1497- ) ;
1498- const sourcePath = path . join ( root , "plugins" , "installs.json" ) ;
1499- fs . mkdirSync ( path . dirname ( sourcePath ) , { recursive : true } ) ;
1500- fs . writeFileSync (
1501- sourcePath ,
1502- JSON . stringify ( {
1503- records : {
1504- demo : {
1505- source : "npm" ,
1506- spec : "file:../legacy-demo" ,
1507- version : "1.0.0" ,
1508- } ,
1509- } ,
1510- } ) ,
1511- "utf8" ,
1512- ) ;
1513-
1514- const detected = await detectLegacyStateMigrations ( {
1515- cfg : { } ,
1516- env : { OPENCLAW_STATE_DIR : root } as NodeJS . ProcessEnv ,
1426+ } ,
1427+ {
1428+ label : "specs are unparseable" ,
1429+ current : {
1430+ source : "npm" ,
1431+ spec : "file:../current-demo" ,
1432+ version : "1.0.0" ,
1433+ resolvedVersion : "1.0.0" ,
1434+ } ,
1435+ legacy : {
1436+ source : "npm" ,
1437+ spec : "file:../legacy-demo" ,
1438+ version : "1.0.0" ,
1439+ } ,
1440+ } ,
1441+ ] satisfies Array < {
1442+ label : string ;
1443+ current : InstalledPluginInstallRecordInfo ;
1444+ legacy : InstalledPluginInstallRecordInfo ;
1445+ } > ) {
1446+ it ( `keeps legacy plugin install index when same-version npm records ${ fixture . label } ` , async ( ) => {
1447+ const root = await makeTempRoot ( ) ;
1448+ await writeExistingPluginInstallIndex ( root , { demo : fixture . current } ) ;
1449+ const sourcePath = writeLegacyPluginInstallIndex ( root , { demo : fixture . legacy } ) ;
1450+
1451+ const result = await runLegacyStateMigrationsForRoot ( root ) ;
1452+
1453+ expect ( result . warnings ) . toStrictEqual ( [
1454+ "Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: demo" ,
1455+ ] ) ;
1456+ expect ( fs . existsSync ( sourcePath ) ) . toBe ( true ) ;
1457+ expect ( fs . existsSync ( `${ sourcePath } .migrated` ) ) . toBe ( false ) ;
15171458 } ) ;
1518- const result = await runLegacyStateMigrations ( { detected } ) ;
1519-
1520- expect ( result . warnings ) . toStrictEqual ( [
1521- "Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: demo" ,
1522- ] ) ;
1523- expect ( fs . existsSync ( sourcePath ) ) . toBe ( true ) ;
1524- expect ( fs . existsSync ( `${ sourcePath } .migrated` ) ) . toBe ( false ) ;
1525- } ) ;
1459+ }
15261460
15271461 it ( "auto-migrates the shipped plugin-state SQLite sidecar by itself" , async ( ) => {
15281462 const root = await makeTempRoot ( ) ;
0 commit comments