@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21

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.
@@ -455,13 +455,12 @@ describe("services-manifest", () => {
455
455
  });
456
456
 
457
457
  // Duplicate-port detection (hub#195). The original collision had
458
- // parachute-scribe and agent both at 1944 in services.json with no
459
- // operator-visible warning. The OS lets only one service bind, the
460
- // hub reverse-proxy quietly routes everyone to whoever won the race,
461
- // and `/agent` requests silently land on scribe. Reject at parse time
462
- // so the same shape can't recur silently. Underlying overwrite bugs
463
- // were fixed in parachute-scribe#41 + parachute-agent#146; this is
464
- // the hub-side gate.
458
+ // parachute-scribe and a third-party service both at 1944 in services.json
459
+ // with no operator-visible warning. The OS lets only one service bind, the
460
+ // hub reverse-proxy quietly routes everyone to whoever won the race, and
461
+ // requests silently land on the wrong service. Reject at parse time so the
462
+ // same shape can't recur silently. Underlying overwrite bugs were fixed in
463
+ // parachute-scribe#41; this is the hub-side gate.
465
464
  describe("duplicate port rejection", () => {
466
465
  test("rejects manifest where two entries share a port", () => {
467
466
  const { path, cleanup } = makeTempPath();
@@ -478,10 +477,10 @@ describe("services-manifest", () => {
478
477
  version: "0.4.0",
479
478
  },
480
479
  {
481
- name: "agent",
480
+ name: "someapp",
482
481
  port: 1944,
483
- paths: ["/agent"],
484
- health: "/agent/health",
482
+ paths: ["/someapp"],
483
+ health: "/someapp/health",
485
484
  version: "0.1.0",
486
485
  },
487
486
  ],
@@ -508,10 +507,10 @@ describe("services-manifest", () => {
508
507
  version: "0.4.0",
509
508
  },
510
509
  {
511
- name: "agent",
510
+ name: "someapp",
512
511
  port: 1944,
513
- paths: ["/agent"],
514
- health: "/agent/health",
512
+ paths: ["/someapp"],
513
+ health: "/someapp/health",
515
514
  version: "0.1.0",
516
515
  },
517
516
  ],
@@ -522,7 +521,7 @@ describe("services-manifest", () => {
522
521
  // they know which two rows to reconcile).
523
522
  expect(() => readManifest(path)).toThrow(/duplicate port 1944/);
524
523
  expect(() => readManifest(path)).toThrow(/parachute-scribe/);
525
- expect(() => readManifest(path)).toThrow(/agent/);
524
+ expect(() => readManifest(path)).toThrow(/someapp/);
526
525
  } finally {
527
526
  cleanup();
528
527
  }
@@ -668,7 +667,7 @@ describe("services-manifest", () => {
668
667
  // catches duplicate ports on the next `readManifest`, but without a
669
668
  // matching write-side check `upsertService` happily writes a corrupt
670
669
  // manifest to disk and only the next read surfaces the fault. A buggy
671
- // service boot calling `upsertService({ name: "agent", port: 1944 })`
670
+ // service boot calling `upsertService({ name: "someapp", port: 1944 })`
672
671
  // while scribe is already at 1944 must fail before `writeManifest` runs.
673
672
  // Same multi-vault carve-out applies.
674
673
  describe("upsertService duplicate-port rejection (hub#205)", () => {
@@ -679,11 +678,11 @@ describe("services-manifest", () => {
679
678
  health: "/scribe/health",
680
679
  version: "0.4.0",
681
680
  };
682
- const agent: ServiceEntry = {
683
- name: "agent",
681
+ const someapp: ServiceEntry = {
682
+ name: "someapp",
684
683
  port: 1944,
685
- paths: ["/agent"],
686
- health: "/agent/health",
684
+ paths: ["/someapp"],
685
+ health: "/someapp/health",
687
686
  version: "0.1.0",
688
687
  };
689
688
 
@@ -691,7 +690,7 @@ describe("services-manifest", () => {
691
690
  const { path, cleanup } = makeTempPath();
692
691
  try {
693
692
  upsertService(scribe, path);
694
- const m = upsertService({ ...agent, port: 1945 }, path);
693
+ const m = upsertService({ ...someapp, port: 1945 }, path);
695
694
  expect(m.services).toHaveLength(2);
696
695
  expect(m.services.map((s) => s.port).sort()).toEqual([1944, 1945]);
697
696
  // And it actually wrote: a fresh read sees both rows.
@@ -705,14 +704,14 @@ describe("services-manifest", () => {
705
704
  const { path, cleanup } = makeTempPath();
706
705
  try {
707
706
  upsertService(scribe, path);
708
- expect(() => upsertService(agent, path)).toThrow(ServicesManifestError);
707
+ expect(() => upsertService(someapp, path)).toThrow(ServicesManifestError);
709
708
  // Error names the colliding port and both services so an operator
710
709
  // scanning logs knows which two rows to reconcile.
711
- expect(() => upsertService(agent, path)).toThrow(/duplicate port 1944/);
712
- expect(() => upsertService(agent, path)).toThrow(/parachute-scribe/);
713
- expect(() => upsertService(agent, path)).toThrow(/agent/);
710
+ expect(() => upsertService(someapp, path)).toThrow(/duplicate port 1944/);
711
+ expect(() => upsertService(someapp, path)).toThrow(/parachute-scribe/);
712
+ expect(() => upsertService(someapp, path)).toThrow(/someapp/);
714
713
  // Crucially: services.json was NOT corrupted on the failed write.
715
- // The pre-existing row stays, and the agent row never lands.
714
+ // The pre-existing row stays, and the someapp row never lands.
716
715
  const m = readManifest(path);
717
716
  expect(m.services).toHaveLength(1);
718
717
  expect(m.services[0]?.name).toBe("parachute-scribe");
@@ -760,7 +759,7 @@ describe("services-manifest", () => {
760
759
  const { path, cleanup } = makeTempPath();
761
760
  try {
762
761
  upsertService(scribe, path); // port 1944
763
- upsertService({ ...agent, port: 1945 }, path); // port 1945
762
+ upsertService({ ...someapp, port: 1945 }, path); // port 1945
764
763
  // Move scribe from 1944 to 1948 (free): succeeds.
765
764
  const m = upsertService({ ...scribe, port: 1948 }, path);
766
765
  expect(m.services).toHaveLength(2);
@@ -780,15 +779,15 @@ describe("services-manifest", () => {
780
779
  const { path, cleanup } = makeTempPath();
781
780
  try {
782
781
  upsertService(scribe, path); // port 1944
783
- upsertService({ ...agent, port: 1945 }, path); // port 1945
784
- // Move scribe to 1945, where agent already lives: must throw.
782
+ upsertService({ ...someapp, port: 1945 }, path); // port 1945
783
+ // Move scribe to 1945, where someapp already lives: must throw.
785
784
  expect(() => upsertService({ ...scribe, port: 1945 }, path)).toThrow(ServicesManifestError);
786
785
  expect(() => upsertService({ ...scribe, port: 1945 }, path)).toThrow(/duplicate port 1945/);
787
- // And the on-disk state stayed coherent — scribe at 1944, agent at
786
+ // And the on-disk state stayed coherent — scribe at 1944, someapp at
788
787
  // 1945 — because the gate fires before writeManifest.
789
788
  const persisted = readManifest(path);
790
789
  expect(persisted.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1944);
791
- expect(persisted.services.find((s) => s.name === "agent")?.port).toBe(1945);
790
+ expect(persisted.services.find((s) => s.name === "someapp")?.port).toBe(1945);
792
791
  } finally {
793
792
  cleanup();
794
793
  }
@@ -798,11 +797,18 @@ describe("services-manifest", () => {
798
797
 
799
798
  describe("claw → agent migration", () => {
800
799
  // Paraclaw was renamed to parachute-agent across the ecosystem (npm
801
- // package, mount path, short name). Operators who upgraded hub but
802
- // still have the old paraclaw row in services.json otherwise see a
803
- // tile labelled "Claw" and a hub route at `/claw` while their newly
804
- // upgraded daemon listens on `/agent`. The migration runs on
805
- // readManifest, rewrites the row in-place, and writes back.
800
+ // package, mount path, short name). The migration was a transitional
801
+ // read-time rewrite that aliased legacy `name: "claw"` rows to
802
+ // `name: "agent"` so operators on the old shape kept routing.
803
+ //
804
+ // As of 2026-05-20, parachute-agent itself is retired (hub#334 added
805
+ // `agent` to RETIRED_MODULES). The migration still runs — and operators
806
+ // with claw rows on disk still see them rewritten to `agent` on the
807
+ // first read — but the retired-module GC then drops the rewritten row
808
+ // on the next read. The migration is effectively a one-step retirement
809
+ // path: claw → agent → dropped. The tests below assert the
810
+ // intermediate rewrite behavior; the retired-module suite asserts the
811
+ // GC step.
806
812
  const claw: ServiceEntry = {
807
813
  name: "claw",
808
814
  port: 1944,
@@ -823,9 +829,12 @@ describe("claw → agent migration", () => {
823
829
  try {
824
830
  writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
825
831
  const got = readManifest(path);
832
+ // Migration ran in this read (claw → agent on raw entries), then
833
+ // the row was rewritten to disk. Retired GC won't touch `claw`-
834
+ // typed rows on the way in, only `agent`-typed rows — so the
835
+ // first read returns the migrated `agent` shape; the second read
836
+ // will then GC it (covered below).
826
837
  expect(got.services).toEqual([agent]);
827
- // Persisted: a second read sees the migrated shape directly, no
828
- // re-migration required.
829
838
  const reread = JSON.parse(readFileSync(path, "utf8")) as {
830
839
  services: ServiceEntry[];
831
840
  };
@@ -837,16 +846,31 @@ describe("claw → agent migration", () => {
837
846
  }
838
847
  });
839
848
 
840
- test("idempotent: an already-agent entry is not rewritten and not rewritten on re-read", () => {
849
+ test("retired GC drops the migrated agent row on the next read (post-retirement)", () => {
850
+ // Confirms the migration's role as an intermediate retirement step:
851
+ // first read migrates claw → agent and writes back; second read
852
+ // applies the retired-module GC and drops the row entirely.
853
+ const { path, cleanup } = makeTempPath();
854
+ try {
855
+ writeFileSync(path, `${JSON.stringify({ services: [claw] }, null, 2)}\n`);
856
+ const first = readManifest(path);
857
+ expect(first.services).toEqual([agent]);
858
+ const second = readManifest(path);
859
+ expect(second.services).toHaveLength(0);
860
+ } finally {
861
+ cleanup();
862
+ }
863
+ });
864
+
865
+ test("an already-agent entry is dropped by retired GC (was: idempotent migration)", () => {
866
+ // Pre-hub#334 this test verified the migration was idempotent — an
867
+ // already-agent row round-tripped unchanged. Post-retirement, the
868
+ // GC takes over: an agent row is stale and gets removed on read.
841
869
  const { path, cleanup } = makeTempPath();
842
870
  try {
843
871
  writeFileSync(path, `${JSON.stringify({ services: [agent] }, null, 2)}\n`);
844
- const beforeMtime = statSync(path).mtimeMs;
845
872
  const got = readManifest(path);
846
- expect(got.services).toEqual([agent]);
847
- // No write back when nothing changed: mtime stays put.
848
- const afterMtime = statSync(path).mtimeMs;
849
- expect(afterMtime).toBe(beforeMtime);
873
+ expect(got.services).toHaveLength(0);
850
874
  } finally {
851
875
  cleanup();
852
876
  }
@@ -864,6 +888,8 @@ describe("claw → agent migration", () => {
864
888
  };
865
889
  writeFileSync(path, `${JSON.stringify({ services: [vault, claw, scribe] }, null, 2)}\n`);
866
890
  const got = readManifest(path);
891
+ // First read: claw migrates to agent (retired GC didn't see `claw`
892
+ // on the way in). Vault + scribe round-trip unchanged.
867
893
  expect(got.services).toHaveLength(3);
868
894
  expect(got.services[0]).toEqual(vault);
869
895
  expect(got.services[1]).toEqual(agent);
@@ -909,3 +935,390 @@ describe("claw → agent migration", () => {
909
935
  }
910
936
  });
911
937
  });
938
+
939
+ // Legacy short-name row cleanup (the parachute-app#13 + parachute-runner#4
940
+ // self-register fixup, surfaced 2026-05-22 when Aaron's services.json
941
+ // carried both `parachute-app` (hub-stamped) and `app` (legacy
942
+ // self-register) at port 1946 and the duplicate-port read gate refused to
943
+ // boot the file). Hub auto-heals on read: drops the legacy short-name row
944
+ // when a same-port `parachute-<short>` row is present, then rewrites the
945
+ // file so the next read is clean.
946
+ describe("legacy short-name row de-dupe (parachute-app#13 / runner#4)", () => {
947
+ test("drops the short-name row when a same-port manifestName row exists", () => {
948
+ const { path, cleanup } = makeTempPath();
949
+ try {
950
+ writeFileSync(
951
+ path,
952
+ JSON.stringify({
953
+ services: [
954
+ {
955
+ name: "parachute-app",
956
+ port: 1946,
957
+ paths: ["/app"],
958
+ health: "/app/healthz",
959
+ version: "0.2.0",
960
+ },
961
+ {
962
+ name: "app",
963
+ port: 1946,
964
+ paths: ["/app"],
965
+ health: "/app/healthz",
966
+ version: "0.2.0",
967
+ },
968
+ ],
969
+ }),
970
+ );
971
+ const m = readManifest(path);
972
+ expect(m.services).toHaveLength(1);
973
+ expect(m.services[0]?.name).toBe("parachute-app");
974
+ } finally {
975
+ cleanup();
976
+ }
977
+ });
978
+
979
+ test("rewrites services.json on disk after de-dupe so the next read is clean", () => {
980
+ const { path, cleanup } = makeTempPath();
981
+ try {
982
+ writeFileSync(
983
+ path,
984
+ JSON.stringify({
985
+ services: [
986
+ {
987
+ name: "parachute-runner",
988
+ port: 1945,
989
+ paths: ["/runner"],
990
+ health: "/runner/healthz",
991
+ version: "0.1.5",
992
+ },
993
+ {
994
+ name: "runner",
995
+ port: 1945,
996
+ paths: ["/runner"],
997
+ health: "/runner/healthz",
998
+ version: "0.1.5",
999
+ },
1000
+ ],
1001
+ }),
1002
+ );
1003
+ readManifest(path);
1004
+ // The on-disk file no longer contains the duplicate — a fresh
1005
+ // reader (one that didn't go through the de-dupe path) sees the
1006
+ // clean shape.
1007
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
1008
+ expect(onDisk.services).toHaveLength(1);
1009
+ expect(onDisk.services[0].name).toBe("parachute-runner");
1010
+ } finally {
1011
+ cleanup();
1012
+ }
1013
+ });
1014
+
1015
+ test("leaves a lone short-name row alone (no same-port manifestName twin)", () => {
1016
+ // Operators on an old self-register that never got the manifestName
1017
+ // write keep working — the row is non-duplicated, hub just renders
1018
+ // them under the legacy name. Auto-rewriting standalone short-name
1019
+ // rows would surprise operators who hand-edit services.json on
1020
+ // purpose; we only intervene when the duplicate breaks reads.
1021
+ const { path, cleanup } = makeTempPath();
1022
+ try {
1023
+ writeFileSync(
1024
+ path,
1025
+ JSON.stringify({
1026
+ services: [
1027
+ {
1028
+ name: "app",
1029
+ port: 1946,
1030
+ paths: ["/app"],
1031
+ health: "/app/healthz",
1032
+ version: "0.2.0",
1033
+ },
1034
+ ],
1035
+ }),
1036
+ );
1037
+ const m = readManifest(path);
1038
+ expect(m.services).toHaveLength(1);
1039
+ expect(m.services[0]?.name).toBe("app");
1040
+ } finally {
1041
+ cleanup();
1042
+ }
1043
+ });
1044
+
1045
+ test("leaves a deliberate third-party short-name row alone (different port from any parachute-X)", () => {
1046
+ const { path, cleanup } = makeTempPath();
1047
+ try {
1048
+ writeFileSync(
1049
+ path,
1050
+ JSON.stringify({
1051
+ services: [
1052
+ {
1053
+ name: "parachute-app",
1054
+ port: 1946,
1055
+ paths: ["/app"],
1056
+ health: "/app/healthz",
1057
+ version: "0.2.0",
1058
+ },
1059
+ {
1060
+ name: "app",
1061
+ port: 9999,
1062
+ paths: ["/their-app"],
1063
+ health: "/their-app/health",
1064
+ version: "1.0.0",
1065
+ },
1066
+ ],
1067
+ }),
1068
+ );
1069
+ const m = readManifest(path);
1070
+ expect(m.services).toHaveLength(2);
1071
+ expect(m.services.map((s) => s.name).sort()).toEqual(["app", "parachute-app"]);
1072
+ } finally {
1073
+ cleanup();
1074
+ }
1075
+ });
1076
+
1077
+ test("does not drop parachute-X when paired with a non-matching, non-retired short-name", () => {
1078
+ // The legacy-short-name heuristic is narrow: only drop short-name
1079
+ // rows whose name is exactly the suffix of a same-port
1080
+ // `parachute-<short>` row. A collision between e.g. `parachute-app`
1081
+ // and an unrelated third-party `unknownmod` doesn't match the
1082
+ // shape — it's a separate problem with its own duplicate-port error.
1083
+ // (Aaron's ambient `parachute-app` + `agent` case is now handled
1084
+ // upstream by `dropRetiredModuleRows`; see the retired-module
1085
+ // suite below.)
1086
+ const { path, cleanup } = makeTempPath();
1087
+ try {
1088
+ writeFileSync(
1089
+ path,
1090
+ JSON.stringify({
1091
+ services: [
1092
+ {
1093
+ name: "parachute-app",
1094
+ port: 1946,
1095
+ paths: ["/app"],
1096
+ health: "/app/healthz",
1097
+ version: "0.2.0",
1098
+ },
1099
+ {
1100
+ name: "unknownmod",
1101
+ port: 1946,
1102
+ paths: ["/unknownmod"],
1103
+ health: "/unknownmod/health",
1104
+ version: "0.1.4",
1105
+ },
1106
+ ],
1107
+ }),
1108
+ );
1109
+ // Both rows pass through the de-dupe; validation then catches the
1110
+ // duplicate-port collision and throws. Operators on this shape
1111
+ // resolve it by removing the colliding row manually.
1112
+ expect(() => readManifest(path)).toThrow(/duplicate port 1946/);
1113
+ } finally {
1114
+ cleanup();
1115
+ }
1116
+ });
1117
+
1118
+ test("idempotent — second read leaves the cleaned file alone", () => {
1119
+ const { path, cleanup } = makeTempPath();
1120
+ try {
1121
+ writeFileSync(
1122
+ path,
1123
+ JSON.stringify({
1124
+ services: [
1125
+ {
1126
+ name: "parachute-app",
1127
+ port: 1946,
1128
+ paths: ["/app"],
1129
+ health: "/app/healthz",
1130
+ version: "0.2.0",
1131
+ },
1132
+ {
1133
+ name: "app",
1134
+ port: 1946,
1135
+ paths: ["/app"],
1136
+ health: "/app/healthz",
1137
+ version: "0.2.0",
1138
+ },
1139
+ ],
1140
+ }),
1141
+ );
1142
+ readManifest(path);
1143
+ const mtimeAfterFirstRead = statSync(path).mtimeMs;
1144
+ // Brief no-op gate: a second read should not rewrite the file. We
1145
+ // assert on the post-mtime equality after a synchronous re-read.
1146
+ readManifest(path);
1147
+ expect(statSync(path).mtimeMs).toBe(mtimeAfterFirstRead);
1148
+ } finally {
1149
+ cleanup();
1150
+ }
1151
+ });
1152
+ });
1153
+
1154
+ // Retired-module row cleanup (hub#334 — Aaron's actual reproducer on
1155
+ // 2026-05-22). His services.json carried a stale `agent` row at 1946
1156
+ // (left over from parachute-agent's brief committed-core window
1157
+ // 2026-05-05 → 2026-05-20) colliding with `parachute-app`'s new
1158
+ // canonical slot at 1946. The legacy-short-name de-dupe doesn't help —
1159
+ // `agent` isn't the short-name twin of `parachute-app`. The retired-
1160
+ // module GC fires unconditionally on rows whose name appears in
1161
+ // `RETIRED_MODULES`, regardless of port collision.
1162
+ describe("retired-module row de-dupe (hub#334)", () => {
1163
+ test("drops a row whose name is in RETIRED_MODULES (agent)", () => {
1164
+ const { path, cleanup } = makeTempPath();
1165
+ try {
1166
+ writeFileSync(
1167
+ path,
1168
+ JSON.stringify({
1169
+ services: [
1170
+ {
1171
+ name: "agent",
1172
+ port: 1946,
1173
+ paths: ["/agent"],
1174
+ health: "/agent/health",
1175
+ version: "0.1.4",
1176
+ },
1177
+ ],
1178
+ }),
1179
+ );
1180
+ const m = readManifest(path);
1181
+ expect(m.services).toHaveLength(0);
1182
+ // The on-disk file is rewritten clean too.
1183
+ const onDisk = JSON.parse(readFileSync(path, "utf8"));
1184
+ expect(onDisk.services).toHaveLength(0);
1185
+ } finally {
1186
+ cleanup();
1187
+ }
1188
+ });
1189
+
1190
+ test("retirement is unconditional — no other rows required", () => {
1191
+ // Verifies dropRetiredModuleRows doesn't depend on a collision
1192
+ // partner (unlike dropLegacyShortNameRows). An agent row sitting
1193
+ // alone is still stale.
1194
+ const { path, cleanup } = makeTempPath();
1195
+ try {
1196
+ writeFileSync(
1197
+ path,
1198
+ JSON.stringify({
1199
+ services: [
1200
+ {
1201
+ name: "agent",
1202
+ port: 9999,
1203
+ paths: ["/agent"],
1204
+ health: "/agent/health",
1205
+ version: "0.1.4",
1206
+ },
1207
+ ],
1208
+ }),
1209
+ );
1210
+ const m = readManifest(path);
1211
+ expect(m.services).toHaveLength(0);
1212
+ } finally {
1213
+ cleanup();
1214
+ }
1215
+ });
1216
+
1217
+ test("no-op when services.json has no retired rows", () => {
1218
+ const { path, cleanup } = makeTempPath();
1219
+ try {
1220
+ writeFileSync(
1221
+ path,
1222
+ JSON.stringify({
1223
+ services: [
1224
+ {
1225
+ name: "parachute-app",
1226
+ port: 1946,
1227
+ paths: ["/app"],
1228
+ health: "/app/healthz",
1229
+ version: "0.2.0",
1230
+ },
1231
+ ],
1232
+ }),
1233
+ );
1234
+ const mtimeBefore = statSync(path).mtimeMs;
1235
+ const m = readManifest(path);
1236
+ expect(m.services).toHaveLength(1);
1237
+ expect(m.services[0]?.name).toBe("parachute-app");
1238
+ // No rewrite when there's nothing to clean.
1239
+ expect(statSync(path).mtimeMs).toBe(mtimeBefore);
1240
+ } finally {
1241
+ cleanup();
1242
+ }
1243
+ });
1244
+
1245
+ test("Aaron's reproducer — agent + parachute-app at same port resolves cleanly", () => {
1246
+ // The motivating bug for hub#334. With dropRetiredModuleRows
1247
+ // running before validateManifest, the stale agent row is GC'd
1248
+ // and the duplicate-port gate doesn't trip downstream.
1249
+ const { path, cleanup } = makeTempPath();
1250
+ try {
1251
+ writeFileSync(
1252
+ path,
1253
+ JSON.stringify({
1254
+ services: [
1255
+ {
1256
+ name: "agent",
1257
+ port: 1946,
1258
+ paths: ["/agent"],
1259
+ health: "/agent/health",
1260
+ version: "0.1.4",
1261
+ },
1262
+ {
1263
+ name: "parachute-app",
1264
+ port: 1946,
1265
+ paths: ["/app"],
1266
+ health: "/app/healthz",
1267
+ version: "0.2.0",
1268
+ },
1269
+ ],
1270
+ }),
1271
+ );
1272
+ const m = readManifest(path);
1273
+ expect(m.services).toHaveLength(1);
1274
+ expect(m.services[0]?.name).toBe("parachute-app");
1275
+ } finally {
1276
+ cleanup();
1277
+ }
1278
+ });
1279
+
1280
+ test("interaction — retired row + legacy short-name pair both cleaned, correct order", () => {
1281
+ // Drop order matters: retired-module cleanup runs first, then
1282
+ // legacy-short-name cleanup. This test ensures both passes
1283
+ // compose correctly on a services.json that exercises both
1284
+ // shapes simultaneously. The agent row is unconditional retire;
1285
+ // the parachute-runner + runner pair triggers legacy-short-name
1286
+ // dedup at port 1945.
1287
+ const { path, cleanup } = makeTempPath();
1288
+ try {
1289
+ writeFileSync(
1290
+ path,
1291
+ JSON.stringify({
1292
+ services: [
1293
+ {
1294
+ name: "agent",
1295
+ port: 1946,
1296
+ paths: ["/agent"],
1297
+ health: "/agent/health",
1298
+ version: "0.1.4",
1299
+ },
1300
+ {
1301
+ name: "parachute-runner",
1302
+ port: 1945,
1303
+ paths: ["/runner"],
1304
+ health: "/runner/healthz",
1305
+ version: "0.1.5",
1306
+ },
1307
+ {
1308
+ name: "runner",
1309
+ port: 1945,
1310
+ paths: ["/runner"],
1311
+ health: "/runner/healthz",
1312
+ version: "0.1.5",
1313
+ },
1314
+ ],
1315
+ }),
1316
+ );
1317
+ const m = readManifest(path);
1318
+ const names = m.services.map((s) => s.name).sort();
1319
+ expect(names).toEqual(["parachute-runner"]);
1320
+ } finally {
1321
+ cleanup();
1322
+ }
1323
+ });
1324
+ });