@monster-orange/den 0.2.3 → 0.2.5

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 (35) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/docs/distributions.md +64 -16
  3. package/package.json +1 -1
  4. package/service/install.sh +4 -2
  5. package/src/shared/constants.ts +7 -0
  6. package/src/shared/den-config.ts +130 -12
  7. package/src/shared/edge/config.ts +16 -0
  8. package/src/shared/engine.ts +18 -0
  9. package/src/shared/environment-manager/default-workspace.ts +182 -87
  10. package/src/shared/environment-manager/health.ts +229 -30
  11. package/src/shared/environment-manager/opctl.ts +83 -1
  12. package/src/shared/environment-manager/service-runner.ts +49 -4
  13. package/src/shared/git/origin-url.ts +77 -0
  14. package/src/shared/manifest/read-manifest.ts +39 -15
  15. package/src/shared/paths.ts +42 -0
  16. package/src/shared/product.ts +7 -1
  17. package/src/shared/profile.ts +8 -0
  18. package/src/shared/read-cache.ts +77 -0
  19. package/src/shared/stacks/compose-health.ts +150 -0
  20. package/src/shared/stacks/runtime.ts +71 -24
  21. package/src/shared/stacks/status.ts +112 -3
  22. package/src/ui/cli/health-display.ts +1 -0
  23. package/src/ui/cli/index.ts +181 -31
  24. package/src/ui/cli/mcp-connect.ts +77 -15
  25. package/src/ui/cli/stack-command.ts +40 -12
  26. package/src/ui/cli/web-command.ts +25 -7
  27. package/src/ui/cli/workspace-notes.ts +43 -0
  28. package/src/ui/server/graph-order.ts +22 -0
  29. package/src/ui/server/index.ts +174 -197
  30. package/src/ui/server/public/changelog-view.js +16 -0
  31. package/src/ui/server/public/header.js +121 -12
  32. package/src/ui/server/services-list.ts +210 -0
  33. package/src/ui/server/single-flight.ts +53 -0
  34. package/src/ui/server/ui-assets.ts +1 -0
  35. package/src/ui/server/workspace-routes.ts +247 -181
@@ -9,8 +9,13 @@ 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
+ type ComposeHealthTarget,
16
+ } from "../stacks/compose-health.js";
13
17
  import * as fs from "fs";
18
+ import * as os from "os";
14
19
  import * as path from "path";
15
20
  import * as dns from "dns";
16
21
  import { spawn } from "child_process";
@@ -38,7 +43,15 @@ import type { Environment } from "../types/index.js";
38
43
 
39
44
  import { product } from "../product.js";
40
45
  export interface PingResult {
41
- ok: boolean;
46
+ /**
47
+ * true: the check answered healthy. false: it COMPLETED and failed. null:
48
+ * it did not complete or could not tell (no answer within its budget, its
49
+ * process could not start, or it reported its state unknown). Only false
50
+ * is down.
51
+ */
52
+ ok: boolean | null;
53
+ /** With ok null: the check gave no answer within its budget. */
54
+ pending?: boolean;
42
55
  status?: number;
43
56
  error?: string;
44
57
  }
