@monster-orange/den 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,6 @@ import type {
39
39
  import { getRunDonePayload } from "./perf-report.js";
40
40
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
41
41
  import {
42
- readWorkspacesFile,
43
42
  getActiveWorkspace,
44
43
  getActiveWorkspaces,
45
44
  getFocusedWorkspaceName,
@@ -49,10 +48,7 @@ import {
49
48
  resolveDepAddress,
50
49
  parseAddress,
51
50
  readManifestAnyBranch,
52
- noAutostartRepos,
53
51
  } from "../../shared/environment-manager/workspaces.js";
54
- import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
55
- import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
56
52
  import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
57
53
  import {
58
54
  resolveHistorySource,
@@ -65,6 +61,7 @@ import {
65
61
  import {
66
62
  pingHttp,
67
63
  pingShell,
64
+ prewarmHealthTls,
68
65
  } from "../../shared/environment-manager/health.js";
69
66
  import {
70
67
  runServiceCommand,
@@ -74,7 +71,7 @@ import {
74
71
  unregisterDaemon,
75
72
  checkServiceHealth,
76
73
  loadDaemonsFromFile,
77
- pruneDeadDaemons,
74
+ pruneDeadDaemonsAsync,
78
75
  isInteractiveServiceCommand,
79
76
  rewriteLegacyOpctlKillCommandForContainer,
80
77
  shouldDetachServiceCommand,
@@ -142,6 +139,10 @@ import {
142
139
  import { handleReadonlyRoutes } from "./readonly-routes.js";
143
140
  import { serveUiAsset } from "./ui-assets.js";
144
141
  import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
142
+ import { singleFlight } from "./single-flight.js";
143
+ import { graphOrder } from "./graph-order.js";
144
+ import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
145
+ import { buildServicesList, type ServicesList } from "./services-list.js";
145
146
 
146
147
  const __filename = fileURLToPath(import.meta.url);
147
148
  const __dirname = path.dirname(__filename);
@@ -209,6 +210,17 @@ function viewerRewrite(
209
210
 
210
211
  const HEALTH_CACHE_TTL_MS = 8_000;
211
212
  const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
213
+ /** The full-env health run in flight per cache key: concurrent polls join it. */
214
+ const healthInFlight = new Map<string, Promise<HealthResult[]>>();
215
+ /** /api/services builds, shared by concurrent polls and reused briefly. */
216
+ /** The startup sweep of stale daemon records (see below the routes). */
217
+ let startupPrune: Promise<void> = Promise.resolve();
218
+ const SERVICES_CACHE_TTL_MS = 2_000;
219
+ // Manifests, the edge config and the derived graph order are reused across
220
+ // a request and the polls after it (cleared by any mutating request).
221
+ const READ_CACHE_TTL_MS = 5_000;
222
+ enableReadCache(READ_CACHE_TTL_MS);
223
+ const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
212
224
  /** A workspace-scoped run covers a different service set than the default one. */
213
225
  function healthCacheKey(env: Environment, workspace: string | null): string {
214
226
  return `${env}|${workspace ?? "*"}`;
@@ -534,6 +546,15 @@ async function handleRequest(
534
546
  ): Promise<void> {
535
547
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
536
548
  const pathname = url.pathname;
549
+ // A mutating request may change what /api/services would list (exec and
550
+ // sync stream over GET, for EventSource).
551
+ if (
552
+ (req.method !== "GET" && req.method !== "HEAD") ||
553
+ /\/(exec|sync)$/.test(pathname)
554
+ ) {
555
+ servicesCache.clear();
556
+ clearReadCache();
557
+ }
537
558
 
538
559
  // ── Request logging ────────────────────────────────────────────────────────
539
560
  // /api/health fires on the UI's 5s polling interval forever — it dominates
@@ -1019,9 +1040,7 @@ async function handleRequest(
1019
1040
  // direction as the graph (and `den status`). orderForDown is the reverse
1020
1041
  // of the deps-first startup order.
1021
1042
  const rank = new Map(
1022
- orderForDown(
1023
- deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
1024
- ).map((repo, i) => [repo, i]),
1043
+ graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
1025
1044
  );
1026
1045
  const sortedEntries = [...entries].sort((a, b) => {
1027
1046
  const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
@@ -1143,9 +1162,17 @@ async function handleRequest(
1143
1162
  if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1144
1163
  return sendJson(res, hit.results.map(forViewer));
1145
1164
  }
1146
- const results = await computeHealth(env, null, undefined, wsParam);
1147
- healthCache.set(cacheKey, { results, at: Date.now() });
1148
- return sendJson(res, results.map(forViewer));
1165
+ let run = healthInFlight.get(cacheKey);
1166
+ if (!run) {
1167
+ run = computeHealth(env, null, undefined, wsParam)
1168
+ .then((results) => {
1169
+ healthCache.set(cacheKey, { results, at: Date.now() });
1170
+ return results;
1171
+ })
1172
+ .finally(() => healthInFlight.delete(cacheKey));
1173
+ healthInFlight.set(cacheKey, run);
1174
+ }
1175
+ return sendJson(res, (await run).map(forViewer));
1149
1176
  }
1150
1177
 
1151
1178
  // Same checks, streamed: each result is sent the moment it lands so the fast
@@ -1196,169 +1223,15 @@ async function handleRequest(
1196
1223
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1197
1224
  // Optional: caller may request services for a specific (non-active) workspace.
1198
1225
  const wsOverride = url.searchParams.get("workspace") ?? null;
1199
-
1200
- // Conventional lifecycle locally: `down` is opctl-conventional (stops
1201
- // running containers with no declared command, no-ops gracefully), and
1202
- // `restart` is orchestrated by the exec runner as down → verify → up — so
1203
- // every service with an `up` can offer both even when the manifest doesn't
1204
- // declare them.
1205
- const withDown = (cmds: string[]): string[] => {
1206
- if (env !== "local") return cmds;
1207
- const out = [...cmds];
1208
- if (!out.includes("down")) out.push("down");
1209
- if (!out.includes("restart") && out.includes("up")) out.push("restart");
1210
- return out;
1211
- };
1212
-
1213
- // Legacy: discovered repos from .den/ symlinks / registered-repos.json
1214
- const legacyRepos = discoverRepos();
1215
- const legacyEntries = legacyRepos.flatMap((repo) => {
1216
- const manifestLayers = loadManifest(repo.repoPath).layers;
1217
- return Object.entries(repo.manifest.services ?? {}).map(
1218
- ([svcName, svc]) => ({
1219
- repo: repo.repoName,
1220
- service: svcName,
1221
- address: `${repo.repoName}/${svcName}`,
1222
- repoPath: repo.repoPath,
1223
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1224
- logs: svc.logs?.[env] ?? null,
1225
- mode: "dev-managed" as const,
1226
- branch: null as string | null,
1227
- fromCatalog: false,
1228
- isEntrypoint: repo.manifest.entrypoint === svcName,
1229
- autostart: true, // legacy discovery has no workspace entry to toggle
1230
- deps: (svc.depends_on ?? []).map(depService),
1231
- cloned: true, // legacy discovery only ever finds on-disk repos
1232
- manifestLayers,
1233
- ...servicePlacement(repo.manifest, svcName, env),
1234
- }),
1235
- );
1236
- });
1237
-
1238
- // Catalog entries (workspace-aware)
1239
- const catalog = readCatalog();
1240
- const wsFile = wsOverride ? readWorkspacesFile() : null;
1241
- const ws = wsOverride
1242
- ? wsFile?.workspaces[wsOverride]
1243
- ? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
1244
- : null
1245
- : getActiveWorkspace();
1246
- const getCurrentBranch = (repoPath: string): string | null => {
1247
- try {
1248
- return execSync("git rev-parse --abbrev-ref HEAD", {
1249
- cwd: repoPath,
1250
- encoding: "utf-8",
1251
- stdio: ["ignore", "pipe", "ignore"],
1252
- timeout: GIT_REMOTE_TIMEOUT_MS,
1253
- }).trim();
1254
- } catch {
1255
- return null;
1256
- }
1257
- };
1258
-
1259
- const noAutostart = ws
1260
- ? noAutostartRepos(ws.config, catalog)
1261
- : new Set<string>();
1262
- const daemonOwners = new Map(
1263
- listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
1264
- );
1265
- // Modules and libraries have no runtime: no card, health or commands.
1266
- // They show as contributors on the Applications tab instead.
1267
- const catalogEntries = ws
1268
- ? listWorkspaceServices(ws.config, catalog, ws.name)
1269
- .filter((r) => hasRuntime(repoKind(r.repoPath)))
1270
- .flatMap((resolved) => {
1271
- // The manifest and the layers it was built from: repo or held,
1272
- // plus override when manifests/<repo>.override.yml applied.
1273
- const { manifest, layers: manifestLayers } = loadManifest(
1274
- resolved.repoPath,
1275
- );
1276
- const svc = manifest?.services?.[resolved.subsystem];
1277
- if (!manifest || !svc) {
1278
- // Workspace membership is config-driven: a managed entry
1279
- // whose clone isn't on disk yet (or a checkout without a usable
1280
- // manifest) must still render everywhere — flagged `cloned:false`
1281
- // so the UI can offer Sync/clone instead of dropping the system.
1282
- // `branch` comes from the workspace entry; never probe git on a
1283
- // path that may not exist.
1284
- return [
1285
- {
1286
- repo: resolved.repoName,
1287
- service: resolved.subsystem,
1288
- address: resolved.address,
1289
- repoPath: resolved.repoPath,
1290
- commands: [] as string[],
1291
- logs: null,
1292
- mode: resolved.mode,
1293
- branch: resolved.branch,
1294
- fromCatalog: true,
1295
- isEntrypoint: false,
1296
- autostart: !noAutostart.has(resolved.repoName),
1297
- owners: daemonOwners.get(resolved.address) ?? [],
1298
- deps: [] as string[],
1299
- cloned: fs.existsSync(resolved.repoPath),
1300
- manifestLayers,
1301
- ...servicePlacement(null, resolved.subsystem, env),
1302
- },
1303
- ];
1304
- }
1305
- return [
1306
- {
1307
- repo: resolved.repoName,
1308
- service: resolved.subsystem,
1309
- address: resolved.address,
1310
- repoPath: resolved.repoPath,
1311
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1312
- logs: svc.logs?.[env] ?? null,
1313
- mode: resolved.mode,
1314
- branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
1315
- fromCatalog: true,
1316
- isEntrypoint: manifest.entrypoint === resolved.subsystem,
1317
- autostart: !noAutostart.has(resolved.repoName),
1318
- owners: daemonOwners.get(resolved.address) ?? [],
1319
- deps: (svc.depends_on ?? []).map((d) =>
1320
- resolveDepAddress(depService(d), catalog, ws.config, ws.name),
1321
- ),
1322
- cloned: true,
1323
- manifestLayers,
1324
- ...servicePlacement(manifest, resolved.subsystem, env),
1325
- },
1326
- ];
1327
- })
1328
- : [];
1329
-
1330
- // Merge: catalog entries take precedence over legacy for same repo.
1331
- // When a workspace filter is active, also exclude legacy entries not in
1332
- // the workspace (legacy discovery is not workspace-scoped).
1333
- const catalogRepos = new Set(Object.keys(catalog));
1334
- const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
1335
- const merged = [
1336
- ...legacyEntries.filter((e) => {
1337
- if (catalogRepos.has(e.repo)) return false;
1338
- if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
1339
- return true;
1340
- }),
1341
- ...catalogEntries,
1342
- ];
1343
-
1344
- // Attach the dependency-graph position so consumers can present systems
1345
- // top-down (entry repo first, infra-stack last) — the same order the
1346
- // health row and `den status` use. Added as a FIELD rather than by
1347
- // re-sorting the array, because four pages consume this payload and some
1348
- // rely on the current order.
1349
- const graphRank = new Map(
1350
- orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
1351
- (repo, i) => [repo, i],
1352
- ),
1353
- );
1354
- const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
1226
+ // Every open tab polls this: concurrent requests share one build, and a
1227
+ // settled one is reused briefly (any mutating request clears it).
1355
1228
  return sendJson(
1356
1229
  res,
1357
- merged.map((e) => ({
1358
- ...e,
1359
- graphRank: graphRank.get(e.repo) ?? Number.MAX_SAFE_INTEGER,
1360
- infra: infraRepo !== null && e.repo === infraRepo,
1361
- })),
1230
+ await servicesCache.get(
1231
+ `${env}|${wsOverride ?? ""}`,
1232
+ () => buildServicesList(env, wsOverride),
1233
+ url.searchParams.get("refresh") === "1",
1234
+ ),
1362
1235
  );
1363
1236
  }
1364
1237
 
@@ -1836,6 +1709,7 @@ async function handleRequest(
1836
1709
 
1837
1710
  // ── API: running daemons list ──────────────────────────────────────────────
1838
1711
  if (pathname === "/api/service/daemons") {
1712
+ await startupPrune;
1839
1713
  const daemons = listDaemons().map((d) => ({
1840
1714
  address: d.address,
1841
1715
  repoName: d.repoName,
@@ -3206,15 +3080,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
3206
3080
  // service stopped outside den — otherwise reopen as tabs in the web UI and
3207
3081
  // clutter the shutdown prompt. Container liveness (not health) is the signal: a
3208
3082
  // service's shared-infra health check stays green even when the service is down.
3209
- // Runs synchronously at startup so /api/service/daemons never hands the UI a
3210
- // stale entry on its first load.
3083
+ // The container listing runs as async child processes (a sync `docker ps`
3084
+ // here held the event loop at startup); /api/service/daemons waits for it,
3085
+ // so the UI never gets a stale entry on its first load.
3211
3086
  loadDaemonsFromFile();
3212
- const prunedDaemons = pruneDeadDaemons();
3213
- if (prunedDaemons.length > 0) {
3214
- console.error(
3215
- `Cleared ${prunedDaemons.length} stale daemon(s): ${prunedDaemons.join(", ")}`,
3216
- );
3217
- }
3087
+ startupPrune = pruneDeadDaemonsAsync()
3088
+ .then((pruned) => {
3089
+ if (pruned.length > 0)
3090
+ console.error(
3091
+ `Cleared ${pruned.length} stale daemon(s): ${pruned.join(", ")}`,
3092
+ );
3093
+ })
3094
+ .catch((e: unknown) => {
3095
+ console.error(`[server] stale-daemon sweep failed: ${String(e)}`);
3096
+ });
3218
3097
 
3219
3098
  // Background recovery: after a short delay (long enough for services to be
3220
3099
  // responding), health-check each loaded daemon. Removal requires strong
@@ -3272,6 +3151,16 @@ process.on("unhandledRejection", (reason) => {
3272
3151
  console.error("[server] unhandled rejection (continuing):", reason);
3273
3152
  });
3274
3153
 
3154
+ // Build, before serving, what the first health poll and /api/services would
3155
+ // otherwise build inside a request: the dependency-graph order and the HTTPS
3156
+ // checks' trust context (both synchronous).
3157
+ try {
3158
+ for (const ws of getActiveWorkspaces()) graphOrder(ws);
3159
+ prewarmHealthTls();
3160
+ } catch (e) {
3161
+ serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
3162
+ }
3163
+
3275
3164
  // Bind loopback only — this UI runs privileged local actions (sudo-backed
3276
3165
  // service commands), so it must not be reachable from the LAN. Opt out with
3277
3166
  // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
@@ -3375,7 +3264,20 @@ async function stopDaemons(): Promise<void> {
3375
3264
 
3376
3265
  async function handleShutdown(signal: string): Promise<void> {
3377
3266
  clearSudoPassword();
3378
- const daemons = listDaemons();
3267
+ // The registry lives in the state dir, which is resolved from the
3268
+ // distribution root on every call: if that can no longer be found (den.yml
3269
+ // moved or removed while the server ran), still exit. Never product() here:
3270
+ // it resolves the same root.
3271
+ let daemons: ReturnType<typeof listDaemons>;
3272
+ try {
3273
+ daemons = listDaemons();
3274
+ } catch (e) {
3275
+ console.error(
3276
+ `\n[server] ${signal}: cannot read the running-services registry ` +
3277
+ `(${(e as Error).message}); exiting and leaving any services running.`,
3278
+ );
3279
+ process.exit(0);
3280
+ }
3379
3281
  if (daemons.length === 0) {
3380
3282
  process.exit(0);
3381
3283
  }
@@ -3419,16 +3321,29 @@ async function handleShutdown(signal: string): Promise<void> {
3419
3321
  process.exit(0);
3420
3322
  }
3421
3323
 
3324
+ /**
3325
+ * Run the shutdown for `signal`. A failure still ends the process: the guard
3326
+ * below ignores every later signal, so a shutdown that rejected would leave
3327
+ * the server running and deaf to SIGTERM (a service manager then waits out
3328
+ * its timeout and SIGKILLs it).
3329
+ */
3330
+ function shutdownOn(signal: string): void {
3331
+ handleShutdown(signal).catch((e: unknown) => {
3332
+ console.error(`[server] ${signal}: shutdown failed (exiting):`, e);
3333
+ process.exit(1);
3334
+ });
3335
+ }
3336
+
3422
3337
  let _shuttingDown = false;
3423
3338
  process.on("SIGINT", () => {
3424
3339
  if (_shuttingDown) return;
3425
3340
  _shuttingDown = true;
3426
3341
  console.log(""); // newline after ^C
3427
- void handleShutdown("SIGINT");
3342
+ shutdownOn("SIGINT");
3428
3343
  });
3429
3344
 
3430
3345
  process.on("SIGTERM", () => {
3431
3346
  if (_shuttingDown) return;
3432
3347
  _shuttingDown = true;
3433
- void handleShutdown("SIGTERM");
3348
+ shutdownOn("SIGTERM");
3434
3349
  });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The /api/services payload: every service of the legacy-discovered repos and
3
+ * of the requested (or active) workspace, with commands, branch, deps and
4
+ * graph rank.
5
+ *
6
+ * Built off the request path's critical section: the per-repo branch lookups
7
+ * (`git rev-parse`, ~40ms each) run as async child processes in parallel
8
+ * instead of one execSync after another. With ~a dozen repos the sequential
9
+ * version held the event loop for 0.5-1.5s, so every other request (even
10
+ * /api/server-info, which answers in ~1ms) waited behind it.
11
+ */
12
+ import { execFile } from "child_process";
13
+ import * as fs from "fs";
14
+ import type { Environment } from "../../shared/types/index.js";
15
+ import { discoverRepos } from "../../pillars/smoke/discover.js";
16
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
17
+ import {
18
+ getActiveWorkspace,
19
+ listWorkspaceServices,
20
+ noAutostartRepos,
21
+ readWorkspacesFile,
22
+ resolveDepAddress,
23
+ } from "../../shared/environment-manager/workspaces.js";
24
+ import { graphOrder } from "./graph-order.js";
25
+ import { listDaemons } from "../../shared/environment-manager/service-runner.js";
26
+ import { loadManifest } from "../../shared/manifest/read-manifest.js";
27
+ import { servicePlacement } from "../../shared/manifest/placement.js";
28
+ import { depService } from "../../shared/manifest/deps.js";
29
+ import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
30
+ import { readDenConfig } from "../../shared/den-config.js";
31
+ import { GIT_REMOTE_TIMEOUT_MS } from "../../shared/constants.js";
32
+
33
+ /** `repoPath`'s checked-out branch, or null (not a checkout, git failed). */
34
+ export function currentBranch(repoPath: string): Promise<string | null> {
35
+ return new Promise((resolve) => {
36
+ execFile(
37
+ "git",
38
+ ["rev-parse", "--abbrev-ref", "HEAD"],
39
+ { cwd: repoPath, encoding: "utf-8", timeout: GIT_REMOTE_TIMEOUT_MS },
40
+ (err, stdout) => resolve(err ? null : stdout.trim()),
41
+ );
42
+ });
43
+ }
44
+
45
+ export async function buildServicesList(
46
+ env: Environment,
47
+ wsOverride: string | null,
48
+ ) {
49
+ // Conventional lifecycle locally: `down` is opctl-conventional (stops
50
+ // running containers with no declared command, no-ops gracefully), and
51
+ // `restart` is orchestrated by the exec runner as down → verify → up — so
52
+ // every service with an `up` can offer both even when the manifest doesn't
53
+ // declare them.
54
+ const withDown = (cmds: string[]): string[] => {
55
+ if (env !== "local") return cmds;
56
+ const out = [...cmds];
57
+ if (!out.includes("down")) out.push("down");
58
+ if (!out.includes("restart") && out.includes("up")) out.push("restart");
59
+ return out;
60
+ };
61
+
62
+ // Legacy: discovered repos from .den/ symlinks / registered-repos.json
63
+ const legacyRepos = discoverRepos();
64
+ const legacyEntries = legacyRepos.flatMap((repo) => {
65
+ const manifestLayers = loadManifest(repo.repoPath).layers;
66
+ return Object.entries(repo.manifest.services ?? {}).map(
67
+ ([svcName, svc]) => ({
68
+ repo: repo.repoName,
69
+ service: svcName,
70
+ address: `${repo.repoName}/${svcName}`,
71
+ repoPath: repo.repoPath,
72
+ commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
73
+ logs: svc.logs?.[env] ?? null,
74
+ mode: "dev-managed" as const,
75
+ branch: null as string | null,
76
+ fromCatalog: false,
77
+ isEntrypoint: repo.manifest.entrypoint === svcName,
78
+ autostart: true, // legacy discovery has no workspace entry to toggle
79
+ deps: (svc.depends_on ?? []).map(depService),
80
+ cloned: true, // legacy discovery only ever finds on-disk repos
81
+ manifestLayers,
82
+ ...servicePlacement(repo.manifest, svcName, env),
83
+ }),
84
+ );
85
+ });
86
+
87
+ // Catalog entries (workspace-aware)
88
+ const catalog = readCatalog();
89
+ const wsFile = wsOverride ? readWorkspacesFile() : null;
90
+ const ws = wsOverride
91
+ ? wsFile?.workspaces[wsOverride]
92
+ ? { name: wsOverride, config: wsFile.workspaces[wsOverride] }
93
+ : null
94
+ : getActiveWorkspace();
95
+
96
+ // One lookup per checkout, all in flight at once; filled in below.
97
+ const branches = new Map<string, Promise<string | null>>();
98
+ const branchOf = (repoPath: string): Promise<string | null> => {
99
+ let p = branches.get(repoPath);
100
+ if (!p) branches.set(repoPath, (p = currentBranch(repoPath)));
101
+ return p;
102
+ };
103
+
104
+ const noAutostart = ws
105
+ ? noAutostartRepos(ws.config, catalog)
106
+ : new Set<string>();
107
+ const daemonOwners = new Map(
108
+ listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
109
+ );
110
+ // Modules and libraries have no runtime: no card, health or commands.
111
+ // They show as contributors on the Applications tab instead.
112
+ const pendingBranches: Promise<void>[] = [];
113
+ const catalogEntries = ws
114
+ ? listWorkspaceServices(ws.config, catalog, ws.name)
115
+ .filter((r) => hasRuntime(repoKind(r.repoPath)))
116
+ .flatMap((resolved) => {
117
+ // The manifest and the layers it was built from: repo or held,
118
+ // plus override when manifests/<repo>.override.yml applied.
119
+ const { manifest, layers: manifestLayers } = loadManifest(
120
+ resolved.repoPath,
121
+ );
122
+ const svc = manifest?.services?.[resolved.subsystem];
123
+ if (!manifest || !svc) {
124
+ // Workspace membership is config-driven: a managed entry
125
+ // whose clone isn't on disk yet (or a checkout without a usable
126
+ // manifest) must still render everywhere — flagged `cloned:false`
127
+ // so the UI can offer Sync/clone instead of dropping the system.
128
+ // `branch` comes from the workspace entry; never probe git on a
129
+ // path that may not exist.
130
+ return [
131
+ {
132
+ repo: resolved.repoName,
133
+ service: resolved.subsystem,
134
+ address: resolved.address,
135
+ repoPath: resolved.repoPath,
136
+ commands: [] as string[],
137
+ logs: null,
138
+ mode: resolved.mode,
139
+ branch: resolved.branch as string | null,
140
+ fromCatalog: true,
141
+ isEntrypoint: false,
142
+ autostart: !noAutostart.has(resolved.repoName),
143
+ owners: daemonOwners.get(resolved.address) ?? [],
144
+ deps: [] as string[],
145
+ cloned: fs.existsSync(resolved.repoPath),
146
+ manifestLayers,
147
+ ...servicePlacement(null, resolved.subsystem, env),
148
+ },
149
+ ];
150
+ }
151
+ const entry = {
152
+ repo: resolved.repoName,
153
+ service: resolved.subsystem,
154
+ address: resolved.address,
155
+ repoPath: resolved.repoPath,
156
+ commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
157
+ logs: svc.logs?.[env] ?? null,
158
+ mode: resolved.mode,
159
+ branch: resolved.branch as string | null,
160
+ fromCatalog: true,
161
+ isEntrypoint: manifest.entrypoint === resolved.subsystem,
162
+ autostart: !noAutostart.has(resolved.repoName),
163
+ owners: daemonOwners.get(resolved.address) ?? [],
164
+ deps: (svc.depends_on ?? []).map((d) =>
165
+ resolveDepAddress(depService(d), catalog, ws.config, ws.name),
166
+ ),
167
+ cloned: true,
168
+ manifestLayers,
169
+ ...servicePlacement(manifest, resolved.subsystem, env),
170
+ };
171
+ if (entry.branch == null)
172
+ pendingBranches.push(
173
+ branchOf(resolved.repoPath).then((b) => {
174
+ entry.branch = b;
175
+ }),
176
+ );
177
+ return [entry];
178
+ })
179
+ : [];
180
+
181
+ // Merge: catalog entries take precedence over legacy for same repo.
182
+ // When a workspace filter is active, also exclude legacy entries not in
183
+ // the workspace (legacy discovery is not workspace-scoped).
184
+ const catalogRepos = new Set(Object.keys(catalog));
185
+ const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
186
+ const merged = [
187
+ ...legacyEntries.filter((e) => {
188
+ if (catalogRepos.has(e.repo)) return false;
189
+ if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
190
+ return true;
191
+ }),
192
+ ...catalogEntries,
193
+ ];
194
+
195
+ // Attach the dependency-graph position so consumers can present systems
196
+ // top-down (entry repo first, infra-stack last) — the same order the
197
+ // health row and `den status` use. Added as a FIELD rather than by
198
+ // re-sorting the array, because four pages consume this payload and some
199
+ // rely on the current order.
200
+ const graphRank = new Map(graphOrder(ws?.name).map((repo, i) => [repo, i]));
201
+ const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
202
+ await Promise.all(pendingBranches);
203
+ return merged.map((e) => ({
204
+ ...e,
205
+ graphRank: graphRank.get(e.repo) ?? Number.MAX_SAFE_INTEGER,
206
+ infra: infraRepo !== null && e.repo === infraRepo,
207
+ }));
208
+ }
209
+
210
+ export type ServicesList = Awaited<ReturnType<typeof buildServicesList>>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A short-lived, keyed cache whose builds are single-flight: callers that ask
3
+ * for the same key while a build is running share it instead of starting their
4
+ * own, and a settled result is reused for `ttlMs`. A failed build is never
5
+ * cached, so the next caller retries.
6
+ *
7
+ * For server routes the UI polls from every open tab (/api/services,
8
+ * /api/health): N tabs cost one build, not N.
9
+ */
10
+ export interface SingleFlight<T> {
11
+ /** The cached or in-flight value for `key`; `fresh` forces a new build. */
12
+ get(key: string, build: () => Promise<T>, fresh?: boolean): Promise<T>;
13
+ /** Drop every entry (after a change the cached value may not reflect). */
14
+ clear(): void;
15
+ }
16
+
17
+ export function singleFlight<T>(
18
+ ttlMs: number,
19
+ now: () => number = Date.now,
20
+ ): SingleFlight<T> {
21
+ const entries = new Map<
22
+ string,
23
+ { promise: Promise<T>; settledAt: number | null }
24
+ >();
25
+ return {
26
+ get(key, build, fresh = false) {
27
+ const hit = entries.get(key);
28
+ if (
29
+ !fresh &&
30
+ hit &&
31
+ (hit.settledAt === null || now() - hit.settledAt < ttlMs)
32
+ )
33
+ return hit.promise;
34
+ const entry: { promise: Promise<T>; settledAt: number | null } = {
35
+ promise: Promise.resolve().then(build),
36
+ settledAt: null,
37
+ };
38
+ entries.set(key, entry);
39
+ entry.promise.then(
40
+ () => {
41
+ entry.settledAt = now();
42
+ },
43
+ () => {
44
+ if (entries.get(key) === entry) entries.delete(key);
45
+ },
46
+ );
47
+ return entry.promise;
48
+ },
49
+ clear() {
50
+ entries.clear();
51
+ },
52
+ };
53
+ }