@monster-orange/den 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/docs/distributions.md +158 -16
  3. package/package.json +1 -1
  4. package/src/pillars/security/discover.ts +1 -1
  5. package/src/pillars/smoke/setup-global.ts +2 -1
  6. package/src/scripts/configure-aws-profiles.ts +2 -1
  7. package/src/scripts/register-repos.ts +4 -3
  8. package/src/shared/constants.ts +7 -0
  9. package/src/shared/den-config.ts +130 -12
  10. package/src/shared/engine.ts +18 -0
  11. package/src/shared/environment-manager/default-workspace.ts +229 -87
  12. package/src/shared/environment-manager/health.ts +235 -35
  13. package/src/shared/manifest/placement.ts +1 -1
  14. package/src/shared/manifest/schema.ts +47 -0
  15. package/src/shared/manifest/service-links.ts +1 -1
  16. package/src/shared/manifest/validate-manifest.ts +1 -1
  17. package/src/shared/plural.ts +13 -0
  18. package/src/shared/service-url.ts +2 -5
  19. package/src/shared/stacks/compose-health.ts +242 -0
  20. package/src/shared/stacks/runtime.ts +73 -24
  21. package/src/shared/stacks/status.ts +3 -2
  22. package/src/shared/types/index.ts +13 -1
  23. package/src/ui/cli/create.ts +2 -1
  24. package/src/ui/cli/health-display.ts +1 -0
  25. package/src/ui/cli/index.ts +86 -41
  26. package/src/ui/cli/mcp-connect.ts +36 -5
  27. package/src/ui/cli/server-identity.ts +233 -0
  28. package/src/ui/cli/service-command.ts +136 -12
  29. package/src/ui/cli/spinner.ts +48 -0
  30. package/src/ui/cli/stack-command.ts +40 -12
  31. package/src/ui/cli/web-command.ts +101 -32
  32. package/src/ui/mcp/api.ts +94 -1
  33. package/src/ui/mcp/server.ts +6 -1
  34. package/src/ui/mcp/tools.ts +6 -2
  35. package/src/ui/server/health-row.ts +6 -3
  36. package/src/ui/server/index.ts +120 -40
  37. package/src/ui/server/public/changelog-view.js +16 -0
  38. package/src/ui/server/public/header.js +121 -12
  39. package/src/ui/server/ui-assets.ts +1 -0
  40. package/src/ui/server/workspace-routes.ts +7 -0
@@ -9,8 +9,16 @@ import { hasRuntime, repoKind } from "../repo-kind.js";
9
9
  import * as https from "https";
10
10
  import * as tls from "tls";
11
11
  import { readEdgeConfig } from "../edge/config.js";
12
- import { composeHealthCmd } from "../stacks/manifest-commands.js";
12
+ import {
13
+ composeHealth,
14
+ composeHealthTarget,
15
+ containerHealth,
16
+ type ComposeHealthTarget,
17
+ type ComposeVerdict,
18
+ type ContainerHealthTarget,
19
+ } from "../stacks/compose-health.js";
13
20
  import * as fs from "fs";
21
+ import * as os from "os";
14
22
  import * as path from "path";
15
23
  import * as dns from "dns";
16
24
  import { spawn } from "child_process";
@@ -33,12 +41,24 @@ import {
33
41
  listWorkspaceServices,
34
42
  readManifestAnyBranch,
35
43
  } from "./workspaces.js";
36
- import type { ResolvedService, ServiceCatalog } from "../types/index.js";
44
+ import type {
45
+ ResolvedService,
46
+ ServiceCatalog,
47
+ ServiceHealthConfig,
48
+ } from "../types/index.js";
37
49
  import type { Environment } from "../types/index.js";
38
50
 
39
51
  import { product } from "../product.js";
40
52
  export interface PingResult {
41
- ok: boolean;
53
+ /**
54
+ * true: the check answered healthy. false: it COMPLETED and failed. null:
55
+ * it did not complete or could not tell (no answer within its budget, its
56
+ * process could not start, or it reported its state unknown). Only false
57
+ * is down.
58
+ */
59
+ ok: boolean | null;
60
+ /** With ok null: the check gave no answer within its budget. */
61
+ pending?: boolean;
42
62
  status?: number;
43
63
  error?: string;
44
64
  }
