@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
@@ -15,7 +15,8 @@
15
15
 
16
16
  import * as fs from "fs";
17
17
  import * as path from "path";
18
- import { execFileSync } from "child_process";
18
+ import { readOriginUrl } from "../git/origin-url.js";
19
+ import { cachedRead } from "../read-cache.js";
19
20
  import { getDistRoot } from "../paths.js";
20
21
  import { parse as parseYaml } from "yaml";
21
22
  import type { RawManifest } from "../types/index.js";
@@ -48,21 +49,15 @@ const originCache = new Map<string, string | null>();
48
49
  /** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
49
50
  export function originRepoName(repoPath: string): string | null {
50
51
  if (originCache.has(repoPath)) return originCache.get(repoPath)!;
51
- let name: string | null = null;
52
- try {
53
- const url = execFileSync(
54
- "git",
55
- ["-C", repoPath, "config", "--get", "remote.origin.url"],
56
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
57
- ).trim();
58
- name =
59
- url
52
+ // Read from the git config file, never by spawning git: this runs on the
53
+ // web server's request path for every repo without its own manifest.
54
+ const url = readOriginUrl(repoPath);
55
+ const name = url
56
+ ? url
60
57
  .replace(/\.git$/, "")
61
58
  .split(/[/:]/)
62
- .pop() || null;
63
- } catch {
64
- /* not a git checkout, or no origin */
65
- }
59
+ .pop() || null
60
+ : null;
66
61
  originCache.set(repoPath, name);
67
62
  return name;
68
63
  }
@@ -169,6 +164,29 @@ export function parseManifestText(
169
164
  return parsed as Record<string, unknown>;
170
165
  }
171
166
 
167
+ /**
168
+ * Parsed manifests by file, reused while the file's text is unchanged. A
169
+ * single web request reads the same manifests several times (kind, health
170
+ * plan, stack names, edge links), and parsing YAML is the expensive part:
171
+ * it held the event loop ~100-200ms per /api/health run. Callers get their
172
+ * own copy, so mutating a result never leaks into the cache.
173
+ */
174
+ const parsedCache = new Map<
175
+ string,
176
+ { raw: string; parsed: Record<string, unknown> | null }
177
+ >();
178
+
179
+ function parseCached(
180
+ file: string,
181
+ raw: string,
182
+ ): Record<string, unknown> | null {
183
+ const hit = parsedCache.get(file);
184
+ if (hit && hit.raw === raw) return hit.parsed && structuredClone(hit.parsed);
185
+ const parsed = parseManifestText(file, raw);
186
+ parsedCache.set(file, { raw, parsed });
187
+ return parsed && structuredClone(parsed);
188
+ }
189
+
172
190
  /** The message for an override that has no base to apply to. */
