@openparachute/hub 0.6.2 → 0.6.3-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ import { MissingDependencyError, type MissingDependencyWire } from "@openparachu
39
39
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
40
40
  import { isLinked as defaultIsLinked } from "./bun-link.ts";
41
41
  import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
42
+ import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
42
43
  import { getModuleInstallChannel } from "./hub-settings.ts";
43
44
  import { validateAccessToken } from "./jwt-sign.ts";
44
45
  import { readModuleManifest } from "./module-manifest.ts";
@@ -456,6 +457,11 @@ async function spawnSupervised(
456
457
  // anchors the child's iss expectation to the same value hub mints with.
457
458
  //
458
459
  // `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
460
+ //
461
+ // No per-service `.env` here, by design: the install path runs before the
462
+ // operator has had a chance to write `configDir/<short>/.env`, so install
463
+ // spawns with install-env only. The per-service `.env` is layered in by
464
+ // `buildModuleSpawnRequest` (serve-boot.ts) on the next `boot` or `start`.
459
465
  const childEnv: Record<string, string> = {
460
466
  PORT: String(entry.port),
461
467
  ...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
@@ -713,6 +719,115 @@ export async function runInstall(
713
719
  registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
714
720
  }
715
721
 
722
+ /**
723
+ * POST /api/modules/:short/start — synchronous.
724
+ *
725
+ * A pure `supervisor.start(req)` of an ALREADY-INSTALLED module, using
726
+ * the same boot-derived SpawnRequest `bootSupervisedModules` builds
727
+ * (PORT / per-service .env / PARACHUTE_HUB_ORIGIN injection via the
728
+ * shared `buildModuleSpawnRequest`). This is the §3.3 endpoint Phase 3
729
+ * will repoint `parachute start <svc>` onto.
730
+ *
731
+ * Explicitly NOT an install: it does not run `bun add -g`, seed
732
+ * services.json, stamp installDir, or refresh well-known. If the module
733
+ * isn't in services.json (never installed) it returns 400 `not_installed`
734
+ * with an actionable hint — not a silent install. If services.json
735
+ * carries the row but no startCmd is resolvable (CLI-only module,
736
+ * unreadable module.json), it returns 422 `no_start_cmd`.
737
+ *
738
+ * Synchronous like restart: `supervisor.start` returns the new state in
739
+ * the body; no operation poll needed. Idempotent — starting an
740
+ * already-running module returns its existing state (the supervisor's
741
+ * own idempotent `start`).
742
+ */
743
+ export async function handleStart(
744
+ req: Request,
745
+ short: CuratedModuleShort,
746
+ deps: ApiModulesOpsDeps,
747
+ ): Promise<Response> {
748
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
749
+ const authFail = await authorize(req, deps);
750
+ if (authFail) return authFail;
751
+
752
+ const spec = specFor(short);
753
+
754
+ // Pure-spawn precondition: the module must already be installed
755
+ // (present in services.json). `start` never installs — that's the
756
+ // install endpoint's job, which is far heavier (bun add -g / seed /
757
+ // stamp). A missing row is an operator error worth a clear message.
758
+ const entry = findService(spec.manifestName, deps.manifestPath);
759
+ if (!entry) {
760
+ return jsonError(
761
+ 400,
762
+ "not_installed",
763
+ `${short} is not installed (no services.json entry) — install it first via POST /api/modules/${short}/install`,
764
+ );
765
+ }
766
+
767
+ // KNOWN_MODULES shorts (vault / scribe / runner): module.json is the
768
+ // canonical source for startCmd. Re-resolve from
769
+ // `<installDir>/.parachute/module.json` when installDir is stamped so the
770
+ // module is authoritative for its own spawn cmd — mirroring runInstall's
771
+ // post-bun-add re-resolve. Falls back to the imperative `extras.startCmd`
772
+ // carried by `spec` when installDir is absent or module.json is unreadable.
773
+ let spawnSpec: ServiceSpec = spec;
774
+ if (entry.installDir && KNOWN_MODULES[short]) {
775
+ const resolved = await resolveSpawnSpec(short, entry.installDir);
776
+ if (resolved) spawnSpec = resolved;
777
+ }
778
+
779
+ const cmd = spawnSpec.startCmd?.(entry);
780
+ if (!cmd || cmd.length === 0) {
781
+ return jsonError(
782
+ 422,
783
+ "no_start_cmd",
784
+ `${short} has no resolvable startCmd (CLI-only module, or <installDir>/.parachute/module.json missing a startCmd)`,
785
+ );
786
+ }
787
+
788
+ // Build the SpawnRequest identically to the serve-boot path so `start`
789
+ // and boot produce the same child env (PORT / .env / HUB_ORIGIN). The
790
+ // test-seam / first-boot `spawnEnv` rides the shared helper's `extraEnv`
791
+ // and wins last, matching `spawnSupervised`'s precedence.
792
+ const spawnReq = buildModuleSpawnRequest(short, entry, cmd, {
793
+ configDir: deps.configDir,
794
+ ...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
795
+ ...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
796
+ });
797
+
798
+ const state = await deps.supervisor.start(spawnReq);
799
+ return jsonOk({ short, state });
800
+ }
801
+
802
+ /**
803
+ * POST /api/modules/:short/stop — synchronous.
804
+ *
805
+ * A pure `supervisor.stop(short)` — SIGTERM the child, await exit (with
806
+ * SIGKILL escalation), mark `stopped`. Distinct from uninstall, which
807
+ * stops-then-removes the services.json row + `bun remove`s the package.
808
+ * `stop` leaves the module installed; it's the §3.3 endpoint Phase 3
809
+ * will repoint `parachute stop <svc>` onto.
810
+ *
811
+ * Idempotent: stopping a not-supervised module returns 200 with a
812
+ * `stopped: false` flag (nothing to stop) rather than erroring — the
813
+ * caller's intent ("ensure it's not running") is already satisfied.
814
+ */
815
+ export async function handleStop(
816
+ req: Request,
817
+ short: CuratedModuleShort,
818
+ deps: ApiModulesOpsDeps,
819
+ ): Promise<Response> {
820
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
821
+ const authFail = await authorize(req, deps);
822
+ if (authFail) return authFail;
823
+
824
+ const state = await deps.supervisor.stop(short);
825
+ if (!state) {
826
+ return jsonOk({ short, stopped: false });
827
+ }
828
+ return jsonOk({ short, stopped: true, state });
829
+ }
830
+
716
831
  /**
717
832
  * POST /api/modules/:short/restart — synchronous.
718
833
  *
@@ -741,6 +856,112 @@ export async function handleRestart(
741
856
  return jsonOk({ short, state });
742
857
  }
743
858
 
859
+ /**
860
+ * GET /api/modules/:short/logs — synchronous.
861
+ *
862
+ * Serves the supervisor's bounded per-module ring buffer (§6.5): the most
863
+ * recent output the child wrote, INCLUDING the boot/crash lines that happened
864
+ * before the caller connected — which a naive connect-time SSE tap would lose
865
+ * (and which are "likely the most important one — the exit cause"). This is
866
+ * the §6 endpoint Phase 3 will repoint `parachute logs <svc>` onto.
867
+ *
868
+ * Returns the buffer as both a joined `text` blob and a `lines` array (the CLI
869
+ * tail wants the blob; a structured consumer wants lines). A module that isn't
870
+ * supervised returns 404 `not_supervised`, matching the `restart` handler's
871
+ * error contract for the same state — the caller can fall through to `start`.
872
+ *
873
+ * `?follow=1` is accepted as a best-effort streaming tap: we replay the buffer
874
+ * first (the must-have), then stream subsequent lines as `text/plain` chunks.
875
+ * The buffer replay is what captures the crash cause; the follow tail is the
876
+ * nice-to-have. Without `follow`, it's a one-shot JSON snapshot.
877
+ */
878
+ export async function handleLogs(
879
+ req: Request,
880
+ short: CuratedModuleShort,
881
+ deps: ApiModulesOpsDeps,
882
+ ): Promise<Response> {
883
+ if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
884
+ const authFail = await authorize(req, deps);
885
+ if (authFail) return authFail;
886
+
887
+ const lines = deps.supervisor.logs(short);
888
+ if (lines === undefined) {
889
+ // Same shape + status as `restart` for a not-supervised module, so the
890
+ // CLI client can treat both identically (fall through to `start`).
891
+ return jsonError(
892
+ 404,
893
+ "not_supervised",
894
+ `${short} is not currently supervised — install or start it first`,
895
+ );
896
+ }
897
+
898
+ const follow = new URL(req.url).searchParams.get("follow");
899
+ if (follow === "1" || follow === "true") {
900
+ return streamModuleLogs(short, lines, deps);
901
+ }
902
+
903
+ // One-shot snapshot: the buffered lines as both a joined blob + the array.
904
+ return jsonOk({ short, lines, text: lines.join("") });
905
+ }
906
+
907
+ /**
908
+ * Best-effort follow stream (§6.5 nice-to-have). Replays the buffered lines
909
+ * (the must-have — captures the boot/crash cause) then forwards subsequent
910
+ * output as `text/plain` chunks by subscribing to a tee of the supervisor's
911
+ * live tap. The buffer replay is guaranteed; the live tail is opportunistic
912
+ * (it ends when the client disconnects or the module stops). Implemented via
913
+ * a polling diff of the ring buffer so it stays decoupled from `pumpLines`'
914
+ * internal sink and needs no new supervisor wiring.
915
+ */
916
+ function streamModuleLogs(
917
+ short: CuratedModuleShort,
918
+ initial: string[],
919
+ deps: ApiModulesOpsDeps,
920
+ ): Response {
921
+ const encoder = new TextEncoder();
922
+ let lastLen = initial.length;
923
+ let timer: ReturnType<typeof setInterval> | undefined;
924
+ const stream = new ReadableStream<Uint8Array>({
925
+ start(controller) {
926
+ // Replay the buffered lines first — the boot/crash cause.
927
+ for (const line of initial) controller.enqueue(encoder.encode(line));
928
+ timer = setInterval(() => {
929
+ const current = deps.supervisor.logs(short);
930
+ if (current === undefined) {
931
+ // Module went away (uninstalled / never-supervised) — end the stream.
932
+ if (timer) clearInterval(timer);
933
+ try {
934
+ controller.close();
935
+ } catch {
936
+ // already closed
937
+ }
938
+ return;
939
+ }
940
+ // The ring buffer may have dropped old lines off the front; only
941
+ // forward genuinely-new tail lines. If the buffer shrank below our
942
+ // cursor (eviction), reset to its current length to avoid replaying.
943
+ // Limitation: new lines written during a heavy eviction burst (a chatty
944
+ // module overflowing the 64KiB cap between two polls) may be skipped in
945
+ // the live tail — use the one-shot snapshot (no ?follow) for crash investigation.
946
+ if (current.length < lastLen) lastLen = current.length;
947
+ for (let i = lastLen; i < current.length; i++) {
948
+ const line = current[i];
949
+ if (line !== undefined) controller.enqueue(encoder.encode(line));
950
+ }
951
+ lastLen = current.length;
952
+ }, 500);
953
+ },
954
+ cancel() {
955
+ // Stop polling when the consumer disconnects.
956
+ if (timer) clearInterval(timer);
957
+ },
958
+ });
959
+ return new Response(stream, {
960
+ status: 200,
961
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
962
+ });
963
+ }
964
+
744
965
  /**
745
966
  * POST /api/modules/:short/upgrade — async.
746
967
  *
@@ -42,8 +42,13 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
42
42
  // still required) and the latter for vault/scribe/runner (post-FALLBACK
43
43
  // retirement, hub#310). The local helper hides the split from the rest of
44
44
  // this file.
45
- import { type UiSubUnit, type UiSubUnitStatus, readManifest, readManifestLenient } from "./services-manifest.ts";
46
- import type { ModuleState, Supervisor } from "./supervisor.ts";
45
+ import {
46
+ type UiSubUnit,
47
+ type UiSubUnitStatus,
48
+ readManifest,
49
+ readManifestLenient,
50
+ } from "./services-manifest.ts";
51
+ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts";
47
52
 
48
53
  /**
49
54
  * Resolve a curated module to the display + install bootstrap data the
@@ -174,6 +179,16 @@ interface ModuleWireShape {
174
179
  latest_version: string | null;
175
180
  supervisor_status: ModuleState["status"] | null;
176
181
  pid: number | null;
182
+ /**
183
+ * Structured supervisor start-failure detail (§6.5 / §6.4), when the
184
+ * supervisor recorded one for this module — a preflight `MissingDependencyError`
185
+ * or the alive-but-never-bound shape (hub#487). Mirrors the services.json
186
+ * `lastStartError` the detached path persists, so `parachute status` and the
187
+ * SPA keep the SAME friendly missing-dependency surface (#188) whether a
188
+ * module was started via the supervisor or the detached path. Null when the
189
+ * module started cleanly (or the hub is in pidfile/CLI mode with no supervisor).
190
+ */
191
+ supervisor_start_error: ModuleStartError | null;
177
192
  /**
178
193
  * The path on disk where the module is installed, if known. Surfaces
179
194
  * the BUN_INSTALL or bun-link install location for operator debug —
@@ -477,6 +492,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
477
492
  latest_version: latestByShort.get(short) ?? null,
478
493
  supervisor_status: state?.status ?? null,
479
494
  pid: state?.pid ?? null,
495
+ supervisor_start_error: state?.startError ?? null,
480
496
  install_dir: installed?.installDir ?? null,
481
497
  uis: toUisWireShape(installed?.uis),
482
498
  management_url: managementUrlByShort.get(short) ?? null,
package/src/cli.ts CHANGED
@@ -454,7 +454,11 @@ async function main(argv: string[]): Promise<number> {
454
454
  console.log(statusHelp());
455
455
  return 0;
456
456
  }
457
- return await status();
457
+ // Pass an empty `supervisor` block so `status` takes the Phase 3c
458
+ // dual-dispatch: on a box with a hub unit installed it reads the platform
459
+ // manager + the running supervisor; on a legacy detached box it falls back
460
+ // to the pidfile readout (design §6.4). Tests drive the seams directly.
461
+ return await status({ supervisor: {} });
458
462
 
459
463
  case "expose": {
460
464
  const hubExtract = extractHubOrigin(rest);
@@ -624,7 +628,13 @@ async function main(argv: string[]): Promise<number> {
624
628
  console.error(`parachute start: ${hubExtract.error}`);
625
629
  return 1;
626
630
  }
627
- const startOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
631
+ // `supervisor: {}` opts into the Phase 3b dual-dispatch: on a box with a
632
+ // hub unit installed, drive the running supervisor; on a legacy detached
633
+ // box (no unit), fall through to the unchanged detached path (design §3.3).
634
+ const startOpts = {
635
+ supervisor: {},
636
+ ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
637
+ };
628
638
  return await start(hubExtract.rest[0], startOpts);
629
639
  }
630
640
 
@@ -633,7 +643,7 @@ async function main(argv: string[]): Promise<number> {
633
643
  console.log(stopHelp());
634
644
  return 0;
635
645
  }
636
- return await stop(rest[0]);
646
+ return await stop(rest[0], { supervisor: {} });
637
647
  }
638
648
 
639
649
  case "restart": {
@@ -641,7 +651,7 @@ async function main(argv: string[]): Promise<number> {
641
651
  console.log(restartHelp());
642
652
  return 0;
643
653
  }
644
- return await restart(rest[0]);
654
+ return await restart(rest[0], { supervisor: {} });
645
655
  }
646
656
 
647
657
  case "upgrade": {