@openparachute/hub 0.6.3-rc.1 → 0.6.3-rc.2

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.
Files changed (40) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  5. package/src/__tests__/expose-off-auto.test.ts +26 -1
  6. package/src/__tests__/expose.test.ts +260 -240
  7. package/src/__tests__/hub-control.test.ts +1 -242
  8. package/src/__tests__/hub-server.test.ts +64 -0
  9. package/src/__tests__/lifecycle.test.ts +431 -1886
  10. package/src/__tests__/migrate-cutover.test.ts +840 -0
  11. package/src/__tests__/migrate-offer.test.ts +240 -0
  12. package/src/__tests__/migrate.test.ts +132 -0
  13. package/src/__tests__/status-supervisor.test.ts +12 -77
  14. package/src/__tests__/status.test.ts +157 -708
  15. package/src/__tests__/upgrade.test.ts +351 -5
  16. package/src/api-hub-upgrade.ts +384 -0
  17. package/src/api-hub.ts +2 -1
  18. package/src/cli.ts +85 -10
  19. package/src/commands/expose-cloudflare.ts +63 -71
  20. package/src/commands/expose-supervisor.ts +247 -0
  21. package/src/commands/expose.ts +59 -48
  22. package/src/commands/lifecycle.ts +184 -873
  23. package/src/commands/migrate-cutover.ts +837 -0
  24. package/src/commands/migrate.ts +71 -2
  25. package/src/commands/status.ts +35 -282
  26. package/src/commands/upgrade.ts +100 -2
  27. package/src/help.ts +128 -68
  28. package/src/hub-control.ts +23 -162
  29. package/src/hub-server.ts +28 -0
  30. package/src/hub-upgrade-helper.ts +306 -0
  31. package/src/hub-upgrade-mode.ts +209 -0
  32. package/src/hub-upgrade-status.ts +150 -0
  33. package/src/managed-unit.ts +20 -2
  34. package/src/migrate-offer.ts +186 -0
  35. package/src/process-state.ts +19 -3
  36. package/src/supervisor.ts +29 -24
  37. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  38. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -1,36 +1,46 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  type LifecycleOpts,
7
7
  defaultAlive,
8
8
  defaultKill,
9
- defaultSpawner,
10
9
  logs,
11
10
  restart,
12
11
  start,
13
12
  stop,
14
13
  } from "../commands/lifecycle.ts";
15
- import { readEnvFileValues } from "../env-file.ts";
16
- import { writeHubPort } from "../hub-control.ts";
17
- import { hubDbPath, openHubDb } from "../hub-db.ts";
18
14
  import type { HubUnitManagerOpResult } from "../hub-unit.ts";
19
- import { validateAccessToken } from "../jwt-sign.ts";
15
+ import type { MigrateOfferOpts, MigrateOfferResult } from "../migrate-offer.ts";
20
16
  import {
21
17
  type ModuleOp,
22
18
  ModuleOpHttpError,
23
19
  type ModuleOpResult,
24
20
  NoOperatorTokenError,
25
21
  } from "../module-ops-client.ts";
26
- import {
27
- OPERATOR_TOKEN_SCOPE_SET_CLAIM,
28
- issueOperatorToken,
29
- readOperatorTokenFile,
30
- } from "../operator-token.ts";
31
- import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
32
- import { readManifest, upsertService } from "../services-manifest.ts";
33
- import { rotateSigningKey } from "../signing-keys.ts";
22
+ import { ensureLogPath, writePid } from "../process-state.ts";
23
+ import { upsertService } from "../services-manifest.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Phase 5b: the supervised path is the ONLY runtime. The detached spawners were
27
+ // retired, so these suites exercise (a) the supervisor-path dispatch (hub UNIT
28
+ // installed → drive the running supervisor / platform manager), (b) the no-unit
29
+ // path (§7.5 auto-offer / actionable error — NEVER a detached spawn), and (c)
30
+ // the group-aware kill/alive primitives that survive for `logs` + future use.
31
+ //
32
+ // Coverage that MOVED with the retirement (no longer asserted here):
33
+ // - per-module spawn / env injection / PORT override / cwd / startCmd
34
+ // resolution / missing-dependency preflight → now the supervisor's job,
35
+ // asserted in `supervisor.test.ts` + `api-modules-ops.test.ts`.
36
+ // - hub#194 settle + hub#487 port-readiness → supervisor post-spawn readiness,
37
+ // asserted in `supervisor.test.ts`.
38
+ // - process-GROUP spawn (`detached: true`) → the supervisor's group-spawn,
39
+ // asserted in `supervisor.test.ts` (`defaultKillGroup` + the real round-trip).
40
+ // - `start|stop|restart hub` via `ensureHubRunning`/`stopHub` → now the
41
+ // platform-manager path (`ensureHubUnit`/`stopHubUnit`/`restartHubUnit`),
42
+ // asserted in the dual-dispatch suites below.
43
+ // ---------------------------------------------------------------------------
34
44
 
35
45
  interface Harness {
36
46
  configDir: string;
@@ -60,2226 +70,761 @@ function seedVault(manifestPath: string): void {
60
70
  );
61
71
  }
62
72
 