@@ -50,7 +63,12 @@ export interface ServiceHealthResult {
50
63
  repoPath: string;
51
64
  mode: "dev-managed" | "managed";
52
65
  branch: string | null;
53
- health: "ok" | "error" | "unknown";
66
+ /**
67
+ * "pending": a check ran but gave no verdict (no answer in time, or it
68
+ * could not tell). Unlike "unknown" (no check configured) it is not a
69
+ * reason to proceed; unlike "error" it is not down.
70
+ */
71
+ health: "ok" | "error" | "unknown" | "pending";
54
72
  healthDetail?: string;
55
73
  /** Where the service runs for the env checked (R13). */
56
74
  runs?: Placement["runs"];
@@ -175,6 +193,36 @@ export function __setExtraCaForTests(pems: string[] | null): void {
175
193
  _extraCa = pems;
176
194
  }
177
195
 
196
+ /**
197
+ * One TLS context with the public roots plus extraCa(), built once. Passing
198
+ * `ca` per request made Node parse ~150 root certificates on every HTTPS
199
+ * health check, synchronously: tens of ms per check, per poll.
200
+ */
201
+ /** Build the HTTPS checks' trust context now (web server startup). */
202
+ export function prewarmHealthTls(): void {
203
+ trustContext();
204
+ }
205
+
206
+ let _trust: {
207
+ ca: string[];
208
+ context: tls.SecureContext;
209
+ agent: https.Agent;
210
+ } | null = null;
211
+ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
212
+ const extra = extraCa();
213
+ if (_trust?.ca !== extra) {
214
+ _trust?.agent.destroy();
215
+ _trust = {
216
+ ca: extra,
217
+ context: tls.createSecureContext({
218
+ ca: [...tls.rootCertificates, ...extra],
219
+ }),
220
+ agent: new https.Agent({ keepAlive: false }),
221
+ };
222
+ }
223
+ return _trust;
224
+ }
225
+
178
226
  /**
179
227
  * HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
180
228
  * hostname for SNI and certificate verification; rewriting the URL to the IP,
@@ -201,7 +249,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
201
249
  {
202
250
  method: "GET",
203
251
  servername: isIpLiteral(host) ? undefined : host,
204
- ca: [...tls.rootCertificates, ...extraCa()],
252
+ // tls.connect's option, passed through by the agent (not in the
253
+ // https.RequestOptions type).
254
+ ...({ secureContext: trustContext().context } as https.RequestOptions),
255
+ // Its own agent: a socket kept alive under one trust set must never
256
+ // be reused for a check under another.
257
+ agent: trustContext().agent,
205
258
  lookup: (_h, opts, cb) => {
206
259
  // Node may ask for all addresses (autoSelectFamily).
207
260
  if ((opts as { all?: boolean }).all)
@@ -374,40 +427,185 @@ export async function pingShell(
374
427
  cmd: string,
375
428
  extraEnv: Record<string, string> = {},
376
429
  cwd?: string,
430
+ opts: {
431
+ /** The answer budget (default CLI_SPAWN_TIMEOUT_MS). */
432
+ timeoutMs?: number;
433
+ } = {},
434
+ ): Promise<PingResult> {
435
+ // Bounded concurrency: a health cycle starts every check at once, and a
436
+ // distribution's shell checks are processes. Queued checks start their
437
+ // budget when they start, not while they wait.
438
+ const release = await shellSlots.acquire();
439
+ try {
440
+ return await runShellCheck(cmd, extraEnv, cwd, opts);
441
+ } finally {
442
+ release();
443
+ }
444
+ }
445
+
446
+ /** A counting semaphore. */
447
+ class Slots {
448
+ private free: number;
449
+ private waiting: (() => void)[] = [];
450
+ constructor(readonly size: number) {
451
+ this.free = size;
452
+ }
453
+ async acquire(): Promise<() => void> {
454
+ if (this.free > 0) this.free--;
455
+ else await new Promise<void>((r) => this.waiting.push(r));
456
+ let released = false;
457
+ return () => {
458
+ if (released) return;
459
+ released = true;
460
+ const next = this.waiting.shift();
461
+ if (next) next();
462
+ else this.free++;
463
+ };
464
+ }
465
+ }
466
+
467
+ /** Shell checks running at once: max(2, cpus/2). */
468
+ export const SHELL_CHECK_CONCURRENCY = Math.max(
469
+ 2,
470
+ Math.floor(os.cpus().length / 2),
471
+ );
472
+ const shellSlots = new Slots(SHELL_CHECK_CONCURRENCY);
473
+
474
+ function runShellCheck(
475
+ cmd: string,
476
+ extraEnv: Record<string, string>,
477
+ cwd: string | undefined,
478
+ opts: { timeoutMs?: number },
377
479
  ): Promise<PingResult> {
480
+ const budget = opts.timeoutMs ?? CLI_SPAWN_TIMEOUT_MS;
378
481
  return new Promise((resolve) => {
379
482
  let settled = false;
380
483
  const settle = (r: PingResult): void => {
381
484
  if (settled) return;
382
485
  settled = true;
486
+ clearTimeout(timer);
383
487
  clearTimeout(guard);
384
488
  resolve(r);
385
489
  };
386
- const proc = spawn(cmd, [], {
387
- shell: true,
388
- timeout: CLI_SPAWN_TIMEOUT_MS,
389
- env: { ...process.env, ...extraEnv },
390
- cwd,
391
- // Output is never read: piped stdio would block a chatty script once the
392
- // 64KB buffer fills, and 'close' would wait on grandchildren holding the
393
- // pipes open. Nothing consumes it — don't create it.
394
- stdio: "ignore",
490
+ // A check that has not answered is not a failed check: past the budget
491
+ // it is pending (ok null), never down. Under load a CLI-based check
492
+ // (`<cli> stack health`, a full tsx start) can need longer than the
493
+ // budget on a cold poll, and calling that "down" painted healthy stacks
494
+ // red until the next poll.
495
+ const noAnswer = (): PingResult => ({
496
+ ok: null,
497
+ pending: true,
498
+ error: `no answer within ${Math.round(budget / 1000)}s`,
395
499
  });
500
+ let proc: ReturnType<typeof spawn>;
501
+ try {
502
+ proc = spawn(cmd, [], {
503
+ shell: true,
504
+ env: { ...process.env, ...extraEnv },
505
+ cwd,
506
+ // Output is never read: piped stdio would block a chatty script once
507
+ // the 64KB buffer fills, and 'close' would wait on grandchildren
508
+ // holding the pipes open. Nothing consumes it — don't create it.
509
+ stdio: "ignore",
510
+ });
511
+ } catch (err) {
512
+ resolve({ ok: null, error: (err as Error).message });
513
+ return;
514
+ }
515
+ const timer = setTimeout(() => {
516
+ settle(noAnswer());
517
+ proc.kill("SIGTERM");
518
+ }, budget);
396
519
  // 'exit', NOT 'close': close waits for stdio to drain, which a killed
397
520
  // shell's surviving grandchildren can hold open forever — one wedged
398
521
  // health check then hangs every /api/health poll for good. The guard
399
- // timer backstops even a spawn timeout that never fires.
400
- const guard = setTimeout(
401
- () => settle({ ok: false, error: "health check timed out" }),
402
- CLI_SPAWN_TIMEOUT_MS + 1_000,
403
- );
404
- proc.on("exit", (code) =>
405
- settle(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }),
406
- );
407
- proc.on("error", (err) => settle({ ok: false, error: err.message }));
522
+ // timer backstops even a kill that never lands.
523
+ const guard = setTimeout(() => settle(noAnswer()), budget + 1_000);
524
+ proc.on("exit", (code, signal) => {
525
+ if (code === 0) return settle({ ok: true });
526
+ // Killed (by the budget above, or from outside): it never answered.
527
+ if (code === null)
528
+ return settle({ ...noAnswer(), error: `killed (${signal})` });
529
+ settle({ ok: false, error: `exit ${code}` });
530
+ });
531
+ // The check could not be started (e.g. EAGAIN under load): no verdict.
532
+ proc.on("error", (err) => settle({ ok: null, error: err.message }));
408
533
  });
409
534
  }
410
535
 
536
+ // ── Timeouts: pending first, down when repeated ─────────────────────────────
537
+ // A check that gets no answer within its budget (an HTTP timeout, a shell
538
+ // check past CLI_SPAWN_TIMEOUT_MS) has not completed: one such cycle is
539
+ // pending, not down. A cold first probe on a loaded machine (cold docker, a
540
+ // tsx-started `stack health`, cold TLS) can miss the budget while the service
541
+ // is fine, and reporting that as down painted healthy repos red until the
542
+ // next poll. Two consecutive timeouts are down: a hung service still shows.
543
+ // The web health row counts cycles (TimeoutStreaks); a one-shot caller
544
+ // (`<cli> status`, checkServiceHealth) re-probes once at once instead, so
545
+ // both say the same thing about the same service.
546
+
547
+ /** Whether a ping got no answer within its budget. */
548
+ export function timedOut(r: PingResult): boolean {
549
+ return r.pending === true || (r.ok === false && r.error === "timeout");
550
+ }
551
+
552
+ /** A timeout's verdict: pending on its first cycle, down on a repeat. */
553
+ function timeoutVerdict(r: PingResult, repeated: boolean): PingResult {
554
+ const error = r.error ?? "timeout";
555
+ return repeated
556
+ ? { ok: false, error: `${error} (twice in a row)` }
557
+ : { ok: null, pending: true, error };
558
+ }
559
+
560
+ /** Consecutive timed-out cycles per check, for a caller that polls. */
561
+ export class TimeoutStreaks {
562
+ private streak = new Map<string, number>();
563
+ /** `r` as this cycle's verdict for check `key`. */
564
+ judge(key: string, r: PingResult): PingResult {
565
+ if (!timedOut(r)) {
566
+ this.streak.delete(key);
567
+ return r;
568
+ }
569
+ const n = (this.streak.get(key) ?? 0) + 1;
570
+ this.streak.set(key, n);
571
+ return timeoutVerdict(r, n >= 2);
572
+ }
573
+ }
574
+
575
+ /** Probe once; on a timeout, probe again at once: down only if it times out twice. */
576
+ export async function pingTwiceOnTimeout(
577
+ probe: () => Promise<PingResult>,
578
+ ): Promise<PingResult> {
579
+ const first = await probe();
580
+ if (!timedOut(first)) return first;
581
+ const second = await probe();
582
+ return timedOut(second) ? timeoutVerdict(second, true) : second;
583
+ }
584
+
585
+ /**
586
+ * Run a shell health check. Den's own compose check (the `<cli> stack
587
+ * health` command it generates, `compose.cmd`) is answered in-process from
588
+ * the shared docker listing: no CLI process per check. Any other command
589
+ * (a distribution's own) runs as a shell command.
590
+ */
591
+ export async function pingShellCheck(
592
+ cmd: string,
593
+ extraEnv: Record<string, string>,
594
+ cwd: string | undefined,
595
+ compose: ComposeHealthTarget | null,
596
+ ): Promise<PingResult> {
597
+ if (!compose || cmd !== compose.cmd) return pingShell(cmd, extraEnv, cwd);
598
+ const v = await composeHealth(compose);
599
+ if (v.ok === true) return { ok: true };
600
+ if (v.ok === false) return { ok: false, error: v.detail };
601
+ return { ok: null, error: v.detail, ...(v.pending ? { pending: true } : {}) };
602
+ }
603
+
604
+ /** A ping result as a ServiceHealthResult health state. */
605
+ export function healthState(r: PingResult): ServiceHealthResult["health"] {
606
+ return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
607
+ }
608
+
411
609
  // ── Per-service health check ──────────────────────────────────────────────────
412
610
 
413
611
  /**
@@ -445,9 +643,9 @@ export async function checkServiceHealth(
445
643
  };
446
644
  }
447
645
 
448
- const composeHealth =
646
+ const compose =
449
647
  env === "local"
450
- ? composeHealthCmd(
648
+ ? composeHealthTarget(
451
649
  manifest,
452
650
  resolved.subsystem,
453
651
  resolved.repoName,
@@ -458,7 +656,7 @@ export async function checkServiceHealth(
458
656
  manifest,
459
657
  resolved.subsystem,
460
658
  env,
461
- composeHealth,
659
+ compose?.cmd ?? null,
462
660
  );
463
661
  const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
464
662
  runs: plan.placement.runs,
@@ -509,21 +707,22 @@ export async function checkServiceHealth(
509
707
  };
510
708
  }
511
709
 
512
- const result =
710
+ const result = await pingTwiceOnTimeout(() =>
513
711
  healthCfg.type === "shell"
514
- ? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
515
- : await pingHttp(
712
+ ? pingShellCheck(healthCfg.cmd, {}, resolved.repoPath, compose)
713
+ : pingHttp(
516
714
  healthCfg.url,
517
715
  env === "local" && plan.placement.runs !== "remote"
518
716
  ? HEALTH_CHECK_TIMEOUT_MS
519
717
  : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
520
718
  plan.expect,
521
- );
719
+ ),
720
+ );
522
721
 
523
722
  return {
524
723
  ...base,
525
724
  ...placed,
526
- health: result.ok ? "ok" : "error",
725
+ health: healthState(result),
527
726
  healthDetail:
528
727
  result.error ??
529
728
  (!result.ok && result.status !== undefined
@@ -10,7 +10,7 @@
10
10
  * repo's dev/up op, captured at spawn time and/or parsed on demand.
11
11
  */
12
12
 
13
- import { execFileSync, execSync } from "child_process";
13
+ import { execFile, execFileSync, execSync } from "child_process";
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { parse as parseYaml } from "yaml";
@@ -333,3 +333,85 @@ export function diffSpawnedContainers(
333
333
  for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
334
334
  return [...fresh];
335
335
  }
336
+
337
+ // ── Async variants (the web server) ──────────────────────────────────────────
338
+ // The sync calls above block the event loop for the whole `docker ps` /
339
+ // `opctl container ls` (hundreds of ms on a loaded machine). The server uses
340
+ // these instead; concurrent callers share one in-flight call.
341
+
342
+ function execOut(cmd: string, args: string[]): Promise<string | null> {
343
+ return new Promise((resolve) => {
344
+ execFile(
345
+ cmd,
346
+ args,
347
+ { encoding: "utf-8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 },
348
+ (err, stdout) => resolve(err ? null : stdout),
349
+ );
350
+ });
351
+ }
352
+
353
+ function shared<T>(run: () => Promise<T>): () => Promise<T> {
354
+ let inFlight: Promise<T> | null = null;
355
+ return () =>
356
+ (inFlight ??= run().finally(() => {
357
+ inFlight = null;
358
+ }));
359
+ }
360
+
361
+ /** Whether `opctl` is on PATH, without spawning a shell. Cached. */
362
+ export async function isOpctlAvailableAsync(): Promise<boolean> {
363
+ if (opctlAvailableCache !== null) return opctlAvailableCache;
364
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
365
+ if (!dir) continue;
366
+ try {
367
+ await fs.promises.access(path.join(dir, "opctl"), fs.constants.X_OK);
368
+ return (opctlAvailableCache = true);
369
+ } catch {
370
+ /* next */
371
+ }
372
+ }
373
+ return (opctlAvailableCache = false);
374
+ }
375
+
376
+ /** runningDockerNames, async; null when docker is unavailable. */
377
+ export const runningDockerNamesAsync = shared(
378
+ async (): Promise<string[] | null> => {
379
+ const out = await execOut("docker", ["ps", "--format", "{{.Names}}"]);
380
+ return out === null
381
+ ? null
382
+ : out
383
+ .split("\n")
384
+ .map((l) => l.trim())
385
+ .filter(Boolean);
386
+ },
387
+ );
388
+
389
+ /** listRunningOpctlContainers, async. */
390
+ export const listRunningOpctlContainersAsync = shared(
391
+ async (): Promise<{ name: string; base: string }[]> => {
392
+ const byBase = new Map<string, { name: string; base: string }>();
393
+ const [opctlOut, dockerNames] = await Promise.all([
394
+ isOpctlAvailableAsync().then((ok) =>
395
+ ok ? execOut("opctl", ["container", "ls"]) : null,
396
+ ),
397
+ runningDockerNamesAsync(),
398
+ ]);
399
+ if (opctlOut !== null) {
400
+ const lines = opctlOut.split("\n");
401
+ const firstToken = lines[0]?.trim().split(/\s+/)[0];
402
+ const body =
403
+ firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
404
+ for (const line of body) {
405
+ const name = line.trim().split(/\s+/)[0];
406
+ if (!name) continue;
407
+ const base = name.replace(/-[0-9a-f]{8}$/i, "");
408
+ byBase.set(base, { name, base });
409
+ }
410
+ }
411
+ for (const name of dockerNames ?? []) {
412
+ const m = name.match(DOCKER_OPCTL_NAME);
413
+ if (m && !byBase.has(m[1])) byBase.set(m[1], { name, base: m[1] });
414
+ }
415
+ return [...byBase.values()];
416
+ },
417
+ );
@@ -44,6 +44,8 @@ import {
44
44
  inspectOpctlContainer,
45
45
  isOpctlAvailable,
46
46
  listRunningOpctlContainers,
47
+ listRunningOpctlContainersAsync,
48
+ isOpctlAvailableAsync,
47
49
  } from "./opctl.js";
48
50
  import {
49
51
  getActiveWorkspace,
@@ -357,6 +359,24 @@ export function activeDaemonOwners(
357
359
  return (record?.workspaces ?? []).filter((w) => active.has(w));
358
360
  }
359
361
 
362
+ /** Whether workspace `name`'s current config lists `daemon`'s service. */
363
+ function workspaceContainsDaemon(
364
+ name: string,
365
+ daemon: Pick<DaemonRecord, "address" | "repoName">,
366
+ ): boolean {
367
+ const services = getWorkspaceByName(name)?.config.services ?? {};
368
+ return daemon.address in services || daemon.repoName in services;
369
+ }
370
+
371
+ /** Replace a running daemon's owners (re-derived from membership). */
372
+ function setDaemonOwners(address: string, workspaces: string[]): void {
373
+ loadDaemonsFromFile();
374
+ const existing = _daemonRegistry.get(address);
375
+ if (!existing) return;
376
+ _daemonRegistry.set(address, { ...existing, workspaces });
377
+ saveDaemonsFile(getDaemonsFile());
378
+ }
379
+
360
380
  /** Outcome of releasing one daemon during a workspace deactivation. */
361
381
  export interface DaemonRelease {
362
382
  address: string;
@@ -382,15 +402,19 @@ export async function releaseWorkspaceDaemons(
382
402
  const owned = listDaemons().filter((d) =>
383
403
  (d.workspaces ?? []).includes(workspace),
384
404
  );
385
- const active = new Set(getActiveWorkspaces());
405
+ const active = getActiveWorkspaces();
386
406
  const releases: DaemonRelease[] = [];
387
407
 
388
408
  for (const daemon of owned) {
389
- const remaining = activeDaemonOwners(daemon, active).filter(
390
- (w) => w !== workspace,
409
+ // Owners re-derived from the CURRENT membership, not the ones recorded at
410
+ // start: a repo moved into another workspace while its daemon ran belongs
411
+ // to that workspace now, and one moved out no longer belongs to this one.
412
+ // Stopped only when no active workspace still contains it.
413
+ const remaining = active.filter(
414
+ (w) => w !== workspace && workspaceContainsDaemon(w, daemon),
391
415
  );
392
416
  if (remaining.length > 0) {
393
- detachDaemonOwner(daemon.address, workspace);
417
+ setDaemonOwners(daemon.address, remaining);
394
418
  onLine(
395
419
  `detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
396
420
  );
@@ -639,6 +663,27 @@ export function pruneDeadDaemons(): string[] {
639
663
  const running = isOpctlAvailable()
640
664
  ? new Set(listRunningOpctlContainers().map((c) => c.base))
641
665
  : null;
666
+ return pruneAgainst(daemons, running);
667
+ }
668
+
669
+ /**
670
+ * pruneDeadDaemons without blocking: the container listing runs as async
671
+ * child processes (the web server's startup, which used to hold the event
672
+ * loop for the `docker ps`).
673
+ */
674
+ export async function pruneDeadDaemonsAsync(): Promise<string[]> {
675
+ const daemons = listDaemons();
676
+ if (daemons.length === 0) return [];
677
+ const running = (await isOpctlAvailableAsync())
678
+ ? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
679
+ : null;
680
+ return pruneAgainst(daemons, running);
681
+ }
682
+
683
+ function pruneAgainst(
684
+ daemons: DaemonRecord[],
685
+ running: Set<string> | null,
686
+ ): string[] {
642
687
  const pruned: string[] = [];
643
688
  for (const d of daemons) {
644
689
  try {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A checkout's `origin` URL, read from its git config file instead of by
3
+ * running `git`. Callers on the web server's request path (manifest
4
+ * resolution for every repo without its own manifest) used to spawn
5
+ * `git config --get remote.origin.url` synchronously, one repo after another:
6
+ * ~40-100ms each, which held the event loop for a second on a cold server.
7
+ *
8
+ * Same answer as `git -C <dir> config --get remote.origin.url` for ordinary
9
+ * checkouts and linked worktrees: the repo is found by walking up from `dir`
10
+ * to the first `.git` (a directory, or a `gitdir:` file), and a worktree's
11
+ * config is its common dir's.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+
16
+ /** The git directory for `start` (walking up), or null outside a repo. */
17
+ export function findGitDir(start: string): string | null {
18
+ let dir = path.resolve(start);
19
+ for (;;) {
20
+ const dotGit = path.join(dir, ".git");
21
+ try {
22
+ const st = fs.statSync(dotGit);
23
+ if (st.isDirectory()) return dotGit;
24
+ if (st.isFile()) {
25
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(
26
+ fs.readFileSync(dotGit, "utf-8"),
27
+ );
28
+ if (m) return path.resolve(dir, m[1]);
29
+ }
30
+ } catch {
31
+ /* no .git here: keep walking */
32
+ }
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir) return null;
35
+ dir = parent;
36
+ }
37
+ }
38
+
39
+ /** The `url` of `[remote "origin"]` in a git config file's text, or null. */
40
+ export function originUrlFromConfig(text: string): string | null {
41
+ let inOrigin = false;
42
+ for (const raw of text.split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
45
+ if (line.startsWith("[")) {
46
+ inOrigin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
47
+ continue;
48
+ }
49
+ if (!inOrigin) continue;
50
+ const m = /^url\s*=\s*(.*)$/i.exec(line);
51
+ if (m) {
52
+ const value = m[1].replace(/\s+[#;].*$/, "").trim();
53
+ return value.replace(/^"(.*)"$/, "$1") || null;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** `dir`'s origin URL, or null (not a checkout, or no origin). Never spawns. */
60
+ export function readOriginUrl(dir: string): string | null {
61
+ const gitDir = findGitDir(dir);
62
+ if (!gitDir) return null;
63
+ let common = gitDir;
64
+ try {
65
+ const rel = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
66
+ if (rel) common = path.resolve(gitDir, rel);
67
+ } catch {
68
+ /* not a linked worktree */
69
+ }
70
+ try {
71
+ return originUrlFromConfig(
72
+ fs.readFileSync(path.join(common, "config"), "utf-8"),
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ }