@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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +19 -3
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +5 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +22 -17
- package/src/service-spec.ts +44 -60
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-5Mj6FqPg.css +1 -0
- package/web/ui/dist/assets/{index-D63mUkVX.js → index-BqjySZ_7.js} +12 -12
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
|
@@ -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
|
|
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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: "
|
|
480
|
+
name: "someapp",
|
|
482
481
|
port: 1944,
|
|
483
|
-
paths: ["/
|
|
484
|
-
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: "
|
|
510
|
+
name: "someapp",
|
|
512
511
|
port: 1944,
|
|
513
|
-
paths: ["/
|
|
514
|
-
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(/
|
|
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: "
|
|
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
|
|
683
|
-
name: "
|
|
681
|
+
const someapp: ServiceEntry = {
|
|
682
|
+
name: "someapp",
|
|
684
683
|
port: 1944,
|
|
685
|
-
paths: ["/
|
|
686
|
-
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({ ...
|
|
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(
|
|
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(
|
|
712
|
-
expect(() => upsertService(
|
|
713
|
-
expect(() => upsertService(
|
|
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
|
|
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({ ...
|
|
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({ ...
|
|
784
|
-
// Move scribe to 1945, where
|
|
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,
|
|
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 === "
|
|
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).
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
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("
|
|
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).
|
|
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
|
+
});
|