@openparachute/hub 0.7.7-rc.7 → 0.7.7-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { RETIRED_MODULES } from "../service-spec.ts";
5
6
  import {
6
7
  type ServiceEntry,
7
8
  ServicesManifestError,
@@ -1024,6 +1025,14 @@ describe("claw → agent migration", () => {
1024
1025
  // file so the next read is clean.
1025
1026
  describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1026
1027
  test("drops the short-name row when a same-port manifestName row exists", () => {
1028
+ // Fixture uses `surface` (the ACTUAL short-name twin of `parachute-surface`
1029
+ // per the structural rule: b.name === `parachute-${a.name}`) rather than
1030
+ // `app`. Pre-hub-parity-P5 this test used `app` here, but that only
1031
+ // passed because `app` was ALSO in RETIRED_MODULES at the time — the
1032
+ // unconditional retired-module GC (not this structural short-name rule)
1033
+ // was doing the dropping. Now that `app`/`parachute-app` are un-retired
1034
+ // (hub-parity P5, a new unrelated module claims them), this fixture is
1035
+ // corrected to actually exercise `dropLegacyShortNameRows`.
1027
1036
  const { path, cleanup } = makeTempPath();
1028
1037
  try {
1029
1038
  writeFileSync(
@@ -1038,7 +1047,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1038
1047
  version: "0.2.0",
1039
1048
  },
1040
1049
  {
1041
- name: "app",
1050
+ name: "surface",
1042
1051
  port: 1946,
1043
1052
  paths: ["/surface"],
1044
1053
  health: "/surface/healthz",
@@ -1195,6 +1204,9 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1195
1204
  });
1196
1205
 
1197
1206
  test("idempotent — second read leaves the cleaned file alone", () => {
1207
+ // See the fixture note on the first test in this describe block — `app`
1208
+ // is renamed to `surface` here for the same reason (un-retired 2026-07-11,
1209
+ // hub-parity P5).
1198
1210
  const { path, cleanup } = makeTempPath();
1199
1211
  try {
1200
1212
  writeFileSync(
@@ -1209,7 +1221,7 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1209
1221
  version: "0.2.0",
1210
1222
  },
1211
1223
  {
1212
- name: "app",
1224
+ name: "surface",
1213
1225
  port: 1946,
1214
1226
  paths: ["/surface"],
1215
1227
  health: "/surface/healthz",
@@ -1232,21 +1244,37 @@ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
1232
1244
 
1233
1245
  // Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
1234
1246
  // 2026-05-22). His services.json originally carried a stale `agent` row at
1235
- // 1946 colliding with `parachute-app`'s canonical slot. NOTE: post the
1236
- // 2026-06-17 channel→agent rename, `agent` is a LIVE module again, so these
1237
- // fixtures use the still-retired `app` (parachute-app) name to exercise the
1238
- // same GC. The legacy-short-name de-dupe doesn't help a retired short
1239
- // isn't the short-name twin of the colliding row. The retired-module GC
1240
- // fires unconditionally on rows whose name appears in `RETIRED_MODULES`,
1241
- // regardless of port collision.
1247
+ // 1946 colliding with `parachute-app`'s canonical slot. The legacy-short-name
1248
+ // de-dupe doesn't help a retired short isn't the short-name twin of the
1249
+ // colliding row. The retired-module GC fires unconditionally on rows whose
1250
+ // name appears in `RETIRED_MODULES`, regardless of port collision.
1251
+ //
1252
+ // NOTE (2026-07-11, hub-parity P5): the original fixtures used `agent` —
1253
+ // which was a RETIRED_MODULES entry until the 2026-06-17 channel→agent
1254
+ // rename re-assigned that name to the live (renamed-from-channel) module —
1255
+ // and were later updated to use `app` (parachute-app, retired 2026-05-27).
1256
+ // `app`/`parachute-app` are now THEMSELVES un-retired (a new, unrelated
1257
+ // module — the real parachute-app super-surface — claims the name for
1258
+ // real; see service-spec.ts's RETIRED_MODULES comment). So these tests now
1259
+ // inject a throwaway synthetic retired entry via a describe-scoped
1260
+ // beforeEach/afterEach rather than depending on production RETIRED_MODULES
1261
+ // having a spare non-live name to spend on test fixtures — this mechanism
1262
+ // has now been renamed out from under these tests twice.
1242
1263
  describe("retired-module row de-dupe (hub#334)", () => {
1243
- // NOTE: the original fixtures used `agent` — which was a RETIRED_MODULES
1244
- // entry until the 2026-06-17 channel→agent rename re-assigned that name to
1245
- // the live (renamed-from-channel) module. These tests now use `app`
1246
- // (parachute-app, retired 2026-05-27 — still in RETIRED_MODULES) so they
1247
- // keep exercising the retired-module GC mechanism on a name that is still
1248
- // genuinely retired.
1249
- test("drops a row whose name is in RETIRED_MODULES (app)", () => {
1264
+ const RETIRED_FIXTURE_NAME = "legacy-test-retired-module";
1265
+
1266
+ beforeEach(() => {
1267
+ RETIRED_MODULES[RETIRED_FIXTURE_NAME] = {
1268
+ retiredAt: "2020-01-01",
1269
+ replacement: "n/a — test-only fixture, not a real retirement",
1270
+ };
1271
+ });
1272
+
1273
+ afterEach(() => {
1274
+ delete RETIRED_MODULES[RETIRED_FIXTURE_NAME];
1275
+ });
1276
+
1277
+ test("drops a row whose name is in RETIRED_MODULES", () => {
1250
1278
  const { path, cleanup } = makeTempPath();
1251
1279
  try {
1252
1280
  writeFileSync(
@@ -1254,10 +1282,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1254
1282
  JSON.stringify({
1255
1283
  services: [
1256
1284
  {
1257
- name: "app",
1285
+ name: RETIRED_FIXTURE_NAME,
1258
1286
  port: 1946,
1259
- paths: ["/app"],
1260
- health: "/app/health",
1287
+ paths: ["/legacy"],
1288
+ health: "/legacy/health",
1261
1289
  version: "0.1.4",
1262
1290
  },
1263
1291
  ],
@@ -1275,7 +1303,7 @@ describe("retired-module row de-dupe (hub#334)", () => {
1275
1303
 
1276
1304
  test("retirement is unconditional — no other rows required", () => {
1277
1305
  // Verifies dropRetiredModuleRows doesn't depend on a collision
1278
- // partner (unlike dropLegacyShortNameRows). An app row sitting
1306
+ // partner (unlike dropLegacyShortNameRows). A retired row sitting
1279
1307
  // alone is still stale.
1280
1308
  const { path, cleanup } = makeTempPath();
1281
1309
  try {
@@ -1284,10 +1312,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1284
1312
  JSON.stringify({
1285
1313
  services: [
1286
1314
  {
1287
- name: "app",
1315
+ name: RETIRED_FIXTURE_NAME,
1288
1316
  port: 9999,
1289
- paths: ["/app"],
1290
- health: "/app/health",
1317
+ paths: ["/legacy"],
1318
+ health: "/legacy/health",
1291
1319
  version: "0.1.4",
1292
1320
  },
1293
1321
  ],
@@ -1329,10 +1357,9 @@ describe("retired-module row de-dupe (hub#334)", () => {
1329
1357
  });
1330
1358
 
1331
1359
  test("Aaron's reproducer — retired row + parachute-surface at same port resolves cleanly", () => {
1332
- // The motivating bug for hub#334 (originally an `agent` row; `agent` is
1333
- // a live module again post-rename, so this uses the still-retired `app`).
1334
- // With dropRetiredModuleRows running before validateManifest, the stale
1335
- // retired row is GC'd and the duplicate-port gate doesn't trip downstream.
1360
+ // The motivating bug for hub#334. With dropRetiredModuleRows running
1361
+ // before validateManifest, the stale retired row is GC'd and the
1362
+ // duplicate-port gate doesn't trip downstream.
1336
1363
  const { path, cleanup } = makeTempPath();
1337
1364
  try {
1338
1365
  writeFileSync(
@@ -1340,10 +1367,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1340
1367
  JSON.stringify({
1341
1368
  services: [
1342
1369
  {
1343
- name: "app",
1370
+ name: RETIRED_FIXTURE_NAME,
1344
1371
  port: 1946,
1345
- paths: ["/app"],
1346
- health: "/app/health",
1372
+ paths: ["/legacy"],
1373
+ health: "/legacy/health",
1347
1374
  version: "0.1.4",
1348
1375
  },
1349
1376
  {
@@ -1366,12 +1393,11 @@ describe("retired-module row de-dupe (hub#334)", () => {
1366
1393
 
1367
1394
  test("interaction — retired row + legacy short-name pair both cleaned, correct order", () => {
1368
1395
  // Drop order matters: retired-module cleanup runs first, then
1369
- // legacy-short-name cleanup. This test ensures both passes
1370
- // compose correctly on a services.json that exercises both
1371
- // shapes simultaneously. The `app` row is unconditional retire
1372
- // (the original `agent` fixture is a live module again post-rename);
1373
- // the parachute-runner + runner pair triggers legacy-short-name
1374
- // dedup at port 1945.
1396
+ // legacy-short-name cleanup. This test ensures both passes compose
1397
+ // correctly on a services.json that exercises both shapes
1398
+ // simultaneously. The retired-fixture row is unconditional retire; the
1399
+ // parachute-runner + runner pair triggers legacy-short-name dedup at
1400
+ // port 1945.
1375
1401
  const { path, cleanup } = makeTempPath();
1376
1402
  try {
1377
1403
  writeFileSync(
@@ -1379,10 +1405,10 @@ describe("retired-module row de-dupe (hub#334)", () => {
1379
1405
  JSON.stringify({
1380
1406
  services: [
1381
1407
  {
1382
- name: "app",
1408
+ name: RETIRED_FIXTURE_NAME,
1383
1409
  port: 1946,
1384
- paths: ["/app"],
1385
- health: "/app/health",
1410
+ paths: ["/legacy"],
1411
+ health: "/legacy/health",
1386
1412
  version: "0.1.4",
1387
1413
  },
1388
1414
  {
@@ -1411,6 +1437,290 @@ describe("retired-module row de-dupe (hub#334)", () => {
1411
1437
  });
1412
1438
  });
1413
1439
 
1440
+ // Stale OLD-UI-host `app`/`parachute-app` row heal (hub-parity P5, B1). The
1441
+ // name `parachute-app` used to belong to the UI-host that renamed to
1442
+ // `parachute-surface` 2026-05-27; hub-parity P5 reuses the name for the NEW
1443
+ // super-surface app (mount /app, port 1944), so the RETIRED_MODULES entry that
1444
+ // used to GC the old row was removed. `healStaleUiHostAppRows` is its
1445
+ // shape-gated replacement for operators upgrading DIRECTLY from a pre-rename
1446
+ // release who still carry the stale `/surface`/1946 row. The load-bearing
1447
+ // assertion: a stale old-app row does NOT survive to spawn a crash-looping
1448
+ // unit, while the NEW app row (paths:["/app"], port 1944) is left intact.
1449
+ describe("stale OLD-UI-host app row heal (hub-parity P5, B1)", () => {
1450
+ test("heals (drops) a stale `parachute-app` row with the old /surface + 1946 shape", () => {
1451
+ const { path, cleanup } = makeTempPath();
1452
+ try {
1453
+ writeFileSync(
1454
+ path,
1455
+ JSON.stringify({
1456
+ services: [
1457
+ {
1458
+ name: "parachute-app",
1459
+ port: 1946,
1460
+ paths: ["/surface"],
1461
+ health: "/surface/healthz",
1462
+ version: "0.1.4",
1463
+ },
1464
+ ],
1465
+ }),
1466
+ );
1467
+ const m = readManifest(path);
1468
+ // The stale row is gone — it never survives to spawn a crash-looping
1469
+ // `--package @openparachute/parachute-app` unit the operator never
1470
+ // installed.
1471
+ expect(m.services).toHaveLength(0);
1472
+ // Rewritten clean on disk too.
1473
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
1474
+ expect(onDisk.services).toHaveLength(0);
1475
+ } finally {
1476
+ cleanup();
1477
+ }
1478
+ });
1479
+
1480
+ test("heals a bare-short `app` row with the old shape (the pre-rename self-register name)", () => {
1481
+ const { path, cleanup } = makeTempPath();
1482
+ try {
1483
+ writeFileSync(
1484
+ path,
1485
+ JSON.stringify({
1486
+ services: [
1487
+ {
1488
+ name: "app",
1489
+ port: 1946,
1490
+ paths: ["/surface"],
1491
+ health: "/surface/healthz",
1492
+ version: "0.1.4",
1493
+ },
1494
+ ],
1495
+ }),
1496
+ );
1497
+ const m = readManifest(path);
1498
+ expect(m.services).toHaveLength(0);
1499
+ } finally {
1500
+ cleanup();
1501
+ }
1502
+ });
1503
+
1504
+ test("heals on port 1946 even if the stale row's paths got mangled (port discriminant)", () => {
1505
+ // Corroborating OR-clause: a stale row squatting the old canonical 1946
1506
+ // is healed even if paths[0] isn't /surface-rooted anymore.
1507
+ const { path, cleanup } = makeTempPath();
1508
+ try {
1509
+ writeFileSync(
1510
+ path,
1511
+ JSON.stringify({
1512
+ services: [
1513
+ {
1514
+ name: "parachute-app",
1515
+ port: 1946,
1516
+ paths: ["/whatever"],
1517
+ health: "/whatever/health",
1518
+ version: "0.1.4",
1519
+ },
1520
+ ],
1521
+ }),
1522
+ );
1523
+ const m = readManifest(path);
1524
+ expect(m.services).toHaveLength(0);
1525
+ } finally {
1526
+ cleanup();
1527
+ }
1528
+ });
1529
+
1530
+ test("leaves the NEW app row (paths:['/app'], port 1944) intact — structurally disjoint", () => {
1531
+ const { path, cleanup } = makeTempPath();
1532
+ try {
1533
+ writeFileSync(
1534
+ path,
1535
+ JSON.stringify({
1536
+ services: [
1537
+ {
1538
+ name: "parachute-app",
1539
+ port: 1944,
1540
+ paths: ["/app"],
1541
+ health: "/app/health",
1542
+ version: "0.4.0",
1543
+ },
1544
+ ],
1545
+ }),
1546
+ );
1547
+ const m = readManifest(path);
1548
+ expect(m.services).toHaveLength(1);
1549
+ expect(m.services[0]?.name).toBe("parachute-app");
1550
+ expect(m.services[0]?.port).toBe(1944);
1551
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1552
+ } finally {
1553
+ cleanup();
1554
+ }
1555
+ });
1556
+
1557
+ test("never heals a /app-mounted app row, whatever its port (new-app protection)", () => {
1558
+ // Defensive: even if a new-app row somehow drifted onto a weird port, its
1559
+ // `/app` mount alone protects it from the heal.
1560
+ const { path, cleanup } = makeTempPath();
1561
+ try {
1562
+ writeFileSync(
1563
+ path,
1564
+ JSON.stringify({
1565
+ services: [
1566
+ {
1567
+ name: "app",
1568
+ port: 1946,
1569
+ paths: ["/app"],
1570
+ health: "/app/health",
1571
+ version: "0.4.0",
1572
+ },
1573
+ ],
1574
+ }),
1575
+ );
1576
+ const m = readManifest(path);
1577
+ expect(m.services).toHaveLength(1);
1578
+ expect(m.services[0]?.paths).toEqual(["/app"]);
1579
+ } finally {
1580
+ cleanup();
1581
+ }
1582
+ });
1583
+
1584
+ test("coexistence — stale old-app + real parachute-surface both at 1946: stale healed, surface keeps /surface", () => {
1585
+ // The load-bearing coexistence case. Without the heal, the two rows collide
1586
+ // on port 1946 and trip the duplicate-port gate (or the stale row hijacks
1587
+ // /surface). With the heal running first, the stale row is dropped by shape
1588
+ // and surface survives clean.
1589
+ const { path, cleanup } = makeTempPath();
1590
+ try {
1591
+ writeFileSync(
1592
+ path,
1593
+ JSON.stringify({
1594
+ services: [
1595
+ {
1596
+ name: "parachute-app",
1597
+ port: 1946,
1598
+ paths: ["/surface"],
1599
+ health: "/surface/healthz",
1600
+ version: "0.1.4",
1601
+ },
1602
+ {
1603
+ name: "parachute-surface",
1604
+ port: 1946,
1605
+ paths: ["/surface"],
1606
+ health: "/surface/healthz",
1607
+ version: "0.2.0",
1608
+ },
1609
+ ],
1610
+ }),
1611
+ );
1612
+ const m = readManifest(path);
1613
+ expect(m.services).toHaveLength(1);
1614
+ expect(m.services[0]?.name).toBe("parachute-surface");
1615
+ expect(m.services[0]?.paths).toEqual(["/surface"]);
1616
+ } finally {
1617
+ cleanup();
1618
+ }
1619
+ });
1620
+
1621
+ test("double-write reproducer — stale `parachute-app` + `app` both at 1946/'/surface' → both healed", () => {
1622
+ // A pre-rename operator hit by the 2026-05-22 double-write carries BOTH
1623
+ // rows. The heal drops both by shape (running ahead of dropLegacyShortNameRows,
1624
+ // which would otherwise keep the `parachute-app` half — still stale).
1625
+ const { path, cleanup } = makeTempPath();
1626
+ try {
1627
+ writeFileSync(
1628
+ path,
1629
+ JSON.stringify({
1630
+ services: [
1631
+ {
1632
+ name: "parachute-app",
1633
+ port: 1946,
1634
+ paths: ["/surface"],
1635
+ health: "/surface/healthz",
1636
+ version: "0.1.4",
1637
+ },
1638
+ {
1639
+ name: "app",
1640
+ port: 1946,
1641
+ paths: ["/surface"],
1642
+ health: "/surface/healthz",
1643
+ version: "0.1.4",
1644
+ },
1645
+ ],
1646
+ }),
1647
+ );
1648
+ const m = readManifest(path);
1649
+ expect(m.services).toHaveLength(0);
1650
+ } finally {
1651
+ cleanup();
1652
+ }
1653
+ });
1654
+
1655
+ test("coexistence — NEW app (/app, 1944) + real surface (/surface, 1946): both survive", () => {
1656
+ // The steady-state post-P5 world: the new app and surface installed side by
1657
+ // side. Neither is healed; both route on their own mounts.
1658
+ const { path, cleanup } = makeTempPath();
1659
+ try {
1660
+ writeFileSync(
1661
+ path,
1662
+ JSON.stringify({
1663
+ services: [
1664
+ {
1665
+ name: "parachute-app",
1666
+ port: 1944,
1667
+ paths: ["/app"],
1668
+ health: "/app/health",
1669
+ version: "0.4.0",
1670
+ },
1671
+ {
1672
+ name: "parachute-surface",
1673
+ port: 1946,
1674
+ paths: ["/surface"],
1675
+ health: "/surface/healthz",
1676
+ version: "0.2.0",
1677
+ },
1678
+ ],
1679
+ }),
1680
+ );
1681
+ const m = readManifest(path);
1682
+ const names = m.services.map((s) => s.name).sort();
1683
+ expect(names).toEqual(["parachute-app", "parachute-surface"]);
1684
+ } finally {
1685
+ cleanup();
1686
+ }
1687
+ });
1688
+
1689
+ test("readManifestLenient heals the same stale old-app shape", () => {
1690
+ // The lenient reader shares the same raw-JSON heal pipeline — verify the
1691
+ // crash-loop row is dropped there too (the supervisor's boot path reads
1692
+ // lenient).
1693
+ const { path, cleanup } = makeTempPath();
1694
+ try {
1695
+ writeFileSync(
1696
+ path,
1697
+ JSON.stringify({
1698
+ services: [
1699
+ {
1700
+ name: "parachute-app",
1701
+ port: 1946,
1702
+ paths: ["/surface"],
1703
+ health: "/surface/healthz",
1704
+ version: "0.1.4",
1705
+ },
1706
+ {
1707
+ name: "parachute-vault",
1708
+ port: 1940,
1709
+ paths: ["/vault/default"],
1710
+ health: "/vault/default/health",
1711
+ version: "0.4.8",
1712
+ },
1713
+ ],
1714
+ }),
1715
+ );
1716
+ const m = readManifestLenient(path, { warn: () => {} });
1717
+ expect(m.services.map((s) => s.name)).toEqual(["parachute-vault"]);
1718
+ } finally {
1719
+ cleanup();
1720
+ }
1721
+ });
1722
+ });
1723
+
1414
1724
  describe("readManifestLenient — skips bad entries instead of throwing (hub#406)", () => {
1415
1725
  test("returns the healthy entries when one row has port=0 (the rc.4 app bug)", () => {
1416
1726
  // Reproduces what hub saw 2026-05-26: a fresh deploy installed
@@ -142,7 +142,10 @@ describe("setup", () => {
142
142
  // rejects duplicate ports between distinct services (hub#195).
143
143
  // parachute-runner rides along as a LEGACY row (runner left the
144
144
  // registries 2026-07-01): it must neither block the all-installed exit
145
- // nor crash the survey.
145
+ // nor crash the survey. parachute-app (hub-parity P5, 2026-07-11) is
146
+ // included so this fixture stays truly "everything installed" — without
147
+ // it, `app` is a genuinely offered-but-uninstalled service and the
148
+ // non-tty exit code flips from 0 to 1 (nothing left vs. work-to-offer).
146
149
  const seeds: Array<{ name: string; port: number }> = [
147
150
  { name: "parachute-vault", port: 1940 },
148
151
  { name: "parachute-notes", port: 1942 },
@@ -150,6 +153,7 @@ describe("setup", () => {
150
153
  { name: "parachute-agent", port: 1941 },
151
154
  { name: "parachute-runner", port: 1945 },
152
155
  { name: "parachute-surface", port: 1946 },
156
+ { name: "parachute-app", port: 1944 },
153
157
  ];
154
158
  for (const s of seeds) {
155
159
  upsertService(