63
- function seedNotes(manifestPath: string): void {
64
- upsertService(
65
- {
66
- name: "parachute-notes",
67
- port: 5173,
68
- paths: ["/notes"],
69
- health: "/notes/health",
70
- version: "0.0.1",
71
- },
72
- manifestPath,
73
- );
74
- }
75
-
76
- interface ThirdPartySeed {
77
- installDir: string;
78
- manifestName?: string;
79
- startCmd?: readonly string[];
80
- port?: number;
73
+ interface SupervisorStub {
74
+ opts: NonNullable<LifecycleOpts["supervisor"]>;
75
+ driveCalls: Array<{ short: string; op: ModuleOp }>;
76
+ ensureCalls: Array<{ port?: number }>;
77
+ stopHubCalls: number;
78
+ restartHubCalls: number;
79
+ healthProbes: number;
81
80
  }
82
81
 
83
82
  /**
84
- * Seed a third-party services.json row + write a `.parachute/module.json` at
85
- * `installDir`. Mirrors what `parachute install /tmp/foo` produces in
86
- * production: row carries `installDir`, lifecycle resolves spec from the
87
- * filesystem.
83
+ * Build a `supervisor` seam that forces the unit-installed arm and records the
84
+ * supervisor / manager calls. `driveResponder` lets a test return a result or
85
+ * throw a module-ops error per (short, op). The default responder returns a
86
+ * benign sync-op result. `health` controls `probeHubHealth`.
88
87
  */
89
- function seedThirdParty(
90
- manifestPath: string,
91
- configDirRoot: string,
92
- name: string,
93
- opts: ThirdPartySeed,
94
- ): string {
95
- const installDir = opts.installDir;
96
- mkdirSync(join(installDir, ".parachute"), { recursive: true });
97
- const manifest = {
98
- name,
99
- manifestName: opts.manifestName ?? name,
100
- port: opts.port ?? 1944,
101
- paths: [`/${name}`],
102
- health: `/${name}/health`,
103
- ...(opts.startCmd ? { startCmd: opts.startCmd } : {}),
104
- };
105
- writeFileSync(join(installDir, ".parachute", "module.json"), JSON.stringify(manifest));
106
- upsertService(
107
- {
108
- name: opts.manifestName ?? name,
109
- port: opts.port ?? 1944,
110
- paths: [`/${name}`],
111
- health: `/${name}/health`,
112
- version: "0.0.1",
113
- installDir,
114
- },
115
- manifestPath,
116
- );
117
- return configDirRoot;
118
- }
119
-
120
- interface SpawnerStub {
121
- spawn: (
122
- cmd: readonly string[],
123
- logFile: string,
124
- opts?: { env?: Record<string, string>; cwd?: string },
125
- ) => number;
126
- calls: Array<{
127
- cmd: readonly string[];
128
- logFile: string;
129
- env?: Record<string, string>;
130
- cwd?: string;
131
- }>;
132
- }
133
-
134
- function makeSpawner(pidSequence: number[]): SpawnerStub {
135
- const calls: Array<{
136
- cmd: readonly string[];
137
- logFile: string;
138
- env?: Record<string, string>;
139
- cwd?: string;
140
- }> = [];
141
- let i = 0;
142
- return {
143
- calls,
144
- spawn(cmd, logFile, opts) {
145
- calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
146
- return pidSequence[i++] ?? 99999;
88
+ function makeSupervisorStub(opts?: {
89
+ health?: boolean;
90
+ ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
91
+ ensureMessages?: string[];
92
+ driveResponder?: (short: string, op: ModuleOp) => ModuleOpResult | Promise<ModuleOpResult>;
93
+ stopHubResult?: HubUnitManagerOpResult;
94
+ restartHubResult?: HubUnitManagerOpResult;
95
+ }): SupervisorStub {
96
+ const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
97
+ const ensureCalls: Array<{ port?: number }> = [];
98
+ const stub: SupervisorStub = {
99
+ driveCalls,
100
+ ensureCalls,
101
+ stopHubCalls: 0,
102
+ restartHubCalls: 0,
103
+ healthProbes: 0,
104
+ opts: {
105
+ unitInstalled: true,
106
+ // openDb is never exercised by the stub driveModuleOp, but the dispatch
107
+ // opens+closes it around the call — hand back a no-op closer.
108
+ openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
109
+ driveModuleOp: async (short, op) => {
110
+ driveCalls.push({ short, op });
111
+ if (opts?.driveResponder) return await opts.driveResponder(short, op);
112
+ return { status: 200, body: { short, state: { status: "running" } } };
113
+ },
114
+ ensureHubUnit: async (o) => {
115
+ ensureCalls.push({ port: o.port });
116
+ return {
117
+ outcome: opts?.ensureOutcome ?? "already-up",
118
+ port: o.port ?? 1939,
119
+ messages: opts?.ensureMessages ?? [],
120
+ };
121
+ },
122
+ stopHubUnit: () => {
123
+ stub.stopHubCalls++;
124
+ return opts?.stopHubResult ?? { outcome: "ok", messages: [] };
125
+ },
126
+ restartHubUnit: () => {
127
+ stub.restartHubCalls++;
128
+ return opts?.restartHubResult ?? { outcome: "ok", messages: [] };
129
+ },
130
+ probeHubHealth: async () => {
131
+ stub.healthProbes++;
132
+ return opts?.health ?? true;
133
+ },
147
134
  },
148
135
  };
136
+ return stub;
149
137
  }
150
138
 
151
- describe("parachute start", () => {
152
- test("errors cleanly when no services installed", async () => {
139
+ // ---------------------------------------------------------------------------
140
+ // Supervisor-path dispatch (design §3.3): a hub UNIT is installed the verbs
141
+ // drive the running supervisor (per-module ops) / platform manager (hub verbs).
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe("start — supervisor path", () => {
145
+ test("module svc, unit-installed → ensureHubUnit then driveModuleOp(start)", async () => {
153
146
  const h = makeHarness();
154
147
  try {
155
- const logs: string[] = [];
156
- const code = await start(undefined, {
148
+ const sup = makeSupervisorStub();
149
+ const log: string[] = [];
150
+ const code = await start("vault", {
157
151
  configDir: h.configDir,
158
152
  manifestPath: h.manifestPath,
159
- log: (l) => logs.push(l),
153
+ log: (l) => log.push(l),
154
+ supervisor: sup.opts,
160
155
  });
161
- expect(code).toBe(1);
162
- expect(logs.join("\n")).toMatch(/No services installed/);
156
+ expect(code).toBe(0);
157
+ expect(sup.ensureCalls).toHaveLength(1);
158
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
159
+ expect(log.join("\n")).toMatch(/✓ vault started/);
163
160
  } finally {
164
161
  h.cleanup();
165
162
  }
166
163
  });
167
164
 
168
- test("errors cleanly when targeting an uninstalled service", async () => {
165
+ test("no svc, unit-installed ensureHubUnit only (boots all modules), no driveModuleOp", async () => {
169
166
  const h = makeHarness();
170
167
  try {
171
- seedVault(h.manifestPath);
172
- const logs: string[] = [];
173
- const code = await start("notes", {
168
+ const sup = makeSupervisorStub();
169
+ const code = await start(undefined, {
174
170
  configDir: h.configDir,
175
171
  manifestPath: h.manifestPath,
176
- log: (l) => logs.push(l),
172
+ log: () => {},
173
+ supervisor: sup.opts,
177
174
  });
178
- expect(code).toBe(1);
179
- expect(logs.join("\n")).toMatch(/notes isn't installed/);
175
+ expect(code).toBe(0);
176
+ expect(sup.ensureCalls).toHaveLength(1);
177
+ expect(sup.driveCalls).toHaveLength(0);
180
178
  } finally {
181
179
  h.cleanup();
182
180
  }
183
181
  });
184
182
 
185
- test("spawns vault with parachute-vault serve, writes PID", async () => {
183
+ test("module svc, NoOperatorTokenError actionable message surfaced (not raw-thrown)", async () => {
186
184
  const h = makeHarness();
187
185
  try {
188
- seedVault(h.manifestPath);
189
- const spawner = makeSpawner([4242]);
190
- const logs: string[] = [];
186
+ const sup = makeSupervisorStub({
187
+ driveResponder: () => {
188
+ throw new NoOperatorTokenError();
189
+ },
190
+ });
191
+ const log: string[] = [];
191
192
  const code = await start("vault", {
192
193
  configDir: h.configDir,
193
194
  manifestPath: h.manifestPath,
194
- spawner,
195
- log: (l) => logs.push(l),
195
+ log: (l) => log.push(l),
196
+ supervisor: sup.opts,
196
197
  });
197
- expect(code).toBe(0);
198
- expect(spawner.calls).toHaveLength(1);
199
- expect(spawner.calls[0]?.cmd).toEqual(["parachute-vault", "serve"]);
200
- expect(spawner.calls[0]?.logFile).toBe(logPath("vault", h.configDir));
201
- expect(readPid("vault", h.configDir)).toBe(4242);
202
- expect(logs.join("\n")).toMatch(/vault started \(pid 4242\)/);
198
+ expect(code).toBe(1);
199
+ expect(log.join("\n")).toMatch(/no operator token/);
200
+ expect(log.join("\n")).toMatch(/parachute auth rotate-operator/);
203
201
  } finally {
204
202
  h.cleanup();
205
203
  }
206
204
  });
207
205
 
208
- test("missing startCmd binaryfriendly missing-dependency message + no spawn", async () => {
206
+ test("module svc, 400 not_installed actionable install hint", async () => {
209
207
  const h = makeHarness();
210
208
  try {
211
- seedVault(h.manifestPath);
212
- const spawner = makeSpawner([4242]);
213
- const logs: string[] = [];
209
+ const sup = makeSupervisorStub({
210
+ driveResponder: () => {
211
+ throw new ModuleOpHttpError(400, "not_installed", "vault is not installed");
212
+ },
213
+ });
214
+ const log: string[] = [];
214
215
  const code = await start("vault", {
215
216
  configDir: h.configDir,
216
217
  manifestPath: h.manifestPath,
217
- spawner,
218
- // Force the preflight's missing-binary branch: parachute-vault not on PATH.
219
- which: () => null,
220
- log: (l) => logs.push(l),
218
+ log: (l) => log.push(l),
219
+ supervisor: sup.opts,
221
220
  });
222
221
  expect(code).toBe(1);
223
- // Preflight fired before the spawn — the stub spawner is never called.
224
- expect(spawner.calls).toHaveLength(0);
225
- const out = logs.join("\n");
226
- expect(out).toMatch(/vault failed to start/);
227
- // The friendly install block names the binary + its install path.
228
- expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
229
- expect(out).toContain("parachute install vault");
230
- expect(readPid("vault", h.configDir)).toBeUndefined();
222
+ expect(log.join("\n")).toMatch(/not installed/);
223
+ expect(log.join("\n")).toMatch(/parachute install vault/);
231
224
  } finally {
232
225
  h.cleanup();
233
226
  }
234
227
  });
228
+ });
235
229
 
236
- test("missing startCmd binary persists lastStartError so a later status surfaces it", async () => {
230
+ describe("stop supervisor path", () => {
231
+ test("module svc, hub UP → driveModuleOp(stop), no ensureHubUnit", async () => {
237
232
  const h = makeHarness();
238
233
  try {
239
- seedVault(h.manifestPath);
240
- await start("vault", {
234
+ const sup = makeSupervisorStub({ health: true });
235
+ const log: string[] = [];
236
+ const code = await stop("vault", {
241
237
  configDir: h.configDir,
242
238
  manifestPath: h.manifestPath,
243
- spawner: makeSpawner([4242]),
244
- which: () => null,
245
- log: () => {},
239
+ log: (l) => log.push(l),
240
+ supervisor: sup.opts,
246
241
  });
247
- const entry = readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault");
248
- expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
249
- expect(entry?.lastStartError?.binary).toBe("parachute-vault");
250
- expect(entry?.lastStartError?.at).toBeDefined();
242
+ expect(code).toBe(0);
243
+ expect(sup.healthProbes).toBe(1);
244
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
245
+ expect(sup.ensureCalls).toHaveLength(0); // never start the hub just to stop a module
246
+ expect(log.join("\n")).toMatch(/✓ vault stopped/);
251
247
  } finally {
252
248
  h.cleanup();
253
249
  }
254
250
  });
255
251
 
256
- test("a successful start clears a previously-recorded lastStartError", async () => {
252
+ test("module svc, hub DOWN success WITHOUT starting the hub or driving stop", async () => {
257
253
  const h = makeHarness();
258
254
  try {
259
- seedVault(h.manifestPath);
260
- // First start fails (binary missing) → records the error.
261
- await start("vault", {
262
- configDir: h.configDir,
263
- manifestPath: h.manifestPath,
264
- spawner: makeSpawner([1]),
265
- which: () => null,
266
- log: () => {},
267
- });
268
- expect(
269
- readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
270
- ?.lastStartError,
271
- ).toBeDefined();
272
- // Second start succeeds (binary present via the permissive default which
273
- // — stub spawner path) → clears the recorded error.
274
- await start("vault", {
255
+ const sup = makeSupervisorStub({ health: false });
256
+ const log: string[] = [];
257
+ const code = await stop("vault", {
275
258
  configDir: h.configDir,
276
259
  manifestPath: h.manifestPath,
277
- spawner: makeSpawner([4242]),
278
- log: () => {},
260
+ log: (l) => log.push(l),
261
+ supervisor: sup.opts,
279
262
  });
280
- expect(
281
- readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
282
- ?.lastStartError,
283
- ).toBeUndefined();
263
+ expect(code).toBe(0);
264
+ expect(sup.healthProbes).toBe(1);
265
+ expect(sup.driveCalls).toHaveLength(0); // nothing to stop — module already down
266
+ expect(sup.ensureCalls).toHaveLength(0); // did NOT ensureHubUnit
267
+ expect(log.join("\n")).toMatch(/already stopped/);
284
268
  } finally {
285
269
  h.cleanup();
286
270
  }
287
271
  });
288
272
 
289
- test("notes start command includes configured port and notes-serve shim path", async () => {
273
+ test("stop hub platform manager (stopHubUnit), never a PID signal", async () => {
290
274
  const h = makeHarness();
291
275
  try {
292
- seedNotes(h.manifestPath);
293
- const spawner = makeSpawner([5151]);
294
- const code = await start("notes", {
276
+ const sup = makeSupervisorStub();
277
+ const log: string[] = [];
278
+ const code = await stop("hub", {
295
279
  configDir: h.configDir,
296
280
  manifestPath: h.manifestPath,
297
- spawner,
298
- log: () => {},
281
+ log: (l) => log.push(l),
282
+ supervisor: sup.opts,
299
283
  });
300
284
  expect(code).toBe(0);
301
- const cmd = spawner.calls[0]?.cmd ?? [];
302
- expect(cmd[0]).toBe("bun");
303
- expect(cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
304
- const portIdx = cmd.indexOf("--port");
305
- expect(portIdx).toBeGreaterThan(-1);
306
- expect(cmd[portIdx + 1]).toBe("5173");
307
- const mountIdx = cmd.indexOf("--mount");
308
- expect(mountIdx).toBeGreaterThan(-1);
309
- expect(cmd[mountIdx + 1]).toBe("/notes");
285
+ expect(sup.stopHubCalls).toBe(1);
286
+ expect(sup.healthProbes).toBe(0);
287
+ expect(log.join("\n")).toMatch(/✓ hub stopped/);
310
288
  } finally {
311
289
  h.cleanup();
312
290
  }
313
291
  });
314
292
 
315
- test("no-op when already running", async () => {
293
+ test("no svc, unit-installed stop the hub unit (manager)", async () => {
316
294
  const h = makeHarness();
317
295
  try {
318
- seedVault(h.manifestPath);
319
- writePid("vault", 4242, h.configDir);
320
- const spawner = makeSpawner([9999]);
321
- const logs: string[] = [];
322
- const code = await start("vault", {
296
+ const sup = makeSupervisorStub();
297
+ const code = await stop(undefined, {
323
298
  configDir: h.configDir,
324
299
  manifestPath: h.manifestPath,
325
- spawner,
326
- alive: () => true,
327
- log: (l) => logs.push(l),
300
+ log: () => {},
301
+ supervisor: sup.opts,
328
302
  });
329
303
  expect(code).toBe(0);
330
- expect(spawner.calls).toHaveLength(0);
331
- expect(logs.join("\n")).toMatch(/already running \(pid 4242\)/);
332
- expect(readPid("vault", h.configDir)).toBe(4242);
304
+ expect(sup.stopHubCalls).toBe(1);
305
+ expect(sup.driveCalls).toHaveLength(0);
333
306
  } finally {
334
307
  h.cleanup();
335
308
  }
336
309
  });
310
+ });
337
311
 
338
- test("clears stale PID file before spawning fresh", async () => {
312
+ describe("restart supervisor path", () => {
313
+ test("module svc, unit-installed → ensureHubUnit then driveModuleOp(restart)", async () => {
339
314
  const h = makeHarness();
340
315
  try {
341
- seedVault(h.manifestPath);
342
- writePid("vault", 4242, h.configDir);
343
- const spawner = makeSpawner([7777]);
344
- const code = await start("vault", {
316
+ const sup = makeSupervisorStub();
317
+ const log: string[] = [];
318
+ const code = await restart("vault", {
345
319
  configDir: h.configDir,
346
320
  manifestPath: h.manifestPath,
347
- spawner,
348
- // Stale 4242 is dead; the freshly spawned 7777 is alive — the
349
- // post-spawn settle (hub#194) calls alive(pid) on the new pid,
350
- // so we differentiate per-pid rather than blanket-false.
351
- alive: (pid) => pid === 7777,
352
- sleep: async () => {},
353
- log: () => {},
321
+ log: (l) => log.push(l),
322
+ supervisor: sup.opts,
354
323
  });
355
324
  expect(code).toBe(0);
356
- expect(spawner.calls).toHaveLength(1);
357
- expect(readPid("vault", h.configDir)).toBe(7777);
325
+ expect(sup.ensureCalls).toHaveLength(1);
326
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
327
+ expect(log.join("\n")).toMatch(/✓ vault restarted/);
358
328
  } finally {
359
329
  h.cleanup();
360
330
  }
361
331
  });
362
332
 
363
- test("start (no svc) targets every installed + known service", async () => {
333
+ test("404 not_supervised on restart fall through to driveModuleOp(start)", async () => {
364
334
  const h = makeHarness();
365
335
  try {
366
- seedVault(h.manifestPath);
367
- seedNotes(h.manifestPath);
368
- const spawner = makeSpawner([4242, 5151]);
369
- const code = await start(undefined, {
336
+ const sup = makeSupervisorStub({
337
+ driveResponder: (_short, op) => {
338
+ if (op === "restart") {
339
+ throw new ModuleOpHttpError(404, "not_supervised", "vault is not currently supervised");
340
+ }
341
+ return { status: 200, body: { short: "vault", state: { status: "running" } } };
342
+ },
343
+ });
344
+ const log: string[] = [];
345
+ const code = await restart("vault", {
370
346
  configDir: h.configDir,
371
347
  manifestPath: h.manifestPath,
372
- spawner,
373
- log: () => {},
348
+ log: (l) => log.push(l),
349
+ supervisor: sup.opts,
374
350
  });
375
351
  expect(code).toBe(0);
376
- expect(spawner.calls).toHaveLength(2);
377
- expect(readPid("vault", h.configDir)).toBe(4242);
378
- expect(readPid("notes", h.configDir)).toBe(5151);
352
+ // restart was attempted, then start as the 404-fallthrough (§6.2).
353
+ expect(sup.driveCalls).toEqual([
354
+ { short: "vault", op: "restart" },
355
+ { short: "vault", op: "start" },
356
+ ]);
357
+ expect(log.join("\n")).toMatch(/✓ vault started/);
379
358
  } finally {
380
359
  h.cleanup();
381
360
  }
382
361
  });
383
362
 
384
- test("legacy parachute-lens manifest entry still starts under the notes spec", async () => {
385
- // Users who installed during the brief Notes→Lens window (Apr 19–22)
386
- // will still have `parachute-lens` in services.json until their notes
387
- // package next boots and rewrites the row. Without the manifest alias,
388
- // shortNameForManifest returns undefined, resolveTargets skips the
389
- // entry, and they get "No manageable services" with no hint.
363
+ test("restart hub platform manager (restartHubUnit), never a PID signal", async () => {
390
364
  const h = makeHarness();
391
365
  try {
392
- upsertService(
393
- {
394
- name: "parachute-lens",
395
- port: 5173,
396
- paths: ["/lens"],
397
- health: "/lens/health",
398
- version: "0.0.1",
399
- },
400
- h.manifestPath,
401
- );
402
- const spawner = makeSpawner([5151]);
403
- const code = await start(undefined, {
366
+ const sup = makeSupervisorStub();
367
+ const log: string[] = [];
368
+ const code = await restart("hub", {
404
369
  configDir: h.configDir,
405
370
  manifestPath: h.manifestPath,
406
- spawner,
407
- log: () => {},
371
+ log: (l) => log.push(l),
372
+ supervisor: sup.opts,
408
373
  });
409
374
  expect(code).toBe(0);
410
- expect(spawner.calls).toHaveLength(1);
411
- expect(spawner.calls[0]?.cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
412
- expect(readPid("notes", h.configDir)).toBe(5151);
375
+ expect(sup.restartHubCalls).toBe(1);
376
+ expect(sup.driveCalls).toHaveLength(0); // NOT a per-module fan-out
377
+ expect(log.join("\n")).toMatch(/✓ hub restarted/);
413
378
  } finally {
414
379
  h.cleanup();
415
380
  }
416
381
  });
417
382
 
418
- test("passes PARACHUTE_HUB_ORIGIN from expose-state when set", async () => {
383
+ test("no svc, unit-installed restart the hub unit (manager), not a fan-out", async () => {
419
384
  const h = makeHarness();
420
385
  try {
421
- seedVault(h.manifestPath);
422
- writeFileSync(
423
- join(h.configDir, "expose-state.json"),
424
- JSON.stringify({
425
- version: 1,
426
- layer: "tailnet",
427
- mode: "path",
428
- canonicalFqdn: "parachute.taildf9ce2.ts.net",
429
- port: 443,
430
- funnel: false,
431
- entries: [],
432
- hubOrigin: "https://parachute.taildf9ce2.ts.net",
433
- }),
434
- );
435
- const spawner = makeSpawner([4242]);
436
- const code = await start("vault", {
386
+ const sup = makeSupervisorStub();
387
+ const code = await restart(undefined, {
437
388
  configDir: h.configDir,
438
389
  manifestPath: h.manifestPath,
439
- spawner,
440
390
  log: () => {},
391
+ supervisor: sup.opts,
441
392
  });
442
393
  expect(code).toBe(0);
443
- // PORT is always set by `parachute start` (hub#356) from the
444
- // services.json entry. PARACHUTE_HUB_ORIGIN comes from expose-state.
445
- expect(spawner.calls[0]?.env).toEqual({
446
- PORT: "1940",
447
- PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
448
- });
449
- // OAuth issuer-mismatch fix: the spawn-env injection above is ephemeral
450
- // (lost on the next launchd / systemd boot). `start vault` ALSO persists
451
- // the public origin into vault/.env so the out-of-band daemon validates
452
- // hub-minted JWTs' `iss` against it. Without this, every reconnect after
453
- // a reboot / crash-restart 401s.
454
- expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
455
- "https://parachute.taildf9ce2.ts.net",
456
- );
394
+ expect(sup.restartHubCalls).toBe(1);
395
+ expect(sup.driveCalls).toHaveLength(0);
457
396
  } finally {
458
397
  h.cleanup();
459
398
  }
460
399
  });
400
+ });
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // §7.5 no-unit path: a box with NO hub unit gets the auto-offer (when enabled)
404
+ // or the actionable "run `parachute migrate --to-supervised`" error — NEVER a
405
+ // detached spawn (the spawners are retired in Phase 5b). Reworked from the
406
+ // former "fall through to the detached arm" tests: the intent (what happens on
407
+ // a no-unit box) is preserved, but the outcome inverted to single-runtime.
408
+ // ---------------------------------------------------------------------------
409
+
410
+ describe("§7.5 no-unit path in start/stop/restart", () => {
411
+ /** A migrate-offer stub recording whether it was called + what it returns. */
412
+ function makeOfferStub(outcome: MigrateOfferResult["outcome"]): {
413
+ offer: (opts: MigrateOfferOpts) => Promise<MigrateOfferResult>;
414
+ calls: number;
415
+ } {
416
+ const state = { calls: 0 };
417
+ return {
418
+ get calls() {
419
+ return state.calls;
420
+ },
421
+ offer: async () => {
422
+ state.calls++;
423
+ return { outcome };
424
+ },
425
+ };
426
+ }
461
427
 
462
- test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
463
- // Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
464
- // loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
465
- // wrote loopback). expose-state.json carries the real public origin. A
466
- // plain `parachute start vault` must rewrite vault/.env to the public
467
- // origin so the daemon stops 401ing hub tokens — the self-heal half of the
468
- // Cloudflare 401 fix.
428
+ test("no unit + offer disabled (omitted) actionable migrate error, exit 1, no spawn", async () => {
469
429
  const h = makeHarness();
470
430
  try {
471
431
  seedVault(h.manifestPath);
472
- writeFileSync(
473
- join(h.configDir, "expose-state.json"),
474
- JSON.stringify({
475
- version: 1,
476
- layer: "public",
477
- mode: "subdomain",
478
- canonicalFqdn: "gitcoin-parachute.unforced.dev",
479
- port: 1939,
480
- funnel: false,
481
- entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
482
- hubOrigin: "https://gitcoin-parachute.unforced.dev",
483
- }),
484
- );
485
- // Pre-seed vault/.env with a stale loopback value (the broken state).
486
- mkdirSync(join(h.configDir, "vault"), { recursive: true });
487
- writeFileSync(
488
- join(h.configDir, "vault", ".env"),
489
- "PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
490
- );
491
- const spawner = makeSpawner([4242]);
432
+ const log: string[] = [];
433
+ // No `supervisor` block → unitInstalled defaults to false; no migrateOffer
434
+ // → the offer hook stays OFF. There is no detached fallback anymore, so the
435
+ // verb surfaces the actionable command and exits non-zero.
492
436
  const code = await start("vault", {
493
437
  configDir: h.configDir,
494
438
  manifestPath: h.manifestPath,
495
- spawner,
496
- log: () => {},
439
+ log: (l) => log.push(l),
497
440
  });
498
- expect(code).toBe(0);
499
- expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
500
- "https://gitcoin-parachute.unforced.dev",
501
- );
441
+ expect(code).toBe(1);
442
+ expect(log.join("\n")).toMatch(/No supervised hub unit is installed/);
443
+ expect(log.join("\n")).toMatch(/parachute migrate --to-supervised/);
502
444
  } finally {
503
445
  h.cleanup();
504
446
  }
505
447
  });
506
448
 
507
- test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
449
+ test("start: accept+migrate dispatches through the supervisor (no detached spawn)", async () => {
508
450
  const h = makeHarness();
509
451
  try {
510
452
  seedVault(h.manifestPath);
511
- writeHubPort(1939, h.configDir);
512
- const spawner = makeSpawner([4242]);
453
+ const offerStub = makeOfferStub("migrated");
454
+ const sup = makeSupervisorStub();
455
+ // Start on the no-unit arm (unitInstalled:false), with the offer enabled
456
+ // and the supervisor stub ready for the post-migrate dispatch.
513
457
  const code = await start("vault", {
514
458
  configDir: h.configDir,
515
459
  manifestPath: h.manifestPath,
516
- spawner,
517
460
  log: () => {},
461
+ supervisor: { ...sup.opts, unitInstalled: false },
462
+ migrateOffer: { enabled: true, offer: offerStub.offer },
518
463
  });
519
464
  expect(code).toBe(0);
520
- // Loopback is fine to inject into the ephemeral spawn env (local dev),
521
- // but persisting it would brick the daemon path once exposure comes up:
522
- // the baked loopback would shadow the real origin. So vault/.env stays
523
- // absent of the key on a loopback-only start.
524
- expect(existsSync(join(h.configDir, "vault", ".env"))).toBe(false);
465
+ expect(offerStub.calls).toBe(1);
466
+ // The migrate flipped the box to supervised the verb drove the supervisor.
467
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
525
468
  } finally {
526
469
  h.cleanup();
527
470
  }
528
471
  });
529
472
 
530
- test("falls back to loopback origin from hub.port when not exposed", async () => {
473
+ test("start: declined actionable-error path, exit 1 (no spawn)", async () => {
531
474
  const h = makeHarness();
532
475
  try {
533
476
  seedVault(h.manifestPath);
534
- writeHubPort(1939, h.configDir);
535
- const spawner = makeSpawner([4242]);
477
+ const offerStub = makeOfferStub("declined");
536
478
  const code = await start("vault", {
537
479
  configDir: h.configDir,
538
480
  manifestPath: h.manifestPath,
539
- spawner,
540
481
  log: () => {},
482
+ migrateOffer: { enabled: true, offer: offerStub.offer },
541
483
  });
542
- expect(code).toBe(0);
543
- expect(spawner.calls[0]?.env).toEqual({
544
- PORT: "1940",
545
- PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1939",
546
- });
484
+ // Declined → no migrate, no detached spawn (retired) → non-zero exit. The
485
+ // offer itself surfaced its own decline guidance, so the verb just bails.
486
+ expect(code).toBe(1);
487
+ expect(offerStub.calls).toBe(1);
547
488
  } finally {
548
489
  h.cleanup();
549
490
  }
550
491
  });
551
492
 
552
- test("--hub-origin override wins over expose-state", async () => {
493
+ test("start: migrate-failed actionable-error path, exit 1 (fail-safe, no spawn)", async () => {
553
494
  const h = makeHarness();
554
495
  try {
555
496
  seedVault(h.manifestPath);
556
- writeFileSync(
557
- join(h.configDir, "expose-state.json"),
558
- JSON.stringify({
559
- version: 1,
560
- layer: "tailnet",
561
- mode: "path",
562
- canonicalFqdn: "parachute.taildf9ce2.ts.net",
563
- port: 443,
564
- funnel: false,
565
- entries: [],
566
- hubOrigin: "https://parachute.taildf9ce2.ts.net",
567
- }),
568
- );
569
- const spawner = makeSpawner([4242]);
497
+ const offerStub = makeOfferStub("migrate-failed");
570
498
  const code = await start("vault", {
571
499
  configDir: h.configDir,
572
500
  manifestPath: h.manifestPath,
573
- spawner,
574
- hubOrigin: "https://override.example.com/",
575
501
  log: () => {},
502
+ migrateOffer: { enabled: true, offer: offerStub.offer },
576
503
  });
577
- expect(code).toBe(0);
578
- expect(spawner.calls[0]?.env).toEqual({
579
- PORT: "1940",
580
- PARACHUTE_HUB_ORIGIN: "https://override.example.com",
581
- });
504
+ // A failed cutover leaves the box un-migrated → the verb bails non-zero
505
+ // (rather than dispatching into a supervisor that isn't up). No spawn.
506
+ expect(code).toBe(1);
507
+ expect(offerStub.calls).toBe(1);
582
508
  } finally {
583
509
  h.cleanup();
584
510
  }
585
511
  });
586
512
 
587
- test("omits env when no override, no exposure, no hub port", async () => {
513
+ test("stop: accept+migrate dispatches through the supervisor", async () => {
588
514
  const h = makeHarness();
589
515
  try {
590
516
  seedVault(h.manifestPath);
591
- const spawner = makeSpawner([4242]);
592
- const code = await start("vault", {
517
+ const offerStub = makeOfferStub("migrated");
518
+ const sup = makeSupervisorStub();
519
+ const code = await stop("vault", {
593
520
  configDir: h.configDir,
594
521
  manifestPath: h.manifestPath,
595
- spawner,
596
522
  log: () => {},
523
+ supervisor: { ...sup.opts, unitInstalled: false },
524
+ migrateOffer: { enabled: true, offer: offerStub.offer },
597
525
  });
598
526
  expect(code).toBe(0);
599
- // PORT is always set (hub#356) — even with no override, no exposure,
600
- // and no hub.port file, the spawn env carries the canonical PORT
601
- // from services.json. Test renamed from "omits env" to reflect
602
- // the new minimum-env shape.
603
- expect(spawner.calls[0]?.env).toEqual({ PORT: "1940" });
527
+ expect(offerStub.calls).toBe(1);
528
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
604
529
  } finally {
605
530
  h.cleanup();
606
531
  }
607
532
  });
608
533
 
609
- test("merges <configDir>/<svc>/.env into the spawn env", async () => {
610
- // Scribe's API key prompt writes GROQ_API_KEY into ~/.parachute/scribe/.env.
611
- // Scribe itself doesn't auto-load .env, so `parachute start scribe` has to
612
- // forward the values into the child env or the API key won't take effect.
534
+ test("restart: accept+migrate dispatches through the supervisor", async () => {
613
535
  const h = makeHarness();
614
536
  try {
615
- upsertService(
616
- {
617
- name: "parachute-scribe",
618
- port: 1943,
619
- paths: ["/scribe"],
620
- health: "/scribe/health",
621
- version: "0.1.0",
622
- },
623
- h.manifestPath,
624
- );
625
- ensureLogPath("scribe", h.configDir);
626
- writeFileSync(
627
- join(h.configDir, "scribe", ".env"),
628
- 'GROQ_API_KEY=gsk_real_value\nQUOTED="quoted_val"\n',
629
- );
630
- const spawner = makeSpawner([7777]);
631
- const code = await start("scribe", {
537
+ seedVault(h.manifestPath);
538
+ const offerStub = makeOfferStub("migrated");
539
+ const sup = makeSupervisorStub();
540
+ const code = await restart("vault", {
632
541
  configDir: h.configDir,
633
542
  manifestPath: h.manifestPath,
634
- spawner,
635
543
  log: () => {},
544
+ supervisor: { ...sup.opts, unitInstalled: false },
545
+ migrateOffer: { enabled: true, offer: offerStub.offer },
636
546
  });
637
547
  expect(code).toBe(0);
638
- expect(spawner.calls[0]?.env).toEqual({
639
- PORT: "1943",
640
- GROQ_API_KEY: "gsk_real_value",
641
- QUOTED: "quoted_val",
642
- });
548
+ expect(offerStub.calls).toBe(1);
549
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
643
550
  } finally {
644
551
  h.cleanup();
645
552
  }
646
553
  });
647
554
 
648
- test("hub-origin override wins over conflicting key in service .env", async () => {
649
- // Defense: `start --hub-origin <url>` is the authoritative source for
650
- // PARACHUTE_HUB_ORIGIN. If a service .env happens to have the same key
651
- // (e.g. an old hand-edit), the live override should still apply.
555
+ test("offer is NOT made on the supervisor arm (unit already installed)", async () => {
652
556
  const h = makeHarness();
653
557
  try {
654
558
  seedVault(h.manifestPath);
655
- ensureLogPath("vault", h.configDir);
656
- writeFileSync(
657
- join(h.configDir, "vault", ".env"),
658
- "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=http://stale.local\n",
659
- );
660
- const spawner = makeSpawner([4242]);
559
+ const offerStub = makeOfferStub("migrated");
560
+ const sup = makeSupervisorStub(); // unitInstalled: true
661
561
  const code = await start("vault", {
662
562
  configDir: h.configDir,
663
563
  manifestPath: h.manifestPath,
664
- spawner,
665
- hubOrigin: "https://live.example.com",
666
- log: () => {},
667
- });
668
- expect(code).toBe(0);
669
- expect(spawner.calls[0]?.env).toEqual({
670
- PORT: "1940",
671
- SCRIBE_AUTH_TOKEN: "secret",
672
- PARACHUTE_HUB_ORIGIN: "https://live.example.com",
673
- });
674
- } finally {
675
- h.cleanup();
676
- }
677
- });
678
-
679
- test("third-party module starts via installDir module.json with cwd", async () => {
680
- // hub#83: services.json rows that carry installDir resolve their spec
681
- // from `<installDir>/.parachute/module.json` at lifecycle time. Spawn
682
- // gets cwd=installDir so manifest-declared relative paths work.
683
- const h = makeHarness();
684
- try {
685
- const installDir = join(h.configDir, "_pkg-someapp");
686
- seedThirdParty(h.manifestPath, h.configDir, "someapp", {
687
- installDir,
688
- startCmd: ["bun", "web/server/src/server.ts"],
689
- port: 1944,
690
- });
691
- const spawner = makeSpawner([8080]);
692
- const code = await start("someapp", {
693
- configDir: h.configDir,
694
- manifestPath: h.manifestPath,
695
- spawner,
696
564
  log: () => {},
565
+ supervisor: sup.opts,
566
+ migrateOffer: { enabled: true, offer: offerStub.offer },
697
567
  });
698
568
  expect(code).toBe(0);
699
- expect(spawner.calls).toHaveLength(1);
700
- expect(spawner.calls[0]?.cmd).toEqual(["bun", "web/server/src/server.ts"]);
701
- expect(spawner.calls[0]?.cwd).toBe(installDir);
702
- expect(readPid("someapp", h.configDir)).toBe(8080);
703
- } finally {
704
- h.cleanup();
705
- }
706
- });
707
-
708
- test("start: installDir-less third-party row surfaces an actionable error", async () => {
709
- // A services.json row whose name isn't first-party AND has no installDir
710
- // can't yield a startCmd. Pre-fix this hit the generic "unknown service"
711
- // path (misleading — the row exists, just with stale shape). Post-fix
712
- // resolveTargets returns the entry with spec=undefined and start prints
713
- // an actionable message that points at the real fix (re-install or
714
- // upgrade-the-module).
715
- const h = makeHarness();
716
- try {
717
- upsertService(
718
- {
719
- name: "mystery",
720
- port: 1944,
721
- paths: ["/mystery"],
722
- health: "/mystery/health",
723
- version: "0.0.1",
724
- },
725
- h.manifestPath,
726
- );
727
- const lines: string[] = [];
728
- const code = await start("mystery", {
729
- configDir: h.configDir,
730
- manifestPath: h.manifestPath,
731
- log: (l) => lines.push(l),
732
- });
733
- expect(code).toBe(1);
734
- const out = lines.join("\n");
735
- expect(out).toMatch(/services\.json entry has no installDir/);
736
- expect(out).toMatch(/parachute install <path-to-mystery>/);
737
- expect(out).not.toMatch(/unknown service/);
569
+ // Supervisor arm taken directly → the offer hook (no-unit only) never ran.
570
+ expect(offerStub.calls).toBe(0);
571
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
738
572
  } finally {
739
573
  h.cleanup();
740
574
  }
741
575
  });
742
576
 
743
- test("start: name absent from services.json still errors as unknown service", async () => {
744
- // The genuinely-unknown path: no first-party fallback, no row in
745
- // services.json. Distinguish from the above (row exists but lacks
746
- // installDir) so the error message is right-shaped for each.
577
+ test("restart: declined offer fires EXACTLY ONCE (MUST-FIX 3), exit 1", async () => {
747
578
  const h = makeHarness();
748
579
  try {
749
580
  seedVault(h.manifestPath);
750
- const lines: string[] = [];
751
- const code = await start("ghost", {
581
+ // The operator DECLINES the offer. `restart` makes a single offer via the
582
+ // shared `requireSupervisedOrOffer` gate (no inner stop+start re-offer
583
+ // anymore — the detached stop-then-start arm is gone).
584
+ const offerStub = makeOfferStub("declined");
585
+ const code = await restart("vault", {
752
586
  configDir: h.configDir,
753
587
  manifestPath: h.manifestPath,
754
- log: (l) => lines.push(l),
588
+ log: () => {},
589
+ migrateOffer: { enabled: true, offer: offerStub.offer },
755
590
  });
756
591
  expect(code).toBe(1);
757
- expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
592
+ // EXACTLY ONE offer.
593
+ expect(offerStub.calls).toBe(1);
758
594
  } finally {
759
595
  h.cleanup();
760
596
  }
761
597
  });
598
+ });
762
599
 
763
- test("start (no svc) sweeps both first-party and third-party rows", async () => {
764
- const h = makeHarness();
765
- try {
766
- seedVault(h.manifestPath);
767
- const installDir = join(h.configDir, "_pkg-someapp");
768
- seedThirdParty(h.manifestPath, h.configDir, "someapp", {
769
- installDir,
770
- startCmd: ["bun", "server.ts"],
771
- port: 1944,
772
- });
773
- const spawner = makeSpawner([4242, 8080]);
774
- const code = await start(undefined, {
775
- configDir: h.configDir,
776
- manifestPath: h.manifestPath,
777
- spawner,
778
- log: () => {},
779
- });
780
- expect(code).toBe(0);
781
- expect(spawner.calls).toHaveLength(2);
782
- const cmds = spawner.calls.map((c) => c.cmd);
783
- expect(cmds).toContainEqual(["parachute-vault", "serve"]);
784
- expect(cmds).toContainEqual(["bun", "server.ts"]);
785
- } finally {
786
- h.cleanup();
787
- }
788
- });
600
+ // ---------------------------------------------------------------------------
601
+ // Group-aware kill / liveness primitives (hub#88). The detached MODULE spawner
602
+ // that created these process groups is retired (the supervisor's group-spawn +
603
+ // `defaultKillGroup` carry that role now, asserted in `supervisor.test.ts`), but
604
+ // `defaultKill` / `defaultAlive` survive as exported primitives — `logs` uses
605
+ // `defaultAlive`, and the supervisor's reaper mirrors `defaultKill`'s group/
606
+ // bare-pid fallback. These tests spawn a detached fixture process directly (not
607
+ // via the retired spawner) to keep that behavior under test.
608
+ // ---------------------------------------------------------------------------
789
609
 
790
- test("third-party with malformed module.json fails clearly", async () => {
791
- const h = makeHarness();
792
- try {
793
- const installDir = join(h.configDir, "_pkg-broken");
794
- mkdirSync(join(installDir, ".parachute"), { recursive: true });
795
- writeFileSync(join(installDir, ".parachute", "module.json"), "{ not valid json");
796
- upsertService(
797
- {
798
- name: "broken",
799
- port: 1944,
800
- paths: ["/broken"],
801
- health: "/broken/health",
802
- version: "0.0.1",
803
- installDir,
804
- },
805
- h.manifestPath,
806
- );
807
- const lines: string[] = [];
808
- const code = await start("broken", {
809
- configDir: h.configDir,
810
- manifestPath: h.manifestPath,
811
- log: (l) => lines.push(l),
812
- });
813
- expect(code).toBe(1);
814
- expect(lines.join("\n")).toMatch(/broken: invalid module\.json/);
815
- } finally {
816
- h.cleanup();
817
- }
818
- });
610
+ /** Spawn a detached fixture child (its own process group) for the kill/alive tests. */
611
+ function spawnDetached(cmd: string[]): { pid: number; logFile: string } {
612
+ const dir = mkdtempSync(join(tmpdir(), "pcli-grp-"));
613
+ const logFile = ensureLogPath("test", dir);
614
+ const fd = openSync(logFile, "a");
615
+ const proc = Bun.spawn(cmd, { stdio: ["ignore", fd, fd], detached: true, env: process.env });
616
+ proc.unref();
617
+ return { pid: proc.pid, logFile };
618
+ }
819
619
 
820
- test("hub#194: reports failure when child dies before the settle window", async () => {
821
- // The bug: `parachute start notes` reported `✓ notes started (pid X)`
822
- // but notes-serve crashed milliseconds later on a Bun.resolveSync
823
- // failure, leaving tailnet `/notes/` 502'ing. Fix: after spawn, sleep
824
- // ~250ms then re-check alive(pid). If dead, clear pidfile, log
825
- // failure, return non-zero. This regression test pins the post-fix
826
- // shape with a stub alive that always reports dead and a fast settle.
827
- const h = makeHarness();
828
- try {
829
- seedVault(h.manifestPath);
830
- const spawner = makeSpawner([4242]);
831
- const lines: string[] = [];
832
- const code = await start("vault", {
833
- configDir: h.configDir,
834
- manifestPath: h.manifestPath,
835
- spawner,
836
- alive: () => false, // child dies immediately after spawn
837
- sleep: async () => {}, // skip the real wait in tests
838
- startSettleMs: 1, // any non-zero value engages the check
839
- log: (l) => lines.push(l),
840
- });
841
- expect(code).toBe(1);
842
- expect(spawner.calls).toHaveLength(1);
843
- // pidfile is cleared so a follow-up `start` doesn't report
844
- // already-running against a corpse.
845
- expect(readPid("vault", h.configDir)).toBeUndefined();
846
- const out = lines.join("\n");
847
- expect(out).toMatch(/✗ vault failed to start/);
848
- expect(out).toMatch(/exited within 1ms/);
849
- expect(out).toMatch(/Tail the log/);
850
- expect(out).not.toMatch(/✓ vault started/);
851
- } finally {
852
- h.cleanup();
853
- }
854
- });
620
+ describe("group-aware kill / liveness (hub#88)", () => {
621
+ test("defaultKill takes down the wrapper and its grandchildren together", async () => {
622
+ // Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild PID via
623
+ // the log file, then `wait` so the wrapper sticks around (mirrors `pnpm exec
624
+ // tsx`'s shape). SIGKILL on the GROUP reaps both.
625
+ const { pid: wrapperPid, logFile } = spawnDetached([
626
+ "sh",
627
+ "-c",
628
+ "sleep 30 & echo $! >&2; wait",
629
+ ]);
630
+ await new Promise((r) => setTimeout(r, 200));
631
+ const logText = await Bun.file(logFile).text();
632
+ const grandchildPid = Number.parseInt(logText.trim().split("\n").pop() ?? "", 10);
633
+ expect(grandchildPid).toBeGreaterThan(0);
634
+ expect(grandchildPid).not.toBe(wrapperPid);
635
+ expect(() => process.kill(grandchildPid, 0)).not.toThrow();
855
636
 
856
- test("hub#194: settle path passes when child stays alive past the window", async () => {
857
- // Companion to the above — verifies the success-path shape doesn't
858
- // regress. Stub alive returns true so the post-spawn check passes,
859
- // and we still see the `✓ ... started` line.
860
- const h = makeHarness();
861
- try {
862
- seedVault(h.manifestPath);
863
- const spawner = makeSpawner([4242]);
864
- const lines: string[] = [];
865
- const code = await start("vault", {
866
- configDir: h.configDir,
867
- manifestPath: h.manifestPath,
868
- spawner,
869
- alive: () => true,
870
- sleep: async () => {},
871
- startSettleMs: 1,
872
- log: (l) => lines.push(l),
873
- });
874
- expect(code).toBe(0);
875
- expect(readPid("vault", h.configDir)).toBe(4242);
876
- expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
877
- } finally {
878
- h.cleanup();
879
- }
880
- });
637
+ defaultKill(wrapperPid, "SIGKILL");
881
638
 
882
- test("hub#194: settle skipped when startSettleMs is 0", async () => {
883
- // Defense don't regress the test-default policy. With a stub
884
- // spawner and no `alive` override, the resolved settle is 0 (see
885
- // resolve() in lifecycle.ts), so the post-spawn check is bypassed
886
- // entirely and even an `alive: () => false` doesn't matter.
887
- const h = makeHarness();
639
+ await new Promise((r) => setTimeout(r, 200));
640
+ let grandchildStillAlive = true;
888
641
  try {
889
- seedVault(h.manifestPath);
890
- const spawner = makeSpawner([4242]);
891
- const code = await start("vault", {
892
- configDir: h.configDir,
893
- manifestPath: h.manifestPath,
894
- spawner,
895
- startSettleMs: 0,
896
- // intentionally omit alive — defaultAlive against a fake pid
897
- // would normally report dead, but startSettleMs: 0 skips the
898
- // call entirely.
899
- log: () => {},
900
- });
901
- expect(code).toBe(0);
902
- expect(readPid("vault", h.configDir)).toBe(4242);
903
- } finally {
904
- h.cleanup();
642
+ process.kill(grandchildPid, 0);
643
+ } catch {
644
+ grandchildStillAlive = false;
905
645
  }
646
+ expect(grandchildStillAlive).toBe(false);
906
647
  });
907
648
 
908
- test("third-party with no startCmd in module.json reports lifecycle-unsupported", async () => {
909
- const h = makeHarness();
649
+ test("defaultAlive reports group liveness for detached children", async () => {
650
+ const { pid } = spawnDetached(["sh", "-c", "sleep 2"]);
910
651
  try {
911
- const installDir = join(h.configDir, "_pkg-noop");
912
- seedThirdParty(h.manifestPath, h.configDir, "noop", {
913
- installDir,
914
- port: 1945,
915
- });
916
- const lines: string[] = [];
917
- const code = await start("noop", {
918
- configDir: h.configDir,
919
- manifestPath: h.manifestPath,
920
- log: (l) => lines.push(l),
921
- });
922
- expect(code).toBe(1);
923
- expect(lines.join("\n")).toMatch(/lifecycle not yet supported/);
652
+ expect(defaultAlive(pid)).toBe(true);
924
653
  } finally {
925
- h.cleanup();
654
+ try {
655
+ process.kill(-pid, "SIGKILL");
656
+ } catch {}
926
657
  }
658
+ await new Promise((r) => setTimeout(r, 100));
659
+ expect(defaultAlive(pid)).toBe(false);
927
660
  });
928
661
 
929
- // hub#487 readiness gating beyond the bare liveness settle. Aaron hit this
930
- // on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
931
- // the process died ~instantly on EADDRINUSE (an orphan held 1940), and
932
- // `parachute status` then showed it inactive.
933
-
934
- /**
935
- * A stub spawner that also seeds the service's log file with `content`, so
936
- * the readiness-failure path's log-tail + EADDRINUSE detection can read a
937
- * realistic boot error. Mirrors how the real spawner appends stdout/stderr
938
- * to the logfile.
939
- */
940
- function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
941
- const calls: SpawnerStub["calls"] = [];
942
- return {
943
- calls,
944
- spawn(cmd, logFile, opts) {
945
- calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
946
- // The start path calls ensureLogPath() before spawn, so logFile's
947
- // parent dir already exists — just write the simulated boot output.
948
- writeFileSync(logFile, content);
949
- return pid;
950
- },
951
- };
952
- }
953
-
954
- test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
955
- const h = makeHarness();
662
+ test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
663
+ // A non-detached child shares the test runner's pgid, so kill(-pid, 0) will
664
+ // ESRCH and both must fall back to a bare-pid path.
665
+ const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
666
+ const pid = proc.pid;
956
667
  try {
957
- seedVault(h.manifestPath);
958
- const spawner = makeSpawnerWithLog(
959
- 4242,
960
- "booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
961
- );
962
- const lines: string[] = [];
963
- const code = await start("vault", {
964
- configDir: h.configDir,
965
- manifestPath: h.manifestPath,
966
- spawner,
967
- alive: () => false, // process died right after the EADDRINUSE throw
968
- sleep: async () => {},
969
- startSettleMs: 1,
970
- log: (l) => lines.push(l),
971
- });
972
- expect(code).toBe(1);
973
- expect(readPid("vault", h.configDir)).toBeUndefined();
974
- const out = lines.join("\n");
975
- expect(out).toMatch(/port 1940 is already in use/);
976
- expect(out).toMatch(/lsof -ti:1940/);
977
- // The real boot error is surfaced inline so the operator doesn't have to
978
- // go tail the log themselves.
979
- expect(out).toMatch(/EADDRINUSE/);
980
- expect(out).not.toMatch(/✓ vault started/);
668
+ expect(defaultAlive(pid)).toBe(true);
669
+ defaultKill(pid, "SIGKILL");
670
+ await new Promise((r) => setTimeout(r, 100));
671
+ expect(defaultAlive(pid)).toBe(false);
981
672
  } finally {
982
- h.cleanup();
673
+ try {
674
+ process.kill(pid, "SIGKILL");
675
+ } catch {}
983
676
  }
984
677
  });
678
+ });
985
679
 
986
- test("hub#487: process survives settle but never binds its port → failure with log tail", async () => {
987
- const h = makeHarness();
988
- try {
989
- seedVault(h.manifestPath);
990
- const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
991
- const lines: string[] = [];
992
- let aliveCalls = 0;
993
- const code = await start("vault", {
994
- configDir: h.configDir,
995
- manifestPath: h.manifestPath,
996
- spawner,
997
- // Alive through the settle + first readiness poll, then dies — the
998
- // slow-EADDRINUSE / crash-after-boot shape.
999
- alive: () => {
1000
- aliveCalls++;
1001
- return aliveCalls <= 1;
1002
- },
1003
- sleep: async () => {},
1004
- startSettleMs: 1,
1005
- startReadyMs: 50,
1006
- startReadyPollMs: 1,
1007
- portListening: async () => false, // never binds
1008
- log: (l) => lines.push(l),
1009
- });
1010
- expect(code).toBe(1);
1011
- expect(readPid("vault", h.configDir)).toBeUndefined();
1012
- const out = lines.join("\n");
1013
- expect(out).toMatch(/✗ vault failed to start/);
1014
- expect(out).toMatch(/exited during startup/);
1015
- expect(out).not.toMatch(/✓ vault started/);
1016
- } finally {
1017
- h.cleanup();
1018
- }
1019
- });
680
+ // ---------------------------------------------------------------------------
681
+ // `parachute logs <svc>` — unchanged by Phase 5b. Reads the per-service logfile
682
+ // keyed by short name (the readers §7.5 keeps). Includes the internal `hub`.
683
+ // ---------------------------------------------------------------------------
1020
684
 
1021
- test("hub#487: alive but port silent past the window → non-fatal warning, exit 0", async () => {
685
+ describe("parachute logs", () => {
686
+ test("hint when no log file exists", async () => {
1022
687
  const h = makeHarness();
1023
688
  try {
1024
689
  seedVault(h.manifestPath);
1025
- const spawner = makeSpawner([4242]);
1026
- const lines: string[] = [];
1027
- const code = await start("vault", {
690
+ const log: string[] = [];
691
+ const code = await logs("vault", {
1028
692
  configDir: h.configDir,
1029
693
  manifestPath: h.manifestPath,
1030
- spawner,
1031
- alive: () => true, // stays up the whole time
1032
- sleep: async () => {},
1033
- startSettleMs: 1,
1034
- startReadyMs: 10,
1035
- startReadyPollMs: 1,
1036
- portListening: async () => false, // slow boot — not listening yet
1037
- log: (l) => lines.push(l),
694
+ log: (l) => log.push(l),
1038
695
  });
1039
- // A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
1040
696
  expect(code).toBe(0);
1041
- expect(readPid("vault", h.configDir)).toBe(4242);
1042
- const out = lines.join("\n");
1043
- expect(out).toMatch(/port 1940 isn't accepting connections yet/);
1044
- expect(out).not.toMatch(/✓ vault started/);
697
+ expect(log.join("\n")).toMatch(/no logs yet for vault/);
1045
698
  } finally {
1046
699
  h.cleanup();
1047
700
  }
1048
701
  });
1049
702
 
1050
- test("hub#487: alive + port listening success", async () => {
703
+ test("prints last N lines in one-shot mode", async () => {
1051
704
  const h = makeHarness();
1052
705
  try {
1053
706
  seedVault(h.manifestPath);
1054
- const spawner = makeSpawner([4242]);
1055
- const lines: string[] = [];
1056
- let probeCalls = 0;
1057
- const code = await start("vault", {
707
+ const path = ensureLogPath("vault", h.configDir);
708
+ writeFileSync(path, "line one\nline two\nline three\n");
709
+ const log: string[] = [];
710
+ const code = await logs("vault", {
1058
711
  configDir: h.configDir,
1059
712
  manifestPath: h.manifestPath,
1060
- spawner,
1061
- alive: () => true,
1062
- sleep: async () => {},
1063
- startSettleMs: 1,
1064
- startReadyMs: 50,
1065
- startReadyPollMs: 1,
1066
- // Not listening on the first poll, bound on the second — exercises the
1067
- // poll loop rather than an instant true.
1068
- portListening: async () => {
1069
- probeCalls++;
1070
- return probeCalls >= 2;
1071
- },
1072
- log: (l) => lines.push(l),
713
+ lines: 2,
714
+ log: (l) => log.push(l),
1073
715
  });
1074
716
  expect(code).toBe(0);
1075
- expect(readPid("vault", h.configDir)).toBe(4242);
1076
- expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
717
+ expect(log).toEqual(["line two", "line three"]);
1077
718
  } finally {
1078
719
  h.cleanup();
1079
720
  }
1080
721
  });
1081
- });
1082
722
 
1083
- describe("parachute stop", () => {
1084
- test("no-op when nothing is running", async () => {
723
+ test("unknown service errors cleanly", async () => {
1085
724
  const h = makeHarness();
1086
725
  try {
1087
726
  seedVault(h.manifestPath);
1088
- const killed: Array<[number, string | number]> = [];
1089
- const logs: string[] = [];
1090
- const code = await stop("vault", {
727
+ const log: string[] = [];
728
+ const code = await logs("nope", {
1091
729
  configDir: h.configDir,
1092
730
  manifestPath: h.manifestPath,
1093
- kill: (pid, sig) => killed.push([pid, sig]),
1094
- log: (l) => logs.push(l),
731
+ log: (l) => log.push(l),
1095
732
  });
1096
- expect(code).toBe(0);
1097
- expect(killed).toHaveLength(0);
1098
- expect(logs.join("\n")).toMatch(/wasn't running/);
733
+ expect(code).toBe(1);
734
+ expect(log.join("\n")).toMatch(/unknown service "nope"/);
1099
735
  } finally {
1100
736
  h.cleanup();
1101
737
  }
1102
738
  });
1103
739
 
1104
- test("cleans stale PID file without sending any signal", async () => {
740
+ test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
1105
741
  const h = makeHarness();
1106
742
  try {
1107
743
  seedVault(h.manifestPath);
1108
- writePid("vault", 4242, h.configDir);
1109
- const killed: Array<[number, string | number]> = [];
1110
- const code = await stop("vault", {
744
+ // A pidfile reader still resolves: seed a live pid (this process) so the
745
+ // running-but-no-logfile diagnostic fires.
746
+ writePid("vault", process.pid, h.configDir);
747
+ const log: string[] = [];
748
+ const code = await logs("vault", {
1111
749
  configDir: h.configDir,
1112
750
  manifestPath: h.manifestPath,
1113
- kill: (pid, sig) => killed.push([pid, sig]),
1114
- alive: () => false,
1115
- log: () => {},
751
+ alive: () => true,
752
+ log: (l) => log.push(l),
1116
753
  });
1117
754
  expect(code).toBe(0);
1118
- expect(killed).toHaveLength(0);
1119
- expect(readPid("vault", h.configDir)).toBeUndefined();
755
+ expect(log.join("\n")).toMatch(/is running \(pid .*\) but no log file/);
1120
756
  } finally {
1121
757
  h.cleanup();
1122
758
  }
1123
759
  });
1124
760
 
1125
- test("SIGTERM + clean exit within window clears PID", async () => {
761
+ test("stale pidfile + missing log file: falls through to start hint", async () => {
1126
762
  const h = makeHarness();
1127
763
  try {
1128
764
  seedVault(h.manifestPath);
1129
- writePid("vault", 4242, h.configDir);
1130
- const killed: Array<[number, string | number]> = [];
1131
- let aliveCall = 0;
1132
- const code = await stop("vault", {
765
+ writePid("vault", 999999, h.configDir);
766
+ const log: string[] = [];
767
+ const code = await logs("vault", {
1133
768
  configDir: h.configDir,
1134
769
  manifestPath: h.manifestPath,
1135
- kill: (pid, sig) => killed.push([pid, sig]),
1136
- alive: () => {
1137
- aliveCall++;
1138
- return aliveCall === 1;
1139
- },
1140
- sleep: async () => {},
1141
- log: () => {},
770
+ alive: () => false,
771
+ log: (l) => log.push(l),
1142
772
  });
1143
773
  expect(code).toBe(0);
1144
- expect(killed).toEqual([[4242, "SIGTERM"]]);
1145
- expect(readPid("vault", h.configDir)).toBeUndefined();
774
+ expect(log.join("\n")).toMatch(/no logs yet for vault/);
1146
775
  } finally {
1147
776
  h.cleanup();
1148
777
  }
1149
778
  });
1150
779
 
1151
- test("escalates to SIGKILL when SIGTERM doesn't land", async () => {
780
+ test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
1152
781
  const h = makeHarness();
1153
782
  try {
1154
783
  seedVault(h.manifestPath);
1155
- writePid("vault", 4242, h.configDir);
1156
- const killed: Array<[number, string | number]> = [];
1157
- let t = 0;
1158
- const code = await stop("vault", {
784
+ const path = ensureLogPath("vault", h.configDir);
785
+ writeFileSync(path, "boot line\n");
786
+ const log: string[] = [];
787
+ const code = await logs("vault", {
1159
788
  configDir: h.configDir,
1160
789
  manifestPath: h.manifestPath,
1161
- kill: (pid, sig) => killed.push([pid, sig]),
1162
- alive: () => true,
1163
- sleep: async () => {},
1164
- now: () => {
1165
- // Jump past the kill-wait window so the polling loop exits fast.
1166
- t += 20_000;
1167
- return t;
1168
- },
1169
- killWaitMs: 10_000,
1170
- log: () => {},
790
+ log: (l) => log.push(l),
1171
791
  });
1172
792
  expect(code).toBe(0);
1173
- expect(killed[0]).toEqual([4242, "SIGTERM"]);
1174
- expect(killed[killed.length - 1]).toEqual([4242, "SIGKILL"]);
1175
- expect(readPid("vault", h.configDir)).toBeUndefined();
793
+ expect(log).toEqual(["boot line"]);
1176
794
  } finally {
1177
795
  h.cleanup();
1178
796
  }
1179
797
  });
1180
798
 
1181
- test("third-party row without installDir: stops via pidfile", async () => {
1182
- // Graceful-degradation path: an installed-but-stale third-party row
1183
- // (no installDir field — pre-installDir-contract self-registration)
1184
- // should still be stoppable. stop only needs the short name to find
1185
- // the pidfile; spec resolution isn't on the critical path for stop.
799
+ test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
1186
800
  const h = makeHarness();
1187
801
  try {
1188
- upsertService(
1189
- {
1190
- name: "mystery",
1191
- port: 1944,
1192
- paths: ["/mystery"],
1193
- health: "/mystery/health",
1194
- version: "0.0.1",
1195
- },
1196
- h.manifestPath,
1197
- );
1198
- writePid("mystery", 4242, h.configDir);
1199
- const killed: Array<[number, string | number]> = [];
1200
- let aliveCall = 0;
1201
- const code = await stop("mystery", {
802
+ const log: string[] = [];
803
+ const code = await logs("hub", {
1202
804
  configDir: h.configDir,
1203
805
  manifestPath: h.manifestPath,
1204
- kill: (pid, sig) => killed.push([pid, sig]),
1205
- alive: () => {
1206
- aliveCall++;
1207
- return aliveCall === 1;
1208
- },
1209
- sleep: async () => {},
1210
- log: () => {},
806
+ log: (l) => log.push(l),
1211
807
  });
1212
808
  expect(code).toBe(0);
1213
- expect(killed).toEqual([[4242, "SIGTERM"]]);
1214
- expect(readPid("mystery", h.configDir)).toBeUndefined();
809
+ expect(log.join("\n")).toMatch(/no logs yet for hub/);
1215
810
  } finally {
1216
811
  h.cleanup();
1217
812
  }
1218
813
  });
1219
- });
1220
814
 
1221
- describe("parachute restart", () => {
1222
- test("stops then starts in sequence", async () => {
815
+ test("logs hub: prints the tail when a log file exists", async () => {
1223
816
  const h = makeHarness();
1224
817
  try {
1225
- seedVault(h.manifestPath);
1226
- writePid("vault", 4242, h.configDir);
1227
- const spawner = makeSpawner([7777]);
1228
- const killed: Array<[number, string | number]> = [];
1229
- const code = await restart("vault", {
818
+ const path = ensureLogPath("hub", h.configDir);
819
+ writeFileSync(path, "hub line one\nhub line two\n");
820
+ const log: string[] = [];
821
+ const code = await logs("hub", {
1230
822
  configDir: h.configDir,
1231
823
  manifestPath: h.manifestPath,
1232
- spawner,
1233
- kill: (pid, sig) => killed.push([pid, sig]),
1234
- // Stale 4242 is dead (stop's stale-pid path skips the kill);
1235
- // freshly spawned 7777 is alive past the post-spawn settle
1236
- // (hub#194). Per-pid differentiation rather than blanket-false.
1237
- alive: (pid) => pid === 7777,
1238
- sleep: async () => {},
1239
- log: () => {},
1240
- });
1241
- expect(code).toBe(0);
1242
- expect(killed).toHaveLength(0); // stale pid → cleanup without kill
1243
- expect(spawner.calls).toHaveLength(1);
1244
- expect(readPid("vault", h.configDir)).toBe(7777);
1245
- } finally {
1246
- h.cleanup();
1247
- }
1248
- });
1249
- });
1250
-
1251
- describe("parachute logs", () => {
1252
- test("hint when no log file exists", async () => {
1253
- const h = makeHarness();
1254
- try {
1255
- const lines: string[] = [];
1256
- const code = await logs("vault", {
1257
- configDir: h.configDir,
1258
- log: (l) => lines.push(l),
1259
- });
1260
- expect(code).toBe(0);
1261
- expect(lines.join("\n")).toMatch(/no logs yet/);
1262
- } finally {
1263
- h.cleanup();
1264
- }
1265
- });
1266
-
1267
- test("prints last N lines in one-shot mode", async () => {
1268
- const h = makeHarness();
1269
- try {
1270
- const p = ensureLogPath("vault", h.configDir);
1271
- const content = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n");
1272
- writeFileSync(p, `${content}\n`);
1273
- const lines: string[] = [];
1274
- const code = await logs("vault", {
1275
- configDir: h.configDir,
1276
- lines: 3,
1277
- log: (l) => lines.push(l),
824
+ log: (l) => log.push(l),
1278
825
  });
1279
826
  expect(code).toBe(0);
1280
- expect(lines).toEqual(["line 8", "line 9", "line 10"]);
1281
- } finally {
1282
- h.cleanup();
1283
- }
1284
- });
1285
-
1286
- test("unknown service errors cleanly", async () => {
1287
- const h = makeHarness();
1288
- try {
1289
- const lines: string[] = [];
1290
- const code = await logs("nope", {
1291
- configDir: h.configDir,
1292
- log: (l) => lines.push(l),
1293
- });
1294
- expect(code).toBe(1);
1295
- expect(lines.join("\n")).toMatch(/unknown service/);
1296
- } finally {
1297
- h.cleanup();
1298
- }
1299
- });
1300
-
1301
- test("third-party module name with installDir is recognised", async () => {
1302
- const h = makeHarness();
1303
- try {
1304
- const installDir = join(h.configDir, "_pkg-someapp");
1305
- seedThirdParty(h.manifestPath, h.configDir, "someapp", {
1306
- installDir,
1307
- startCmd: ["bun", "server.ts"],
1308
- });
1309
- const p = ensureLogPath("someapp", h.configDir);
1310
- writeFileSync(p, "someapp line 1\nsomeapp line 2\n");
1311
- const lines: string[] = [];
1312
- const code = await logs("someapp", {
1313
- configDir: h.configDir,
1314
- manifestPath: h.manifestPath,
1315
- log: (l) => lines.push(l),
1316
- });
1317
- expect(code).toBe(0);
1318
- expect(lines).toEqual(["someapp line 1", "someapp line 2"]);
1319
- } finally {
1320
- h.cleanup();
1321
- }
1322
- });
1323
-
1324
- test("third-party row without installDir: tails by short name", async () => {
1325
- // Graceful-degradation path: log file is keyed by short name, written by
1326
- // start. installDir is irrelevant for tailing — the entry just needs to
1327
- // exist in services.json.
1328
- const h = makeHarness();
1329
- try {
1330
- upsertService(
1331
- {
1332
- name: "mystery",
1333
- port: 1944,
1334
- paths: ["/mystery"],
1335
- health: "/mystery/health",
1336
- version: "0.0.1",
1337
- },
1338
- h.manifestPath,
1339
- );
1340
- const p = ensureLogPath("mystery", h.configDir);
1341
- writeFileSync(p, "mystery line 1\nmystery line 2\n");
1342
- const lines: string[] = [];
1343
- const code = await logs("mystery", {
1344
- configDir: h.configDir,
1345
- manifestPath: h.manifestPath,
1346
- log: (l) => lines.push(l),
1347
- });
1348
- expect(code).toBe(0);
1349
- expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
1350
- } finally {
1351
- h.cleanup();
1352
- }
1353
- });
1354
-
1355
- test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
1356
- // Aaron's #335 reproducer shape: parachute-app daemon was running
1357
- // (curl proxied 200s, pidfile alive) but `parachute logs app` printed
1358
- // `parachute start app to begin` — telling the operator to start a
1359
- // service that was already up. The fix: when the log file is missing
1360
- // but a live pidfile exists, surface the running pid + the path we
1361
- // expected instead of the misleading start-hint.
1362
- const h = makeHarness();
1363
- try {
1364
- seedVault(h.manifestPath);
1365
- writePid("vault", 9999, h.configDir);
1366
- const lines: string[] = [];
1367
- const code = await logs("vault", {
1368
- configDir: h.configDir,
1369
- manifestPath: h.manifestPath,
1370
- // pid 9999 is "alive" — simulates the running daemon case.
1371
- alive: () => true,
1372
- log: (l) => lines.push(l),
1373
- });
1374
- expect(code).toBe(0);
1375
- const out = lines.join("\n");
1376
- expect(out).toMatch(/vault is running \(pid 9999\)/);
1377
- expect(out).toMatch(/no log file/);
1378
- expect(out).not.toMatch(/parachute start vault/);
1379
- } finally {
1380
- h.cleanup();
1381
- }
1382
- });
1383
-
1384
- test("stale pidfile + missing log file: falls through to start hint", async () => {
1385
- // The other half of the disambiguation: pidfile exists but the process
1386
- // is gone (stale pidfile, or cleanly shut down). That's effectively
1387
- // "not running," so the original `parachute start` hint is still the
1388
- // right message.
1389
- const h = makeHarness();
1390
- try {
1391
- seedVault(h.manifestPath);
1392
- writePid("vault", 9999, h.configDir);
1393
- const lines: string[] = [];
1394
- const code = await logs("vault", {
1395
- configDir: h.configDir,
1396
- manifestPath: h.manifestPath,
1397
- // pid 9999 is "dead" — `processState` returns `stopped`.
1398
- alive: () => false,
1399
- log: (l) => lines.push(l),
1400
- });
1401
- expect(code).toBe(0);
1402
- expect(lines.join("\n")).toMatch(/no logs yet for vault/);
1403
- } finally {
1404
- h.cleanup();
1405
- }
1406
- });
1407
-
1408
- test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
1409
- // The happy path Aaron's title calls out: when the log file exists,
1410
- // we tail it — independent of whether the pidfile is present. A
1411
- // running daemon's logs are useful; a stopped daemon's prior logs are
1412
- // useful too (post-mortem). Pidfile state only changes the message
1413
- // when the file is missing.
1414
- const h = makeHarness();
1415
- try {
1416
- const p = ensureLogPath("vault", h.configDir);
1417
- writeFileSync(p, "vault line a\nvault line b\n");
1418
- // No pidfile written — verify we still print the tail.
1419
- const lines: string[] = [];
1420
- const code = await logs("vault", {
1421
- configDir: h.configDir,
1422
- manifestPath: h.manifestPath,
1423
- alive: () => false,
1424
- log: (l) => lines.push(l),
1425
- });
1426
- expect(code).toBe(0);
1427
- expect(lines).toEqual(["vault line a", "vault line b"]);
1428
- } finally {
1429
- h.cleanup();
1430
- }
1431
- });
1432
- });
1433
-
1434
- describe("process-group lifecycle (hub#88)", () => {
1435
- // Spawn a wrapper that forks a long-running grandchild (sleep), wait for
1436
- // both to come up, then check that the wrapper PID equals its PGID — the
1437
- // post-fix invariant that makes group-kill safe. Without `detached: true`
1438
- // the child inherits the test runner's PGID and group-kill would target
1439
- // the wrong tree.
1440
- test("defaultSpawner puts child in its own process group", async () => {
1441
- const h = makeHarness();
1442
- try {
1443
- const logFile = ensureLogPath("test", h.configDir);
1444
- const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2 & wait"], logFile);
1445
- try {
1446
- // Resolve the child's PGID via ps; the kernel reports it as a
1447
- // numeric column. PGID == PID means our setsid-equivalent worked.
1448
- const ps = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(pid)]);
1449
- const pgid = Number.parseInt(ps.stdout.toString().trim(), 10);
1450
- expect(pgid).toBe(pid);
1451
- } finally {
1452
- try {
1453
- process.kill(-pid, "SIGKILL");
1454
- } catch {}
1455
- }
1456
- } finally {
1457
- h.cleanup();
1458
- }
1459
- });
1460
-
1461
- // The smoking-gun scenario from #88: a wrapper (sh) forks a grandchild
1462
- // (sleep) that keeps a resource — here, just stays alive. SIGKILL on the
1463
- // wrapper PID alone leaves the grandchild running. With detached spawn +
1464
- // group-kill, both go down. We assert by checking the grandchild's PID
1465
- // is no longer kill-able after `defaultKill`.
1466
- test("defaultKill takes down the wrapper and its grandchildren together", async () => {
1467
- const h = makeHarness();
1468
- try {
1469
- const logFile = ensureLogPath("test", h.configDir);
1470
- // Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild
1471
- // PID via the log file, then `wait` so the wrapper sticks around as
1472
- // a parent (mirrors `pnpm exec tsx`'s shape).
1473
- const wrapperPid = defaultSpawner.spawn(
1474
- ["sh", "-c", "sleep 30 & echo $! >&2; wait"],
1475
- logFile,
1476
- );
1477
- // Give the grandchild time to start and the log line to flush.
1478
- await new Promise((r) => setTimeout(r, 200));
1479
- const log = await Bun.file(logFile).text();
1480
- const grandchildPid = Number.parseInt(log.trim().split("\n").pop() ?? "", 10);
1481
- expect(grandchildPid).toBeGreaterThan(0);
1482
- expect(grandchildPid).not.toBe(wrapperPid);
1483
- // Both should be alive before kill.
1484
- expect(() => process.kill(grandchildPid, 0)).not.toThrow();
1485
-
1486
- defaultKill(wrapperPid, "SIGKILL");
1487
-
1488
- // Reap + wait for the grandchild to exit; on macOS the kernel may
1489
- // take a tick to deliver the signal.
1490
- await new Promise((r) => setTimeout(r, 200));
1491
- let grandchildStillAlive = true;
1492
- try {
1493
- process.kill(grandchildPid, 0);
1494
- } catch {
1495
- grandchildStillAlive = false;
1496
- }
1497
- expect(grandchildStillAlive).toBe(false);
1498
- } finally {
1499
- h.cleanup();
1500
- }
1501
- });
1502
-
1503
- // defaultAlive's post-fix semantics: returns true while any group member
1504
- // is alive (the wrapper stays in the group as long as it's running),
1505
- // false after the group drains.
1506
- test("defaultAlive reports group liveness for detached children", async () => {
1507
- const h = makeHarness();
1508
- try {
1509
- const logFile = ensureLogPath("test", h.configDir);
1510
- const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2"], logFile);
1511
- try {
1512
- expect(defaultAlive(pid)).toBe(true);
1513
- } finally {
1514
- try {
1515
- process.kill(-pid, "SIGKILL");
1516
- } catch {}
1517
- }
1518
- // Wait for the kill to drain the group, then re-check.
1519
- await new Promise((r) => setTimeout(r, 100));
1520
- expect(defaultAlive(pid)).toBe(false);
1521
- } finally {
1522
- h.cleanup();
1523
- }
1524
- });
1525
-
1526
- // Legacy pidfile compatibility: a pre-detached pidfile holds a positive
1527
- // PID whose pgid is the parent shell, not the pid itself. defaultAlive
1528
- // must fall back to a bare-pid check so the next `stop` actually runs;
1529
- // defaultKill must fall back to a bare-pid signal so it can be reaped.
1530
- test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
1531
- // Spawn a non-detached child to simulate a legacy pidfile (pre-fix
1532
- // start). It shares the test runner's pgid, so kill(-pid, 0) will
1533
- // ESRCH and we should fall back.
1534
- const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
1535
- const pid = proc.pid;
1536
- try {
1537
- expect(defaultAlive(pid)).toBe(true);
1538
- defaultKill(pid, "SIGKILL");
1539
- await new Promise((r) => setTimeout(r, 100));
1540
- expect(defaultAlive(pid)).toBe(false);
1541
- } finally {
1542
- try {
1543
- process.kill(pid, "SIGKILL");
1544
- } catch {}
1545
- }
1546
- });
1547
- });
1548
-
1549
- /**
1550
- * `parachute start|stop|restart hub` — the bug Aaron filed as hub#166. Hub
1551
- * isn't a row in services.json, so the generic services-manifest path
1552
- * surfaced "unknown service: hub". The fix dispatches `svc === "hub"`
1553
- * straight to hub-control.ts. These tests inject `ensureRunning`/`stop`
1554
- * stubs so we don't actually fork bun.
1555
- */
1556
- describe("parachute start|stop|restart hub", () => {
1557
- test("start hub: dispatches to ensureHubRunning, propagates configDir + issuer", async () => {
1558
- const h = makeHarness();
1559
- try {
1560
- const log: string[] = [];
1561
- const ensureCalls: Array<{ configDir?: string; issuer?: string }> = [];
1562
- const code = await start("hub", {
1563
- configDir: h.configDir,
1564
- manifestPath: h.manifestPath,
1565
- hubOrigin: "https://hub.example.com",
1566
- hub: {
1567
- ensureRunning: async (opts) => {
1568
- ensureCalls.push({ configDir: opts.configDir, issuer: opts.issuer });
1569
- return { pid: 4711, port: 1939, started: true };
1570
- },
1571
- },
1572
- log: (l) => log.push(l),
1573
- });
1574
- expect(code).toBe(0);
1575
- expect(ensureCalls).toHaveLength(1);
1576
- expect(ensureCalls[0]).toEqual({
1577
- configDir: h.configDir,
1578
- issuer: "https://hub.example.com",
1579
- });
1580
- } finally {
1581
- h.cleanup();
1582
- }
1583
- });
1584
-
1585
- test("start hub: reports already-running cleanly when ensureHubRunning returns started=false", async () => {
1586
- const h = makeHarness();
1587
- try {
1588
- const log: string[] = [];
1589
- const code = await start("hub", {
1590
- configDir: h.configDir,
1591
- manifestPath: h.manifestPath,
1592
- hub: {
1593
- ensureRunning: async () => ({ pid: 8888, port: 1939, started: false }),
1594
- },
1595
- log: (l) => log.push(l),
1596
- });
1597
- expect(code).toBe(0);
1598
- expect(log.join("\n")).toMatch(/hub already running \(pid 8888\) on port 1939/);
1599
- } finally {
1600
- h.cleanup();
1601
- }
1602
- });
1603
-
1604
- test("start hub: surfaces ensureHubRunning errors as exit 1", async () => {
1605
- const h = makeHarness();
1606
- try {
1607
- const log: string[] = [];
1608
- const code = await start("hub", {
1609
- configDir: h.configDir,
1610
- manifestPath: h.manifestPath,
1611
- hub: {
1612
- ensureRunning: async () => {
1613
- throw new Error("hub: port 1939 unavailable");
1614
- },
1615
- },
1616
- log: (l) => log.push(l),
1617
- });
1618
- expect(code).toBe(1);
1619
- expect(log.join("\n")).toMatch(/hub failed to start.*port 1939 unavailable/);
1620
- } finally {
1621
- h.cleanup();
1622
- }
1623
- });
1624
-
1625
- // hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
1626
- // the injectable `hub.selfHealOperatorToken` seam to assert the call happens
1627
- // (and to make it throw without failing start); a separate test drives the
1628
- // REAL self-heal against an on-disk operator token + hub.db.
1629
- test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
1630
- const h = makeHarness();
1631
- try {
1632
- const log: string[] = [];
1633
- const calls: Array<{ issuer: string; configDir: string }> = [];
1634
- const code = await start("hub", {
1635
- configDir: h.configDir,
1636
- manifestPath: h.manifestPath,
1637
- hubOrigin: "https://hub.example.com",
1638
- hub: {
1639
- ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1640
- selfHealOperatorToken: async (args) => {
1641
- calls.push({ issuer: args.issuer, configDir: args.configDir });
1642
- return {
1643
- kind: "rotated",
1644
- path: "/x/operator.token",
1645
- scopeSet: "admin",
1646
- expiresAt: "z",
1647
- };
1648
- },
1649
- },
1650
- log: (l) => log.push(l),
1651
- });
1652
- expect(code).toBe(0);
1653
- expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
1654
- // Rotation emits an operator-facing line.
1655
- expect(log.join("\n")).toMatch(
1656
- /refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
1657
- );
1658
- } finally {
1659
- h.cleanup();
1660
- }
1661
- });
1662
-
1663
- test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
1664
- const h = makeHarness();
1665
- try {
1666
- let called = false;
1667
- // No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
1668
- // yields undefined, so the self-heal seam must NOT be called.
1669
- const code = await start("hub", {
1670
- configDir: h.configDir,
1671
- manifestPath: h.manifestPath,
1672
- hub: {
1673
- ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1674
- selfHealOperatorToken: async () => {
1675
- called = true;
1676
- return { kind: "absent" };
1677
- },
1678
- },
1679
- log: () => {},
1680
- });
1681
- expect(code).toBe(0);
1682
- expect(called).toBe(false);
1683
- } finally {
1684
- h.cleanup();
1685
- }
1686
- });
1687
-
1688
- test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
1689
- const h = makeHarness();
1690
- try {
1691
- const log: string[] = [];
1692
- const code = await start("hub", {
1693
- configDir: h.configDir,
1694
- manifestPath: h.manifestPath,
1695
- hubOrigin: "https://hub.example.com",
1696
- hub: {
1697
- ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1698
- selfHealOperatorToken: async () => {
1699
- throw new Error("hub.db is locked");
1700
- },
1701
- },
1702
- log: (l) => log.push(l),
1703
- });
1704
- expect(code).toBe(0);
1705
- // Degrades to a brief note, not a hard failure.
1706
- expect(log.join("\n")).toMatch(
1707
- /operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
1708
- );
1709
- } finally {
1710
- h.cleanup();
1711
- }
1712
- });
1713
-
1714
- test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
1715
- const h = makeHarness();
1716
- try {
1717
- // Seed signing keys + a stale-iss operator token in the harness configDir's
1718
- // hub.db / operator.token, then drive the production self-heal seam.
1719
- const db = openHubDb(hubDbPath(h.configDir));
1720
- try {
1721
- rotateSigningKey(db);
1722
- await issueOperatorToken(db, "user-abc", {
1723
- dir: h.configDir,
1724
- issuer: "http://127.0.0.1:1939",
1725
- scopeSet: "start",
1726
- });
1727
- } finally {
1728
- db.close();
1729
- }
1730
-
1731
- const log: string[] = [];
1732
- const code = await start("hub", {
1733
- configDir: h.configDir,
1734
- manifestPath: h.manifestPath,
1735
- hubOrigin: "https://gitcoin-parachute.unforced.dev",
1736
- // No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
1737
- // (opens hub.db at <configDir>/hub.db).
1738
- hub: {
1739
- ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
1740
- },
1741
- log: (l) => log.push(l),
1742
- });
1743
- expect(code).toBe(0);
1744
- expect(log.join("\n")).toMatch(
1745
- /refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
1746
- );
1747
-
1748
- // The on-disk token now validates under the new issuer, scope-set preserved.
1749
- const verifyDb = openHubDb(hubDbPath(h.configDir));
1750
- try {
1751
- const onDisk = await readOperatorTokenFile(h.configDir);
1752
- expect(onDisk).not.toBeNull();
1753
- const validated = await validateAccessToken(
1754
- verifyDb,
1755
- onDisk as string,
1756
- "https://gitcoin-parachute.unforced.dev",
1757
- );
1758
- expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
1759
- expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
1760
- } finally {
1761
- verifyDb.close();
1762
- }
1763
- } finally {
1764
- h.cleanup();
1765
- }
1766
- });
1767
-
1768
- test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
1769
- const h = makeHarness();
1770
- try {
1771
- const log: string[] = [];
1772
- const stopCalls: Array<{ configDir?: string }> = [];
1773
- const code = await stop("hub", {
1774
- configDir: h.configDir,
1775
- manifestPath: h.manifestPath,
1776
- hub: {
1777
- stop: async (opts) => {
1778
- stopCalls.push({ configDir: opts.configDir });
1779
- return true;
1780
- },
1781
- },
1782
- log: (l) => log.push(l),
1783
- });
1784
- expect(code).toBe(0);
1785
- expect(stopCalls).toHaveLength(1);
1786
- expect(stopCalls[0]?.configDir).toBe(h.configDir);
1787
- expect(log.join("\n")).toMatch(/✓ hub stopped/);
1788
- } finally {
1789
- h.cleanup();
1790
- }
1791
- });
1792
-
1793
- test("stop hub: false → 'wasn't running' (still exit 0)", async () => {
1794
- const h = makeHarness();
1795
- try {
1796
- const log: string[] = [];
1797
- const code = await stop("hub", {
1798
- configDir: h.configDir,
1799
- manifestPath: h.manifestPath,
1800
- hub: { stop: async () => false },
1801
- log: (l) => log.push(l),
1802
- });
1803
- expect(code).toBe(0);
1804
- expect(log.join("\n")).toMatch(/hub wasn't running/);
1805
- } finally {
1806
- h.cleanup();
1807
- }
1808
- });
1809
-
1810
- test("restart hub: chains stop then start through the same hub seam", async () => {
1811
- const h = makeHarness();
1812
- try {
1813
- const log: string[] = [];
1814
- const order: string[] = [];
1815
- const code = await restart("hub", {
1816
- configDir: h.configDir,
1817
- manifestPath: h.manifestPath,
1818
- hub: {
1819
- stop: async () => {
1820
- order.push("stop");
1821
- return true;
1822
- },
1823
- ensureRunning: async () => {
1824
- order.push("start");
1825
- return { pid: 5151, port: 1939, started: true };
1826
- },
1827
- },
1828
- log: (l) => log.push(l),
1829
- });
1830
- expect(code).toBe(0);
1831
- expect(order).toEqual(["stop", "start"]);
1832
- } finally {
1833
- h.cleanup();
1834
- }
1835
- });
1836
-
1837
- test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
1838
- const h = makeHarness();
1839
- try {
1840
- // No log file yet — exercise the "no logs yet" branch, which still
1841
- // returns 0. Goal of this test is just the unknown-service guard.
1842
- const log: string[] = [];
1843
- const code = await logs("hub", {
1844
- configDir: h.configDir,
1845
- manifestPath: h.manifestPath,
1846
- log: (l) => log.push(l),
1847
- });
1848
- expect(code).toBe(0);
1849
- expect(log.join("\n")).toMatch(/no logs yet for hub/);
1850
- } finally {
1851
- h.cleanup();
1852
- }
1853
- });
1854
-
1855
- test("logs hub: prints the tail when a log file exists", async () => {
1856
- const h = makeHarness();
1857
- try {
1858
- const path = ensureLogPath("hub", h.configDir);
1859
- writeFileSync(path, "hub line one\nhub line two\n");
1860
- const log: string[] = [];
1861
- const code = await logs("hub", {
1862
- configDir: h.configDir,
1863
- manifestPath: h.manifestPath,
1864
- log: (l) => log.push(l),
1865
- });
1866
- expect(code).toBe(0);
1867
- expect(log).toEqual(["hub line one", "hub line two"]);
1868
- } finally {
1869
- h.cleanup();
1870
- }
1871
- });
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);
827
+ expect(log).toEqual(["hub line one", "hub line two"]);
2283
828
  } finally {
2284
829
  h.cleanup();
2285
830
  }