@@ -50,7 +70,12 @@ export interface ServiceHealthResult {
50
70
  repoPath: string;
51
71
  mode: "dev-managed" | "managed";
52
72
  branch: string | null;
53
- health: "ok" | "error" | "unknown";
73
+ /**
74
+ * "pending": a check ran but gave no verdict (no answer in time, or it
75
+ * could not tell). Unlike "unknown" (no check configured) it is not a
76
+ * reason to proceed; unlike "error" it is not down.
77
+ */
78
+ health: "ok" | "error" | "unknown" | "pending";
54
79
  healthDetail?: string;
55
80
  /** Where the service runs for the env checked (R13). */
56
81
  runs?: Placement["runs"];
@@ -409,40 +434,212 @@ export async function pingShell(
409
434
  cmd: string,
410
435
  extraEnv: Record<string, string> = {},
411
436
  cwd?: string,
437
+ opts: {
438
+ /** The answer budget (default CLI_SPAWN_TIMEOUT_MS). */
439
+ timeoutMs?: number;
440
+ } = {},
441
+ ): Promise<PingResult> {
442
+ // Bounded concurrency: a health cycle starts every check at once, and a
443
+ // distribution's shell checks are processes. Queued checks start their
444
+ // budget when they start, not while they wait.
445
+ const release = await shellSlots.acquire();
446
+ try {
447
+ return await runShellCheck(cmd, extraEnv, cwd, opts);
448
+ } finally {
449
+ release();
450
+ }
451
+ }
452
+
453
+ /** A counting semaphore. */
454
+ class Slots {
455
+ private free: number;
456
+ private waiting: (() => void)[] = [];
457
+ constructor(readonly size: number) {
458
+ this.free = size;
459
+ }
460
+ async acquire(): Promise<() => void> {
461
+ if (this.free > 0) this.free--;
462
+ else await new Promise<void>((r) => this.waiting.push(r));
463
+ let released = false;
464
+ return () => {
465
+ if (released) return;
466
+ released = true;
467
+ const next = this.waiting.shift();
468
+ if (next) next();
469
+ else this.free++;
470
+ };
471
+ }
472
+ }
473
+
474
+ /** Shell checks running at once: max(2, cpus/2). */
475
+ export const SHELL_CHECK_CONCURRENCY = Math.max(
476
+ 2,
477
+ Math.floor(os.cpus().length / 2),
478
+ );
479
+ const shellSlots = new Slots(SHELL_CHECK_CONCURRENCY);
480
+
481
+ function runShellCheck(
482
+ cmd: string,
483
+ extraEnv: Record<string, string>,
484
+ cwd: string | undefined,
485
+ opts: { timeoutMs?: number },
412
486
  ): Promise<PingResult> {
487
+ const budget = opts.timeoutMs ?? CLI_SPAWN_TIMEOUT_MS;
413
488
  return new Promise((resolve) => {
414
489
  let settled = false;
415
490
  const settle = (r: PingResult): void => {
416
491
  if (settled) return;
417
492
  settled = true;
493
+ clearTimeout(timer);
418
494
  clearTimeout(guard);
419
495
  resolve(r);
420
496
  };
421
- const proc = spawn(cmd, [], {
422
- shell: true,
423
- timeout: CLI_SPAWN_TIMEOUT_MS,
424
- env: { ...process.env, ...extraEnv },
425
- cwd,
426
- // Output is never read: piped stdio would block a chatty script once the
427
- // 64KB buffer fills, and 'close' would wait on grandchildren holding the
428
- // pipes open. Nothing consumes it — don't create it.
429
- stdio: "ignore",
497
+ // A check that has not answered is not a failed check: past the budget
498
+ // it is pending (ok null), never down. Under load a CLI-based check
499
+ // (`<cli> stack health`, a full tsx start) can need longer than the
500
+ // budget on a cold poll, and calling that "down" painted healthy stacks
501
+ // red until the next poll.
502
+ const noAnswer = (): PingResult => ({
503
+ ok: null,
504
+ pending: true,
505
+ error: `no answer within ${Math.round(budget / 1000)}s`,
430
506
  });
507
+ let proc: ReturnType<typeof spawn>;
508
+ try {
509
+ proc = spawn(cmd, [], {
510
+ shell: true,
511
+ env: { ...process.env, ...extraEnv },
512
+ cwd,
513
+ // Output is never read: piped stdio would block a chatty script once
514
+ // the 64KB buffer fills, and 'close' would wait on grandchildren
515
+ // holding the pipes open. Nothing consumes it — don't create it.
516
+ stdio: "ignore",
517
+ });
518
+ } catch (err) {
519
+ resolve({ ok: null, error: (err as Error).message });
520
+ return;
521
+ }
522
+ const timer = setTimeout(() => {
523
+ settle(noAnswer());
524
+ proc.kill("SIGTERM");
525
+ }, budget);
431
526
  // 'exit', NOT 'close': close waits for stdio to drain, which a killed
432
527
  // shell's surviving grandchildren can hold open forever — one wedged
433
528
  // health check then hangs every /api/health poll for good. The guard
434
- // timer backstops even a spawn timeout that never fires.
435
- const guard = setTimeout(
436
- () => settle({ ok: false, error: "health check timed out" }),
437
- CLI_SPAWN_TIMEOUT_MS + 1_000,
438
- );
439
- proc.on("exit", (code) =>
440
- settle(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }),
441
- );
442
- proc.on("error", (err) => settle({ ok: false, error: err.message }));
529
+ // timer backstops even a kill that never lands.
530
+ const guard = setTimeout(() => settle(noAnswer()), budget + 1_000);
531
+ proc.on("exit", (code, signal) => {
532
+ if (code === 0) return settle({ ok: true });
533
+ // Killed (by the budget above, or from outside): it never answered.
534
+ if (code === null)
535
+ return settle({ ...noAnswer(), error: `killed (${signal})` });
536
+ settle({ ok: false, error: `exit ${code}` });
537
+ });
538
+ // The check could not be started (e.g. EAGAIN under load): no verdict.
539
+ proc.on("error", (err) => settle({ ok: null, error: err.message }));
443
540
  });
444
541
  }
445
542
 
543
+ // ── Timeouts: pending first, down when repeated ─────────────────────────────
544
+ // A check that gets no answer within its budget (an HTTP timeout, a shell
545
+ // check past CLI_SPAWN_TIMEOUT_MS) has not completed: one such cycle is
546
+ // pending, not down. A cold first probe on a loaded machine (cold docker, a
547
+ // tsx-started `stack health`, cold TLS) can miss the budget while the service
548
+ // is fine, and reporting that as down painted healthy repos red until the
549
+ // next poll. Two consecutive timeouts are down: a hung service still shows.
550
+ // The web health row counts cycles (TimeoutStreaks); a one-shot caller
551
+ // (`<cli> status`, checkServiceHealth) re-probes once at once instead, so
552
+ // both say the same thing about the same service.
553
+
554
+ /** Whether a ping got no answer within its budget. */
555
+ export function timedOut(r: PingResult): boolean {
556
+ return r.pending === true || (r.ok === false && r.error === "timeout");
557
+ }
558
+
559
+ /** A timeout's verdict: pending on its first cycle, down on a repeat. */
560
+ function timeoutVerdict(r: PingResult, repeated: boolean): PingResult {
561
+ const error = r.error ?? "timeout";
562
+ return repeated
563
+ ? { ok: false, error: `${error} (twice in a row)` }
564
+ : { ok: null, pending: true, error };
565
+ }
566
+
567
+ /** Consecutive timed-out cycles per check, for a caller that polls. */
568
+ export class TimeoutStreaks {
569
+ private streak = new Map<string, number>();
570
+ /** `r` as this cycle's verdict for check `key`. */
571
+ judge(key: string, r: PingResult): PingResult {
572
+ if (!timedOut(r)) {
573
+ this.streak.delete(key);
574
+ return r;
575
+ }
576
+ const n = (this.streak.get(key) ?? 0) + 1;
577
+ this.streak.set(key, n);
578
+ return timeoutVerdict(r, n >= 2);
579
+ }
580
+ }
581
+
582
+ /** Probe once; on a timeout, probe again at once: down only if it times out twice. */
583
+ export async function pingTwiceOnTimeout(
584
+ probe: () => Promise<PingResult>,
585
+ ): Promise<PingResult> {
586
+ const first = await probe();
587
+ if (!timedOut(first)) return first;
588
+ const second = await probe();
589
+ return timedOut(second) ? timeoutVerdict(second, true) : second;
590
+ }
591
+
592
+ /**
593
+ * Run a shell health check. Den's own compose check (the `<cli> stack
594
+ * health` command it generates, `compose.cmd`) is answered in-process from
595
+ * the shared docker listing: no CLI process per check. Any other command
596
+ * (a distribution's own) runs as a shell command.
597
+ */
598
+ export async function pingShellCheck(
599
+ cmd: string,
600
+ extraEnv: Record<string, string>,
601
+ cwd: string | undefined,
602
+ compose: ComposeHealthTarget | null,
603
+ ): Promise<PingResult> {
604
+ if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
605
+ return verdictPing(await composeHealth(compose));
606
+ }
607
+
608
+ /**
609
+ * A compose or container verdict as a ping. One conversion for both, so a
610
+ * docker that could not be asked reads the same: `ok: null`, pending (and so
611
+ * down on a repeat) only when the listing ran past its budget.
612
+ */
613
+ function verdictPing(v: ComposeVerdict): PingResult {
614
+ if (v.ok === true) return { ok: true };
615
+ if (v.ok === false) return { ok: false, error: v.detail };
616
+ return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
617
+ }
618
+
619
+ /**
620
+ * Run a `type: container` check: answered in-process from the shared docker
621
+ * listing (the one Den's compose checks use), never a docker call of its own.
622
+ */
623
+ export async function pingContainerCheck(
624
+ target: ContainerHealthTarget,
625
+ ): Promise<PingResult> {
626
+ return verdictPing(await containerHealth(target));
627
+ }
628
+
629
+ /** The container a `type: container` health config names. */
630
+ export function containerTarget(
631
+ h: Extract<ServiceHealthConfig, { type: "container" }>,
632
+ ): ContainerHealthTarget {
633
+ return h.container_name !== undefined
634
+ ? { container_name: h.container_name }
635
+ : { project: h.project, service: h.service };
636
+ }
637
+
638
+ /** A ping result as a ServiceHealthResult health state. */
639
+ export function healthState(r: PingResult): ServiceHealthResult["health"] {
640
+ return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
641
+ }
642
+
446
643
  // ── Per-service health check ──────────────────────────────────────────────────
447
644
 
448
645
  /**
@@ -480,9 +677,9 @@ export async function checkServiceHealth(
480
677
  };
481
678
  }
482
679
 
483
- const composeHealth =
680
+ const compose =
484
681
  env === "local"
485
- ? composeHealthCmd(
682
+ ? composeHealthTarget(
486
683
  manifest,
487
684
  resolved.subsystem,
488
685
  resolved.repoName,
@@ -493,7 +690,7 @@ export async function checkServiceHealth(
493
690
  manifest,
494
691
  resolved.subsystem,
495
692
  env,
496
- composeHealth,
693
+ compose?.cmd ?? null,
497
694
  );
498
695
  const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
499
696
  runs: plan.placement.runs,
@@ -544,21 +741,24 @@ export async function checkServiceHealth(
544
741
  };
545
742
  }
546
743
 
547
- const result =
744
+ const result = await pingTwiceOnTimeout(() =>
548
745
  healthCfg.type === "shell"
549
- ? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
550
- : await pingHttp(
551
- healthCfg.url,
552
- env === "local" && plan.placement.runs !== "remote"
553
- ? HEALTH_CHECK_TIMEOUT_MS
554
- : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
555
- plan.expect,
556
- );
746
+ ? pingShellCheck(healthCfg.cmd, {}, resolved.repoPath, compose)
747
+ : healthCfg.type === "container"
748
+ ? pingContainerCheck(containerTarget(healthCfg))
749
+ : pingHttp(
750
+ healthCfg.url,
751
+ env === "local" && plan.placement.runs !== "remote"
752
+ ? HEALTH_CHECK_TIMEOUT_MS
753
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
754
+ plan.expect,
755
+ ),
756
+ );
557
757
 
558
758
  return {
559
759
  ...base,
560
760
  ...placed,
561
- health: result.ok ? "ok" : "error",
761
+ health: healthState(result),
562
762
  healthDetail:
563
763
  result.error ??
564
764
  (!result.ok && result.status !== undefined
@@ -52,7 +52,7 @@ export type IsProduction = (env: string) => boolean;
52
52
  const LOCAL = "local";
53
53
 
54
54
  function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
55
- return !!h && h.type !== "shell" && typeof h.url === "string";
55
+ return !!h && "url" in h && typeof h.url === "string";
56
56
  }
57
57
 
58
58
  function hasCommands(
@@ -32,7 +32,54 @@ const commandDef = z.union([
32
32
  // { url: "http://..." } — implicit HTTP
33
33
  // { type: "http", url: "http://..." } — explicit HTTP
34
34
  // { type: "shell", cmd: "pg_isready ..." } — shell
35
+ // { type: "container", project, service } — a compose container
36
+ // { type: "container", container_name } — a container by name
37
+ const CONTAINER_HEALTH_KEYS = new Set([
38
+ "type",
39
+ "project",
40
+ "service",
41
+ "container_name",
42
+ "label",
43
+ ]);
44
+ export const CONTAINER_HEALTH_SHAPE =
45
+ 'container checks need "project" and "service" (compose labels), or "container_name"';
46
+ const containerHealth = z
47
+ .object({
48
+ type: z.literal("container"),
49
+ project: z.string().min(1).optional(),
50
+ service: z.string().min(1).optional(),
51
+ container_name: z.string().min(1).optional(),
52
+ label: z.string().optional(),
53
+ })
54
+ .passthrough()
55
+ .superRefine((h, ctx) => {
56
+ const unknown = Object.keys(h).filter((k) => !CONTAINER_HEALTH_KEYS.has(k));
57
+ if (unknown.length)
58
+ ctx.addIssue({
59
+ code: z.ZodIssueCode.custom,
60
+ message: `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in a container check: ${CONTAINER_HEALTH_SHAPE}`,
61
+ });
62
+ const byLabels = h.project !== undefined || h.service !== undefined;
63
+ const byName = h.container_name !== undefined;
64
+ if (byLabels && byName)
65
+ ctx.addIssue({
66
+ code: z.ZodIssueCode.custom,
67
+ message: `a container check matches by "project"/"service" or by "container_name", not both`,
68
+ });
69
+ else if (byLabels && (h.project === undefined || h.service === undefined))
70
+ ctx.addIssue({
71
+ code: z.ZodIssueCode.custom,
72
+ message: `a container check by compose labels needs both "project" and "service"`,
73
+ });
74
+ else if (!byLabels && !byName)
75
+ ctx.addIssue({
76
+ code: z.ZodIssueCode.custom,
77
+ message: CONTAINER_HEALTH_SHAPE,
78
+ });
79
+ });
80
+
35
81
  const healthConfig = z.union([
82
+ containerHealth,
36
83
  // Shell health check — must have cmd
37
84
  z.object({
38
85
  type: z.literal("shell"),
@@ -47,7 +47,7 @@ export function openUrlFor(
47
47
  const declared = httpUrl(svc.urls?.[env]);
48
48
  if (declared) return declared;
49
49
  const health = svc.health?.[env];
50
- if (!health || ("type" in health && health.type === "shell")) return null;
50
+ if (!health || !("url" in health)) return null;
51
51
  return httpOrigin((health as { url?: string }).url);
52
52
  }
53
53
 
@@ -57,7 +57,7 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
57
57
 
58
58
  // health with missing url/cmd
59
59
  if (pathStr.includes("health")) {
60
- return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"';
60
+ return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"; container checks need "type: container" and "project" + "service", or "container_name"';
61
61
  }
62
62
 
63
63
  return undefined;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Counts in output: "1 repo", "3 repos", never "1 repos" or "repo(s)".
3
+ */
4
+
5
+ /** `word` in the form `n` takes: "repo" for 1, "repos" (or `many`) otherwise. */
6
+ export function noun(n: number, word: string, many = `${word}s`): string {
7
+ return n === 1 ? word : many;
8
+ }
9
+
10
+ /** `n` and its noun: plural(1, "repo") → "1 repo", plural(2, "repo") → "2 repos". */
11
+ export function plural(n: number, word: string, many?: string): string {
12
+ return `${n} ${noun(n, word, many)}`;
13
+ }
@@ -24,11 +24,8 @@ function healthUrlForConfig(healthConfig?: ServiceHealthConfig): string | null {
24
24
  return null;
25
25
  }
26
26
 
27
- if ("type" in healthConfig && healthConfig.type === "shell") {
28
- return null;
29
- }
30
-
31
- return healthConfig.url;
27
+ // Shell and container checks have no URL.
28
+ return "url" in healthConfig ? healthConfig.url : null;
32
29
  }
33
30
 
34
31
  function normalizeServiceBaseUrl(