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