@openparachute/hub 0.6.1 → 0.6.3-rc.1

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.
@@ -103,8 +103,11 @@ describe("init", () => {
103
103
  expect(code).toBe(0);
104
104
  expect(calls).toEqual(["ensureHub"]);
105
105
  const joined = logs.join("\n");
106
- expect(joined).toContain("Hub not runningstarting it now");
107
- expect(joined).toContain("Hub started (pid 5555, port 1939)");
106
+ // A genuinely-started unit reports the port only no `pid 0` sentinel,
107
+ // no misleading "starting it now" preamble.
108
+ expect(joined).toContain("Hub unit started (port 1939)");
109
+ expect(joined).not.toContain("pid 0");
110
+ expect(joined).not.toContain("starting it now");
108
111
  expect(joined).toContain("http://127.0.0.1:1939/admin/");
109
112
  expect(joined).toContain("finish setup in the admin wizard");
110
113
  } finally {
@@ -112,6 +115,46 @@ describe("init", () => {
112
115
  }
113
116
  });
114
117
 
118
+ test("unit-managed re-run against a live hub logs 'already running', not 'pid 0'", async () => {
119
+ // A unit-managed hub writes no pidfile, so `processState(HUB_SVC)` reports
120
+ // not-running on every re-run — but `ensureHub` probes /health and returns
121
+ // started:false when the hub is already up. The log must be honest: no
122
+ // "starting it now", no "Hub unit started", no `pid 0` sentinel.
123
+ const h = makeHarness();
124
+ try {
125
+ writeHubPort(1939, h.configDir);
126
+ const calls: string[] = [];
127
+ const logs: string[] = [];
128
+ const code = await init({
129
+ configDir: h.configDir,
130
+ manifestPath: h.manifestPath,
131
+ log: (l) => logs.push(l),
132
+ // No pidfile (unit-managed) → processState reports not-running.
133
+ alive: () => false,
134
+ ensureHub: async () => {
135
+ calls.push("ensureHub");
136
+ // Hub already answered /health — ensureHubUnit's already-up arm.
137
+ return { pid: 0, port: 1939, started: false };
138
+ },
139
+ readExposeStateFn: () => undefined,
140
+ isTty: false,
141
+ platform: "darwin",
142
+ installVaultModuleImpl: noopVaultInstall,
143
+ });
144
+ expect(code).toBe(0);
145
+ // ensureHub IS called (processState can't see a unit-managed hub) but
146
+ // reports started:false.
147
+ expect(calls).toEqual(["ensureHub"]);
148
+ const joined = logs.join("\n");
149
+ expect(joined).toContain("Hub already running (port 1939)");
150
+ expect(joined).not.toContain("pid 0");
151
+ expect(joined).not.toContain("starting it now");
152
+ expect(joined).not.toContain("Hub unit started");
153
+ } finally {
154
+ h.cleanup();
155
+ }
156
+ });
157
+
115
158
  test("idempotent: skips ensureHub and confirms 'looks good' when hub up + vault configured", async () => {
116
159
  const h = makeHarness();
117
160
  try {
@@ -879,6 +922,180 @@ describe("init exposure chain", () => {
879
922
  }
880
923
  });
881
924
 
925
+ // -------------------------------------------------------------------------
926
+ // Phase 3a cutover (design §3.3 init row, §3.1, §4.1/§4.2): init installs +
927
+ // starts the hub UNIT (not a detached spawn) and guarantees an operator
928
+ // token. The `ensureHub` + `guaranteeOperatorToken` seams stay injectable;
929
+ // these tests drive them as stubs (and exercise the REAL operator-token
930
+ // guarantee against a seeded hub DB).
931
+ // -------------------------------------------------------------------------
932
+
933
+ test("calls guaranteeOperatorToken after the hub is up, then falls through to the wizard", async () => {
934
+ const h = makeHarness();
935
+ try {
936
+ const order: string[] = [];
937
+ const code = await init({
938
+ configDir: h.configDir,
939
+ manifestPath: h.manifestPath,
940
+ log: () => {},
941
+ alive: () => false,
942
+ ensureHub: async () => {
943
+ order.push("ensureHub");
944
+ writeHubPort(1939, h.configDir);
945
+ return { pid: 0, port: 1939, started: true };
946
+ },
947
+ guaranteeOperatorToken: async (ctx) => {
948
+ order.push("guaranteeOperatorToken");
949
+ // The hub is up before the token guarantee runs (§3.2 step 4 — read
950
+ // the token AFTER readiness so we don't race the start-hub iss
951
+ // self-heal).
952
+ expect(ctx.hubPort).toBe(1939);
953
+ return "present";
954
+ },
955
+ readExposeStateFn: () => undefined,
956
+ isTty: false,
957
+ platform: "linux",
958
+ installVaultModuleImpl: noopVaultInstall,
959
+ });
960
+ expect(code).toBe(0);
961
+ // hub-up first, then token guarantee — in that order.
962
+ expect(order).toEqual(["ensureHub", "guaranteeOperatorToken"]);
963
+ } finally {
964
+ h.cleanup();
965
+ }
966
+ });
967
+
968
+ test("real guarantee: MINTS the operator token when absent + a hub user exists", async () => {
969
+ const h = makeHarness();
970
+ try {
971
+ const { openHubDb, hubDbPath } = await import("../hub-db.ts");
972
+ const { createUser } = await import("../users.ts");
973
+ const { readOperatorTokenFile } = await import("../operator-token.ts");
974
+ // Seed a first-admin so the guarantee has someone to mint for.
975
+ const db = openHubDb(hubDbPath(h.configDir));
976
+ await createUser(db, "owner", "owner-password-123");
977
+ db.close();
978
+
979
+ // No operator.token yet.
980
+ expect(await readOperatorTokenFile(h.configDir)).toBeNull();
981
+
982
+ const code = await init({
983
+ configDir: h.configDir,
984
+ manifestPath: h.manifestPath,
985
+ log: () => {},
986
+ alive: () => false,
987
+ ensureHub: async () => {
988
+ writeHubPort(1939, h.configDir);
989
+ return { pid: 0, port: 1939, started: true };
990
+ },
991
+ // No guaranteeOperatorToken seam → exercises the REAL default.
992
+ readExposeStateFn: () => undefined,
993
+ isTty: false,
994
+ platform: "linux",
995
+ installVaultModuleImpl: noopVaultInstall,
996
+ });
997
+ expect(code).toBe(0);
998
+ // The default minted + wrote the token.
999
+ expect(await readOperatorTokenFile(h.configDir)).not.toBeNull();
1000
+ } finally {
1001
+ h.cleanup();
1002
+ }
1003
+ });
1004
+
1005
+ test("real guarantee: does NOT double-mint when a token already exists", async () => {
1006
+ const h = makeHarness();
1007
+ try {
1008
+ const { openHubDb, hubDbPath } = await import("../hub-db.ts");
1009
+ const { createUser } = await import("../users.ts");
1010
+ const { writeOperatorTokenFile, readOperatorTokenFile } = await import(
1011
+ "../operator-token.ts"
1012
+ );
1013
+ const db = openHubDb(hubDbPath(h.configDir));
1014
+ await createUser(db, "owner", "owner-password-123");
1015
+ db.close();
1016
+ // Plant a sentinel token on disk.
1017
+ await writeOperatorTokenFile("SENTINEL.PRE-EXISTING.TOKEN", h.configDir);
1018
+
1019
+ const code = await init({
1020
+ configDir: h.configDir,
1021
+ manifestPath: h.manifestPath,
1022
+ log: () => {},
1023
+ alive: () => false,
1024
+ ensureHub: async () => {
1025
+ writeHubPort(1939, h.configDir);
1026
+ return { pid: 0, port: 1939, started: true };
1027
+ },
1028
+ readExposeStateFn: () => undefined,
1029
+ isTty: false,
1030
+ platform: "linux",
1031
+ installVaultModuleImpl: noopVaultInstall,
1032
+ });
1033
+ expect(code).toBe(0);
1034
+ // Untouched — the guarantee left the pre-existing token in place.
1035
+ expect(await readOperatorTokenFile(h.configDir)).toBe("SENTINEL.PRE-EXISTING.TOKEN");
1036
+ } finally {
1037
+ h.cleanup();
1038
+ }
1039
+ });
1040
+
1041
+ test("real guarantee: no hub user yet → no token minted, init still succeeds (no-user, not an error)", async () => {
1042
+ const h = makeHarness();
1043
+ try {
1044
+ const { readOperatorTokenFile } = await import("../operator-token.ts");
1045
+ // No user seeded — the common fresh-box case where init runs before the
1046
+ // wizard creates first-admin.
1047
+ const code = await init({
1048
+ configDir: h.configDir,
1049
+ manifestPath: h.manifestPath,
1050
+ log: () => {},
1051
+ alive: () => false,
1052
+ ensureHub: async () => {
1053
+ writeHubPort(1939, h.configDir);
1054
+ return { pid: 0, port: 1939, started: true };
1055
+ },
1056
+ readExposeStateFn: () => undefined,
1057
+ isTty: false,
1058
+ platform: "linux",
1059
+ installVaultModuleImpl: noopVaultInstall,
1060
+ });
1061
+ expect(code).toBe(0);
1062
+ // No token (the wizard mints it when the admin is created).
1063
+ expect(await readOperatorTokenFile(h.configDir)).toBeNull();
1064
+ } finally {
1065
+ h.cleanup();
1066
+ }
1067
+ });
1068
+
1069
+ test("hub-unit bringup failure (e.g. no service manager) → init exits 1 with the actionable message", async () => {
1070
+ const h = makeHarness();
1071
+ try {
1072
+ const logs: string[] = [];
1073
+ const code = await init({
1074
+ configDir: h.configDir,
1075
+ manifestPath: h.manifestPath,
1076
+ log: (l) => logs.push(l),
1077
+ alive: () => false,
1078
+ // Mirror what the production default throws when there's no manager.
1079
+ ensureHub: async () => {
1080
+ throw new Error(
1081
+ "no service manager (systemd/launchd) found — run `parachute serve` in the foreground, or use a platform that provides one",
1082
+ );
1083
+ },
1084
+ guaranteeOperatorToken: async () => "no-user",
1085
+ readExposeStateFn: () => undefined,
1086
+ isTty: false,
1087
+ platform: "linux",
1088
+ installVaultModuleImpl: noopVaultInstall,
1089
+ });
1090
+ expect(code).toBe(1);
1091
+ const joined = logs.join("\n");
1092
+ expect(joined).toContain("no service manager (systemd/launchd) found");
1093
+ expect(joined).toContain("parachute logs hub");
1094
+ } finally {
1095
+ h.cleanup();
1096
+ }
1097
+ });
1098
+
882
1099
  test("after exposure runs, the admin URL re-reads expose state for the FQDN", async () => {
883
1100
  const h = makeHarness();
884
1101
  try {
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
+ type LifecycleOpts,
6
7
  defaultAlive,
7
8
  defaultKill,
8
9
  defaultSpawner,
@@ -14,7 +15,14 @@ import {
14
15
  import { readEnvFileValues } from "../env-file.ts";
15
16
  import { writeHubPort } from "../hub-control.ts";
16
17
  import { hubDbPath, openHubDb } from "../hub-db.ts";
18
+ import type { HubUnitManagerOpResult } from "../hub-unit.ts";
17
19
  import { validateAccessToken } from "../jwt-sign.ts";
20
+ import {
21
+ type ModuleOp,
22
+ ModuleOpHttpError,
23
+ type ModuleOpResult,
24
+ NoOperatorTokenError,
25
+ } from "../module-ops-client.ts";
18
26
  import {
19
27
  OPERATOR_TOKEN_SCOPE_SET_CLAIM,
20
28
  issueOperatorToken,
@@ -1862,3 +1870,418 @@ describe("parachute start|stop|restart hub", () => {
1862
1870
  }
1863
1871
  });
1864
1872
  });
1873
+
1874
+ // ---------------------------------------------------------------------------
1875
+ // Phase 3b dual-dispatch (design §3.3): when a hub UNIT is installed, the verbs
1876
+ // drive the running supervisor / platform manager; when no unit is installed,
1877
+ // the existing detached arm is taken UNCHANGED. These suites exercise both arms
1878
+ // via injected `supervisor` seams (unit arm) and a stub spawner (detached arm).
1879
+ // ---------------------------------------------------------------------------
1880
+
1881
+ interface SupervisorStub {
1882
+ opts: NonNullable<LifecycleOpts["supervisor"]>;
1883
+ driveCalls: Array<{ short: string; op: ModuleOp }>;
1884
+ ensureCalls: Array<{ port?: number }>;
1885
+ stopHubCalls: number;
1886
+ restartHubCalls: number;
1887
+ healthProbes: number;
1888
+ }
1889
+
1890
+ /**
1891
+ * Build a `supervisor` seam that forces the unit-installed arm and records the
1892
+ * supervisor / manager calls. `driveResponder` lets a test return a result or
1893
+ * throw a module-ops error per (short, op). The default responder returns a
1894
+ * benign sync-op result. `health` controls `probeHubHealth`.
1895
+ */
1896
+ function makeSupervisorStub(opts?: {
1897
+ health?: boolean;
1898
+ ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
1899
+ ensureMessages?: string[];
1900
+ driveResponder?: (short: string, op: ModuleOp) => ModuleOpResult | Promise<ModuleOpResult>;
1901
+ stopHubResult?: HubUnitManagerOpResult;
1902
+ restartHubResult?: HubUnitManagerOpResult;
1903
+ }): SupervisorStub {
1904
+ const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
1905
+ const ensureCalls: Array<{ port?: number }> = [];
1906
+ const stub: SupervisorStub = {
1907
+ driveCalls,
1908
+ ensureCalls,
1909
+ stopHubCalls: 0,
1910
+ restartHubCalls: 0,
1911
+ healthProbes: 0,
1912
+ opts: {
1913
+ unitInstalled: true,
1914
+ // openDb is never exercised by the stub driveModuleOp, but the dispatch
1915
+ // opens+closes it around the call — hand back a no-op closer.
1916
+ openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
1917
+ driveModuleOp: async (short, op) => {
1918
+ driveCalls.push({ short, op });
1919
+ if (opts?.driveResponder) return await opts.driveResponder(short, op);
1920
+ return { status: 200, body: { short, state: { status: "running" } } };
1921
+ },
1922
+ ensureHubUnit: async (o) => {
1923
+ ensureCalls.push({ port: o.port });
1924
+ return {
1925
+ outcome: opts?.ensureOutcome ?? "already-up",
1926
+ port: o.port ?? 1939,
1927
+ messages: opts?.ensureMessages ?? [],
1928
+ };
1929
+ },
1930
+ stopHubUnit: () => {
1931
+ stub.stopHubCalls++;
1932
+ return opts?.stopHubResult ?? { outcome: "ok", messages: [] };
1933
+ },
1934
+ restartHubUnit: () => {
1935
+ stub.restartHubCalls++;
1936
+ return opts?.restartHubResult ?? { outcome: "ok", messages: [] };
1937
+ },
1938
+ probeHubHealth: async () => {
1939
+ stub.healthProbes++;
1940
+ return opts?.health ?? true;
1941
+ },
1942
+ },
1943
+ };
1944
+ return stub;
1945
+ }
1946
+
1947
+ describe("Phase 3b dual-dispatch — start", () => {
1948
+ test("module svc, unit-installed → ensureHubUnit then driveModuleOp(start)", async () => {
1949
+ const h = makeHarness();
1950
+ try {
1951
+ const sup = makeSupervisorStub();
1952
+ const log: string[] = [];
1953
+ const code = await start("vault", {
1954
+ configDir: h.configDir,
1955
+ manifestPath: h.manifestPath,
1956
+ log: (l) => log.push(l),
1957
+ supervisor: sup.opts,
1958
+ });
1959
+ expect(code).toBe(0);
1960
+ expect(sup.ensureCalls).toHaveLength(1);
1961
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
1962
+ expect(log.join("\n")).toMatch(/✓ vault started/);
1963
+ } finally {
1964
+ h.cleanup();
1965
+ }
1966
+ });
1967
+
1968
+ test("no svc, unit-installed → ensureHubUnit only (boots all modules), no driveModuleOp", async () => {
1969
+ const h = makeHarness();
1970
+ try {
1971
+ const sup = makeSupervisorStub();
1972
+ const code = await start(undefined, {
1973
+ configDir: h.configDir,
1974
+ manifestPath: h.manifestPath,
1975
+ log: () => {},
1976
+ supervisor: sup.opts,
1977
+ });
1978
+ expect(code).toBe(0);
1979
+ expect(sup.ensureCalls).toHaveLength(1);
1980
+ expect(sup.driveCalls).toHaveLength(0);
1981
+ } finally {
1982
+ h.cleanup();
1983
+ }
1984
+ });
1985
+
1986
+ test("module svc, NO unit → existing detached spawner path (unchanged)", async () => {
1987
+ const h = makeHarness();
1988
+ try {
1989
+ seedVault(h.manifestPath);
1990
+ const spawner = makeSpawner([4242]);
1991
+ const code = await start("vault", {
1992
+ configDir: h.configDir,
1993
+ manifestPath: h.manifestPath,
1994
+ spawner,
1995
+ log: () => {},
1996
+ // supervisor omitted → detached arm, deterministically.
1997
+ });
1998
+ expect(code).toBe(0);
1999
+ expect(spawner.calls).toHaveLength(1);
2000
+ expect(spawner.calls[0]?.cmd).toEqual(["parachute-vault", "serve"]);
2001
+ expect(readPid("vault", h.configDir)).toBe(4242);
2002
+ } finally {
2003
+ h.cleanup();
2004
+ }
2005
+ });
2006
+
2007
+ test("module svc, NoOperatorTokenError → actionable message surfaced (not raw-thrown)", async () => {
2008
+ const h = makeHarness();
2009
+ try {
2010
+ const sup = makeSupervisorStub({
2011
+ driveResponder: () => {
2012
+ throw new NoOperatorTokenError();
2013
+ },
2014
+ });
2015
+ const log: string[] = [];
2016
+ const code = await start("vault", {
2017
+ configDir: h.configDir,
2018
+ manifestPath: h.manifestPath,
2019
+ log: (l) => log.push(l),
2020
+ supervisor: sup.opts,
2021
+ });
2022
+ expect(code).toBe(1);
2023
+ expect(log.join("\n")).toMatch(/no operator token/);
2024
+ expect(log.join("\n")).toMatch(/parachute auth rotate-operator/);
2025
+ } finally {
2026
+ h.cleanup();
2027
+ }
2028
+ });
2029
+
2030
+ test("module svc, 400 not_installed → actionable install hint", async () => {
2031
+ const h = makeHarness();
2032
+ try {
2033
+ const sup = makeSupervisorStub({
2034
+ driveResponder: () => {
2035
+ throw new ModuleOpHttpError(400, "not_installed", "vault is not installed");
2036
+ },
2037
+ });
2038
+ const log: string[] = [];
2039
+ const code = await start("vault", {
2040
+ configDir: h.configDir,
2041
+ manifestPath: h.manifestPath,
2042
+ log: (l) => log.push(l),
2043
+ supervisor: sup.opts,
2044
+ });
2045
+ expect(code).toBe(1);
2046
+ expect(log.join("\n")).toMatch(/not installed/);
2047
+ expect(log.join("\n")).toMatch(/parachute install vault/);
2048
+ } finally {
2049
+ h.cleanup();
2050
+ }
2051
+ });
2052
+ });
2053
+
2054
+ describe("Phase 3b dual-dispatch — stop", () => {
2055
+ test("module svc, hub UP → driveModuleOp(stop), no ensureHubUnit", async () => {
2056
+ const h = makeHarness();
2057
+ try {
2058
+ const sup = makeSupervisorStub({ health: true });
2059
+ const log: string[] = [];
2060
+ const code = await stop("vault", {
2061
+ configDir: h.configDir,
2062
+ manifestPath: h.manifestPath,
2063
+ log: (l) => log.push(l),
2064
+ supervisor: sup.opts,
2065
+ });
2066
+ expect(code).toBe(0);
2067
+ expect(sup.healthProbes).toBe(1);
2068
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
2069
+ expect(sup.ensureCalls).toHaveLength(0); // never start the hub just to stop a module
2070
+ expect(log.join("\n")).toMatch(/✓ vault stopped/);
2071
+ } finally {
2072
+ h.cleanup();
2073
+ }
2074
+ });
2075
+
2076
+ test("module svc, hub DOWN → success WITHOUT starting the hub or driving stop", async () => {
2077
+ const h = makeHarness();
2078
+ try {
2079
+ const sup = makeSupervisorStub({ health: false });
2080
+ const log: string[] = [];
2081
+ const code = await stop("vault", {
2082
+ configDir: h.configDir,
2083
+ manifestPath: h.manifestPath,
2084
+ log: (l) => log.push(l),
2085
+ supervisor: sup.opts,
2086
+ });
2087
+ expect(code).toBe(0);
2088
+ expect(sup.healthProbes).toBe(1);
2089
+ expect(sup.driveCalls).toHaveLength(0); // nothing to stop — module already down
2090
+ expect(sup.ensureCalls).toHaveLength(0); // did NOT ensureHubUnit
2091
+ expect(log.join("\n")).toMatch(/already stopped/);
2092
+ } finally {
2093
+ h.cleanup();
2094
+ }
2095
+ });
2096
+
2097
+ test("stop hub → platform manager (stopHubUnit), never a PID signal", async () => {
2098
+ const h = makeHarness();
2099
+ try {
2100
+ const sup = makeSupervisorStub();
2101
+ // A kill seam that fails the test if ANY PID signal is sent.
2102
+ const kill = () => {
2103
+ throw new Error("stop hub must NOT signal a PID — it must go through the manager");
2104
+ };
2105
+ const log: string[] = [];
2106
+ const code = await stop("hub", {
2107
+ configDir: h.configDir,
2108
+ manifestPath: h.manifestPath,
2109
+ log: (l) => log.push(l),
2110
+ kill,
2111
+ supervisor: sup.opts,
2112
+ });
2113
+ expect(code).toBe(0);
2114
+ expect(sup.stopHubCalls).toBe(1);
2115
+ expect(sup.healthProbes).toBe(0);
2116
+ expect(log.join("\n")).toMatch(/✓ hub stopped/);
2117
+ } finally {
2118
+ h.cleanup();
2119
+ }
2120
+ });
2121
+
2122
+ test("no svc, unit-installed → stop the hub unit (manager)", async () => {
2123
+ const h = makeHarness();
2124
+ try {
2125
+ const sup = makeSupervisorStub();
2126
+ const code = await stop(undefined, {
2127
+ configDir: h.configDir,
2128
+ manifestPath: h.manifestPath,
2129
+ log: () => {},
2130
+ supervisor: sup.opts,
2131
+ });
2132
+ expect(code).toBe(0);
2133
+ expect(sup.stopHubCalls).toBe(1);
2134
+ expect(sup.driveCalls).toHaveLength(0);
2135
+ } finally {
2136
+ h.cleanup();
2137
+ }
2138
+ });
2139
+
2140
+ test("module svc, NO unit → existing detached stop path (unchanged)", async () => {
2141
+ const h = makeHarness();
2142
+ try {
2143
+ seedVault(h.manifestPath);
2144
+ writePid("vault", 4242, h.configDir);
2145
+ const killed: Array<{ pid: number; sig: NodeJS.Signals | number }> = [];
2146
+ const code = await stop("vault", {
2147
+ configDir: h.configDir,
2148
+ manifestPath: h.manifestPath,
2149
+ kill: (pid, sig) => killed.push({ pid, sig }),
2150
+ alive: (() => {
2151
+ let n = 0;
2152
+ return () => n++ < 1; // alive once (for the SIGTERM), then dead.
2153
+ })(),
2154
+ log: () => {},
2155
+ // supervisor omitted → detached arm.
2156
+ });
2157
+ expect(code).toBe(0);
2158
+ expect(killed[0]).toEqual({ pid: 4242, sig: "SIGTERM" });
2159
+ expect(readPid("vault", h.configDir)).toBeUndefined();
2160
+ } finally {
2161
+ h.cleanup();
2162
+ }
2163
+ });
2164
+ });
2165
+
2166
+ describe("Phase 3b dual-dispatch — restart", () => {
2167
+ test("module svc, unit-installed → ensureHubUnit then driveModuleOp(restart)", async () => {
2168
+ const h = makeHarness();
2169
+ try {
2170
+ const sup = makeSupervisorStub();
2171
+ const log: string[] = [];
2172
+ const code = await restart("vault", {
2173
+ configDir: h.configDir,
2174
+ manifestPath: h.manifestPath,
2175
+ log: (l) => log.push(l),
2176
+ supervisor: sup.opts,
2177
+ });
2178
+ expect(code).toBe(0);
2179
+ expect(sup.ensureCalls).toHaveLength(1);
2180
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
2181
+ expect(log.join("\n")).toMatch(/✓ vault restarted/);
2182
+ } finally {
2183
+ h.cleanup();
2184
+ }
2185
+ });
2186
+
2187
+ test("404 not_supervised on restart → fall through to driveModuleOp(start)", async () => {
2188
+ const h = makeHarness();
2189
+ try {
2190
+ const sup = makeSupervisorStub({
2191
+ driveResponder: (_short, op) => {
2192
+ if (op === "restart") {
2193
+ throw new ModuleOpHttpError(404, "not_supervised", "vault is not currently supervised");
2194
+ }
2195
+ return { status: 200, body: { short: "vault", state: { status: "running" } } };
2196
+ },
2197
+ });
2198
+ const log: string[] = [];
2199
+ const code = await restart("vault", {
2200
+ configDir: h.configDir,
2201
+ manifestPath: h.manifestPath,
2202
+ log: (l) => log.push(l),
2203
+ supervisor: sup.opts,
2204
+ });
2205
+ expect(code).toBe(0);
2206
+ // restart was attempted, then start as the 404-fallthrough (§6.2).
2207
+ expect(sup.driveCalls).toEqual([
2208
+ { short: "vault", op: "restart" },
2209
+ { short: "vault", op: "start" },
2210
+ ]);
2211
+ expect(log.join("\n")).toMatch(/✓ vault started/);
2212
+ } finally {
2213
+ h.cleanup();
2214
+ }
2215
+ });
2216
+
2217
+ test("restart hub → platform manager (restartHubUnit), never a PID signal", async () => {
2218
+ const h = makeHarness();
2219
+ try {
2220
+ const sup = makeSupervisorStub();
2221
+ const kill = () => {
2222
+ throw new Error("restart hub must NOT signal a PID — it must go through the manager");
2223
+ };
2224
+ const log: string[] = [];
2225
+ const code = await restart("hub", {
2226
+ configDir: h.configDir,
2227
+ manifestPath: h.manifestPath,
2228
+ log: (l) => log.push(l),
2229
+ kill,
2230
+ supervisor: sup.opts,
2231
+ });
2232
+ expect(code).toBe(0);
2233
+ expect(sup.restartHubCalls).toBe(1);
2234
+ expect(sup.driveCalls).toHaveLength(0); // NOT a per-module fan-out
2235
+ expect(log.join("\n")).toMatch(/✓ hub restarted/);
2236
+ } finally {
2237
+ h.cleanup();
2238
+ }
2239
+ });
2240
+
2241
+ test("no svc, unit-installed → restart the hub unit (manager), not a fan-out", async () => {
2242
+ const h = makeHarness();
2243
+ try {
2244
+ const sup = makeSupervisorStub();
2245
+ const code = await restart(undefined, {
2246
+ configDir: h.configDir,
2247
+ manifestPath: h.manifestPath,
2248
+ log: () => {},
2249
+ supervisor: sup.opts,
2250
+ });
2251
+ expect(code).toBe(0);
2252
+ expect(sup.restartHubCalls).toBe(1);
2253
+ expect(sup.driveCalls).toHaveLength(0);
2254
+ } finally {
2255
+ h.cleanup();
2256
+ }
2257
+ });
2258
+
2259
+ test("module svc, NO unit → existing detached stop-then-start path (unchanged)", async () => {
2260
+ const h = makeHarness();
2261
+ try {
2262
+ seedVault(h.manifestPath);
2263
+ writePid("vault", 4242, h.configDir);
2264
+ const spawner = makeSpawner([7777]);
2265
+ const killed: Array<{ pid: number; sig: NodeJS.Signals | number }> = [];
2266
+ const code = await restart("vault", {
2267
+ configDir: h.configDir,
2268
+ manifestPath: h.manifestPath,
2269
+ spawner,
2270
+ kill: (pid, sig) => killed.push({ pid, sig }),
2271
+ // Stale 4242 is dead (stop's stale-pid path cleans up without a kill);
2272
+ // freshly spawned 7777 is alive past the post-spawn settle (hub#194).
2273
+ // Per-pid differentiation, mirroring the existing detached-restart test.
2274
+ alive: (pid) => pid === 7777,
2275
+ sleep: async () => {},
2276
+ log: () => {},
2277
+ // supervisor omitted → detached arm (stop then start).
2278
+ });
2279
+ expect(code).toBe(0);
2280
+ expect(killed).toHaveLength(0); // stale pid → detached cleanup, no kill
2281
+ expect(spawner.calls).toHaveLength(1); // detached start ran (NOT the supervisor)
2282
+ expect(readPid("vault", h.configDir)).toBe(7777);
2283
+ } finally {
2284
+ h.cleanup();
2285
+ }
2286
+ });
2287
+ });