@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.
- package/CHANGELOG.md +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
400
|
-
const guard = setTimeout(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
646
|
+
const compose =
|
|
449
647
|
env === "local"
|
|
450
|
-
?
|
|
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
|
-
|
|
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
|
-
?
|
|
515
|
-
:
|
|
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
|
|
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 =
|
|
405
|
+
const active = getActiveWorkspaces();
|
|
386
406
|
const releases: DaemonRelease[] = [];
|
|
387
407
|
|
|
388
408
|
for (const daemon of owned) {
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
+
}
|