173
191
  export function orphanOverrideMessage(): string {
174
192
  return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
@@ -198,7 +216,7 @@ function combine(
198
216
  ): ManifestLoad {
199
217
  const out: ManifestLoad = { manifest: null, layers: [], ...at };
200
218
  const parse = (file: string, raw: string): Record<string, unknown> => {
201
- const parsed = parseManifestText(file, raw);
219
+ const parsed = parseCached(file, raw);
202
220
  if (!parsed) throw new Error(`${file}: not a map`);
203
221
  return parsed;
204
222
  };
@@ -224,6 +242,12 @@ function combine(
224
242
 
225
243
  /** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
226
244
  export function loadManifest(repoPath: string): ManifestLoad {
245
+ // The web server resolves the same manifests many times per request; it
246
+ // reuses them for the read cache's TTL (see read-cache.ts).
247
+ return cachedRead(`manifest:${repoPath}`, () => loadManifestFresh(repoPath));
248
+ }
249
+
250
+ function loadManifestFresh(repoPath: string): ManifestLoad {
227
251
  const at = locate(repoPath);
228
252
  try {
229
253
  return combine(
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import {
4
4
  loadProfile,
5
+ onProfileCacheReset,
5
6
  PROFILE_FILE,
6
7
  readEnv,
7
8
  type ProductProfile,
@@ -74,6 +75,34 @@ function rootFromEnv(): string | null {
74
75
  * anchor fails loudly.
75
76
  */
76
77
  export function getDistRoot(): string {
78
+ // Resolved once per set of inputs: the walk and its den.yml checks used to
79
+ // run on every call (hundreds per web request, via product()). The inputs
80
+ // are the env vars it reads and the entry script; change any and it
81
+ // re-resolves. A failure is never cached.
82
+ const key = distRootKey();
83
+ if (distRootMemo?.key === key) return distRootMemo.root;
84
+ const root = resolveDistRoot();
85
+ distRootMemo = { key, root };
86
+ return root;
87
+ }
88
+
89
+ let distRootMemo: { key: string; root: string } | null = null;
90
+
91
+ /** Everything getDistRoot's answer depends on, as one string. */
92
+ function distRootKey(): string {
93
+ let key = `${process.argv[1] ?? ""}\0${process.env.DEN_PROFILE ?? ""}`;
94
+ for (const name in process.env)
95
+ if (name.endsWith("_ROOT")) key += `\0${name}=${process.env[name]}`;
96
+ return key;
97
+ }
98
+
99
+ /** Forget the resolved root (tests that rebuild a root in place). */
100
+ export function resetDistRootCache(): void {
101
+ distRootMemo = null;
102
+ }
103
+ onProfileCacheReset(resetDistRootCache);
104
+
105
+ function resolveDistRoot(): string {
77
106
  const fromEnv = rootFromEnv();
78
107
  if (fromEnv) return fromEnv;
79
108
  const start = entryDir();
@@ -128,6 +157,19 @@ function isProductRootVar(dir: string, key: string): boolean {
128
157
  * node_modules/@monster-orange/den; in Den's own checkout, its root.
129
158
  */
130
159
  export function getEngineRoot(): string {
160
+ const key = `${process.env.DEN_ENGINE_ROOT ?? ""}\0${process.argv[1] ?? ""}\0${process.cwd()}`;
161
+ if (engineRootMemo?.key === key) return engineRootMemo.root;
162
+ const root = resolveEngineRoot();
163
+ engineRootMemo = { key, root };
164
+ return root;
165
+ }
166
+
167
+ let engineRootMemo: { key: string; root: string } | null = null;
168
+ onProfileCacheReset(() => {
169
+ engineRootMemo = null;
170
+ });
171
+
172
+ function resolveEngineRoot(): string {
131
173
  const env = process.env.DEN_ENGINE_ROOT?.trim();
132
174
  if (env) return path.resolve(env);
133
175
  for (const start of [entryDir(), process.cwd()]) {
@@ -35,10 +35,16 @@ function withDerived(p: ProductProfile): Product {
35
35
  };
36
36
  }
37
37
 
38
+ let derived: { profile: ProductProfile; product: Product } | null = null;
39
+
38
40
  /** The running product. Never throws. */
39
41
  export function product(): Product {
40
42
  try {
41
- return withDerived(loadProfile(getDistRoot()));
43
+ const profile = loadProfile(getDistRoot());
44
+ // Same profile object (loadProfile caches it): same derived names.
45
+ if (derived?.profile !== profile)
46
+ derived = { profile, product: withDerived(profile) };
47
+ return derived.product;
42
48
  } catch {
43
49
  const pinned = process.env.DEN_PROFILE?.trim();
44
50
  try {
@@ -122,8 +122,16 @@ export function loadProfile(root: string): ProductProfile {
122
122
  return cached.profile;
123
123
  }
124
124
 
125
+ const resetHooks: (() => void)[] = [];
126
+
127
+ /** Run `hook` whenever resetProfileCache runs (caches derived from it). */
128
+ export function onProfileCacheReset(hook: () => void): void {
129
+ resetHooks.push(hook);
130
+ }
131
+
125
132
  export function resetProfileCache(): void {
126
133
  cached = null;
134
+ for (const hook of resetHooks) hook();
127
135
  }
128
136
 
129
137
  /** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A short-lived cache for the web server's repeated reads (manifests, the
3
+ * edge config, the derived graph), so a request does not re-stat and
4
+ * re-read the same files dozens of times, and polls a few seconds apart
5
+ * share one read.
6
+ *
7
+ * Off unless the web server enables it: a CLI command and the tests read
8
+ * fresh every time, as before. The server clears it on every mutating
9
+ * request, so a write made through the UI is seen by the very next read;
10
+ * an edit made outside the server (an editor, the CLI) is seen within the
11
+ * TTL. Values are cloned on the way out, so a caller that mutates its copy
12
+ * never changes what the next caller gets.
13
+ */
14
+
15
+ let ttlMs = 0;
16
+ const entries = new Map<string, { at: number; value: unknown }>();
17
+ /** cachedReadStale's values: kept across a clear, only marked stale. */
18
+ const stale = new Map<string, { at: number; value: unknown }>();
19
+ const refreshing = new Set<string>();
20
+
21
+ /** Turn the cache on (web server only). */
22
+ export function enableReadCache(ms: number): void {
23
+ ttlMs = ms;
24
+ }
25
+
26
+ /** Drop every cached read (after a mutation, or in tests). */
27
+ export function clearReadCache(): void {
28
+ entries.clear();
29
+ for (const e of stale.values()) e.at = 0;
30
+ }
31
+
32
+ function copy<T>(v: T): T {
33
+ return v !== null && typeof v === "object" ? structuredClone(v) : v;
34
+ }
35
+
36
+ /** `load()`'s value for `key`, reused for the TTL when the cache is on. */
37
+ export function cachedRead<T>(key: string, load: () => T): T {
38
+ if (ttlMs <= 0) return load();
39
+ const hit = entries.get(key);
40
+ if (hit && Date.now() - hit.at < ttlMs) return copy(hit.value as T);
41
+ const value = load();
42
+ entries.set(key, { at: Date.now(), value });
43
+ return copy(value);
44
+ }
45
+
46
+ /**
47
+ * Like cachedRead, but past the TTL it still answers with the last value at
48
+ * once and recomputes after the current request (setImmediate), so no
49
+ * request waits on the recompute. For expensive, slow-changing results
50
+ * (the derived dependency graph) whose staleness only affects ordering.
51
+ */
52
+ export function cachedReadStale<T>(
53
+ key: string,
54
+ maxAgeMs: number,
55
+ load: () => T,
56
+ ): T {
57
+ if (ttlMs <= 0) return load();
58
+ const hit = stale.get(key);
59
+ if (!hit) {
60
+ const value = load();
61
+ stale.set(key, { at: Date.now(), value });
62
+ return copy(value);
63
+ }
64
+ if (Date.now() - hit.at >= maxAgeMs && !refreshing.has(key)) {
65
+ refreshing.add(key);
66
+ setImmediate(() => {
67
+ try {
68
+ stale.set(key, { at: Date.now(), value: load() });
69
+ } catch {
70
+ /* keep the last good value */
71
+ } finally {
72
+ refreshing.delete(key);
73
+ }
74
+ });
75
+ }
76
+ return copy(hit.value as T);
77
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Compose-stack health answered in-process, from one `docker ps` listing
3
+ * shared by every check that runs at the same time.
4
+ *
5
+ * Den gives a compose repo with no declared check a `<cli> stack health`
6
+ * shell check (composeHealthCmd), one per manifest service. Spawned, each
7
+ * was a full CLI start: 17-19 tsx processes per health cycle for three
8
+ * stacks, which on a loaded machine (load 35-55) ran past their budget and
9
+ * read as down. The checks Den generates itself are now judged here, with
10
+ * the verdict `stack health` gives; a distribution's own shell checks still
11
+ * run as shell commands.
12
+ */
13
+ import { execFile } from "child_process";
14
+ import type { RawManifest } from "../types/index.js";
15
+ import { manifestStackName } from "./config.js";
16
+ import { composeHealthCmd } from "./manifest-commands.js";
17
+ import { projectName } from "./render.js";
18
+ import { judgeStackRows, type ComposeRow } from "./runtime.js";
19
+ import { resolveStackWorkspaces } from "./status.js";
20
+
21
+ export { judgeStackRows, type ComposeRow };
22
+
23
+ /** A Den-generated compose health check: what to look for in the listing. */
24
+ export interface ComposeHealthTarget {
25
+ /** The Compose project (`<stack>-<stack workspace>`). */
26
+ project: string;
27
+ /** A compose service to check on its own; unset: the whole stack. */
28
+ service?: string;
29
+ /** The equivalent `<cli> stack health` command (shown as the check). */
30
+ cmd: string;
31
+ }
32
+
33
+ /** A health verdict; `ok: null` when docker could not be asked. */
34
+ export interface ComposeVerdict {
35
+ ok: boolean | null;
36
+ detail: string;
37
+ /** With ok null: docker gave no answer within the budget. */
38
+ pending?: boolean;
39
+ }
40
+
41
+ /** The compose health check Den generates for this service, or null. */
42
+ export function composeHealthTarget(
43
+ manifest: RawManifest,
44
+ subsystem: string,
45
+ repoName: string,
46
+ workspace: string,
47
+ ): ComposeHealthTarget | null {
48
+ const cmd = composeHealthCmd(manifest, subsystem, repoName, workspace);
49
+ if (!cmd) return null;
50
+ const project = projectName(
51
+ manifestStackName(manifest, repoName),
52
+ resolveStackWorkspaces(workspace).stackWorkspace,
53
+ );
54
+ const service = cmd.includes(" --service ") ? subsystem : undefined;
55
+ return { project, ...(service ? { service } : {}), cmd };
56
+ }
57
+
58
+ /** Budget for the shared listing; past it the checks are pending. */
59
+ export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
60
+
61
+ type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
62
+
63
+ let inFlight: Promise<Listing> | null = null;
64
+
65
+ /**
66
+ * Every compose container, from one `docker ps -a`. Concurrent callers share
67
+ * the call in flight, so a health cycle lists once however many checks it has.
68
+ */
69
+ export function composeListing(): Promise<Listing> {
70
+ return (inFlight ??= listOnce().finally(() => {
71
+ inFlight = null;
72
+ }));
73
+ }
74
+
75
+ function listOnce(): Promise<Listing> {
76
+ return new Promise((done) => {
77
+ // Backstop: execFile reports only once the pipes close, which a killed
78
+ // process's children can hold open; the checks must not wait on that.
79
+ const guard = setTimeout(
80
+ () =>
81
+ resolve({
82
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
83
+ pending: true,
84
+ }),
85
+ COMPOSE_LISTING_TIMEOUT_MS + 500,
86
+ );
87
+ let settled = false;
88
+ const resolve = (l: Listing): void => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(guard);
92
+ done(l);
93
+ };
94
+ execFile(
95
+ "docker",
96
+ [
97
+ "ps",
98
+ "-a",
99
+ "--filter",
100
+ "label=com.docker.compose.project",
101
+ "--format",
102
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
103
+ ],
104
+ {
105
+ encoding: "utf-8",
106
+ timeout: COMPOSE_LISTING_TIMEOUT_MS,
107
+ maxBuffer: 16 * 1024 * 1024,
108
+ },
109
+ (err, stdout, stderr) => {
110
+ if (err) {
111
+ const killed = (err as { killed?: boolean }).killed === true;
112
+ return resolve(
113
+ killed
114
+ ? {
115
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
116
+ pending: true,
117
+ }
118
+ : {
119
+ error:
120
+ String(stderr || err.message).trim() || "docker ps failed",
121
+ },
122
+ );
123
+ }
124
+ const rows = stdout
125
+ .split("\n")
126
+ .filter(Boolean)
127
+ .map((line) => {
128
+ const [project = "", service = "", state = "", status = ""] =
129
+ line.split("\t");
130
+ return { project, service, state, status };
131
+ });
132
+ resolve({ rows });
133
+ },
134
+ );
135
+ });
136
+ }
137
+
138
+ /** The health of one Den-generated compose check, from the shared listing. */
139
+ export async function composeHealth(
140
+ target: ComposeHealthTarget,
141
+ ): Promise<ComposeVerdict> {
142
+ const listing = await composeListing();
143
+ if ("error" in listing)
144
+ return {
145
+ ok: null,
146
+ detail: `container state unknown: ${listing.error}`,
147
+ ...(listing.pending ? { pending: true } : {}),
148
+ };
149
+ return judgeStackRows(listing.rows, target.project, target.service);
150
+ }
@@ -364,39 +364,86 @@ export function stackLogs(
364
364
 
365
365
  /**
366
366
  * Health of a running stack: ok when it has containers, all are running, and
367
- * none reports a failing or pending Docker healthcheck.
367
+ * none reports a failing or pending Docker healthcheck. `ok: null` when
368
+ * docker could not be asked: container state unknown is not "not running".
368
369
  */
369
370
  export function stackHealth(
370
371
  ctx: StackContext,
371
372
  s: StackConfig,
372
373
  service?: string,
373
- ): { ok: boolean; detail: string } {
374
+ ): { ok: boolean | null; detail: string } {
374
375
  const project = projectName(s.name, ctx.workspace);
375
- const r = run("docker", [
376
- "ps",
377
- "-a",
378
- "--filter",
379
- `label=com.docker.compose.project=${project}`,
380
- ...(service
381
- ? ["--filter", `label=com.docker.compose.service=${service}`]
382
- : []),
383
- "--format",
384
- '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
385
- ]);
386
- if (r.code !== 0) return { ok: false, detail: r.err || "docker ps failed" };
387
- const rows = r.out.split("\n").filter(Boolean);
388
- if (!rows.length)
376
+ let r: ReturnType<typeof run>;
377
+ try {
378
+ r = run("docker", [
379
+ "ps",
380
+ "-a",
381
+ "--filter",
382
+ `label=com.docker.compose.project=${project}`,
383
+ ...(service
384
+ ? ["--filter", `label=com.docker.compose.service=${service}`]
385
+ : []),
386
+ "--format",
387
+ '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
388
+ ]);
389
+ } catch (e) {
390
+ return {
391
+ ok: null,
392
+ detail: `container state unknown: ${(e as Error).message}`,
393
+ };
394
+ }
395
+ if (r.code !== 0)
396
+ return {
397
+ ok: null,
398
+ detail: `container state unknown: ${r.err.trim() || "docker ps failed"}`,
399
+ };
400
+ const rows = r.out
401
+ .split("\n")
402
+ .filter(Boolean)
403
+ .map((line) => {
404
+ const [svc = "", state = "", status = ""] = line.split("\t");
405
+ return { project, service: svc, state, status };
406
+ });
407
+ return judgeStackRows(rows, project, service);
408
+ }
409
+
410
+ /** One compose container, as a `docker ps` listing reports it. */
411
+ export interface ComposeRow {
412
+ project: string;
413
+ service: string;
414
+ state: string;
415
+ status: string;
416
+ }
417
+
418
+ /**
419
+ * The verdict `stack health` gives for `project` (and `service`): ok when it
420
+ * has containers, all running, none with a failing or starting Docker
421
+ * healthcheck. Shared by the CLI and the in-process check (compose-health).
422
+ */
423
+ export function judgeStackRows(
424
+ rows: ComposeRow[],
425
+ project: string,
426
+ service?: string,
427
+ ): { ok: boolean; detail: string } {
428
+ const mine = rows.filter(
429
+ (r) => r.project === project && (!service || r.service === service),
430
+ );
431
+ if (!mine.length)
389
432
  return {
390
433
  ok: false,
391
434
  detail: `${project}${service ? `/${service}` : ""}: not running`,
392
435
  };
393
- const bad = rows.filter((row) => {
394
- const [, state, status] = row.split("\t");
395
- return (
396
- state !== "running" || /\((unhealthy|health: starting)\)/.test(status)
397
- );
398
- });
436
+ const bad = mine.filter(
437
+ (r) =>
438
+ r.state !== "running" ||
439
+ /\((unhealthy|health: starting)\)/.test(r.status),
440
+ );
399
441
  return bad.length
400
- ? { ok: false, detail: bad.join("; ") }
401
- : { ok: true, detail: `${rows.length} container(s) running` };
442
+ ? {
443
+ ok: false,
444
+ detail: bad
445
+ .map((r) => `${r.service}\t${r.state}\t${r.status}`)
446
+ .join("; "),
447
+ }
448
+ : { ok: true, detail: `${mine.length} container(s) running` };
402
449
  }
@@ -3,13 +3,18 @@
3
3
  * The CLI prints it; the web server serves it at /api/stacks (and through it
4
4
  * the MCP `stacks` tool).
5
5
  */
6
+ import { spawnSync } from "child_process";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { readCatalog } from "../environment-manager/catalog.js";
6
10
  import { readEdgeConfig } from "../edge/config.js";
7
11
  import { routeUrl } from "../edge/render.js";
8
12
  import {
9
13
  getFocusedWorkspaceName,
10
14
  getWorkspaceByName,
15
+ listWorkspaceServices,
11
16
  } from "../environment-manager/workspaces.js";
12
- import { readStacks } from "./config.js";
17
+ import { readManifestStacks, readStacks } from "./config.js";
13
18
  import { projectName, stackWorkspace } from "./render.js";
14
19
  import { stackHealth, type StackContext } from "./runtime.js";
15
20
 
@@ -29,7 +34,8 @@ export interface StackStatus {
29
34
  routes: StackRoute[];
30
35
  uses: string[];
31
36
  /** Container health (only when asked for: it runs `docker ps`). */
32
- health?: { ok: boolean; detail: string };
37
+ /** `ok: null`: docker could not be asked, so the state is unknown. */
38
+ health?: { ok: boolean | null; detail: string };
33
39
  }
34
40
 
35
41
  const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -91,9 +97,112 @@ export function stackStatuses(
91
97
  try {
92
98
  status.health = healthOf(ctx, s);
93
99
  } catch (e) {
94
- status.health = { ok: false, detail: (e as Error).message };
100
+ status.health = { ok: null, detail: (e as Error).message };
95
101
  }
96
102
  }
97
103
  return status;
98
104
  });
99
105
  }
106
+
107
+ /** A running Compose project started from one of a workspace's repos. */
108
+ export interface WorkspaceStack {
109
+ /** The Den stack name, or the project name for a stack the repo runs. */
110
+ name: string;
111
+ repo: string;
112
+ project: string;
113
+ }
114
+
115
+ /** A running Compose project and the directory it was started from. */
116
+ export interface RunningProject {
117
+ project: string;
118
+ /** `com.docker.compose.project.working_dir`; "" when unlabelled. */
119
+ workingDir: string;
120
+ }
121
+
122
+ /**
123
+ * The Compose projects with running containers, from one `docker ps`; null
124
+ * when docker can't be asked (not installed, daemon down, too slow).
125
+ */
126
+ export function runningComposeProjects(): RunningProject[] | null {
127
+ const r = spawnSync(
128
+ "docker",
129
+ [
130
+ "ps",
131
+ "--format",
132
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}',
133
+ ],
134
+ { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
135
+ );
136
+ if (r.error || r.status !== 0) return null;
137
+ const seen = new Map<string, RunningProject>();
138
+ for (const line of (r.stdout ?? "").split("\n")) {
139
+ const [project = "", workingDir = ""] = line.trim().split("\t");
140
+ if (project && !seen.has(project))
141
+ seen.set(project, { project, workingDir: workingDir.trim() });
142
+ }
143
+ return [...seen.values()];
144
+ }
145
+
146
+ /** `p` and its realpath (compose may record either form). */
147
+ function pathForms(p: string): string[] {
148
+ try {
149
+ const real = fs.realpathSync(p);
150
+ return real === p ? [p] : [p, real];
151
+ } catch {
152
+ return [p];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * `workspace`'s running compose stacks: what `workspace deactivate` leaves
158
+ * up, since it releases only the daemons Den tracks. A project counts when
159
+ * it is a Den stack one of the workspace's repos declares (`compose:`, named
160
+ * by projectName), or when Compose started it from inside one of the
161
+ * workspace's checkouts (its working_dir label): the stacks a repo runs
162
+ * itself, e.g. from `make up`. Null when docker could not be asked.
163
+ */
164
+ export function runningWorkspaceStacks(
165
+ workspace: string,
166
+ running: () => RunningProject[] | null = runningComposeProjects,
167
+ ): WorkspaceStack[] | null {
168
+ const ws = getWorkspaceByName(workspace);
169
+ if (!ws) return [];
170
+ const denStacks = new Map<string, { name: string; repo: string }>();
171
+ const checkouts: { repo: string; forms: string[] }[] = [];
172
+ try {
173
+ const { stackWorkspace: suffix } = resolveStackWorkspaces(workspace);
174
+ for (const s of Object.values(readManifestStacks(workspace)))
175
+ denStacks.set(projectName(s.name, suffix), {
176
+ name: s.name,
177
+ repo: s.repo,
178
+ });
179
+ const seen = new Set<string>();
180
+ for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name))
181
+ if (svc.repoPath && !seen.has(svc.repoPath)) {
182
+ seen.add(svc.repoPath);
183
+ checkouts.push({ repo: svc.repoName, forms: pathForms(svc.repoPath) });
184
+ }
185
+ } catch {
186
+ return null; // an unreadable manifest or workspace name: can't tell
187
+ }
188
+ if (denStacks.size === 0 && checkouts.length === 0) return [];
189
+ const up = running();
190
+ if (!up) return null;
191
+ const inside = (dir: string, root: string): boolean =>
192
+ dir === root || dir.startsWith(`${root}${path.sep}`);
193
+ const out: WorkspaceStack[] = [];
194
+ for (const { project, workingDir } of up) {
195
+ const den = denStacks.get(project);
196
+ if (den) {
197
+ out.push({ ...den, project });
198
+ continue;
199
+ }
200
+ if (!workingDir) continue;
201
+ const dirs = pathForms(workingDir);
202
+ const owner = checkouts.find((c) =>
203
+ c.forms.some((root) => dirs.some((d) => inside(d, root))),
204
+ );
205
+ if (owner) out.push({ name: project, repo: owner.repo, project });
206
+ }
207
+ return out;
208
+ }
@@ -13,6 +13,7 @@ export function healthLabelText(health: HealthState): string {
13
13
  export function formatHealthLabel(health: HealthState): string {
14
14
  if (health === "ok") return chalk.green("● ok");
15
15
  if (health === "error") return chalk.red("● down");
16
+ if (health === "pending") return chalk.gray("● pending");
16
17
  return chalk.gray("● unknown");
17
18
  }
18
19