@monster-orange/den 0.2.2 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/docs/distributions.md +51 -0
  3. package/package.json +1 -1
  4. package/scripts/setup-secrets.ts +11 -0
  5. package/service/install.sh +4 -2
  6. package/src/shared/edge/config.ts +16 -0
  7. package/src/shared/environment-manager/health.ts +36 -1
  8. package/src/shared/environment-manager/opctl.ts +83 -1
  9. package/src/shared/environment-manager/service-runner.ts +205 -8
  10. package/src/shared/git/origin-url.ts +77 -0
  11. package/src/shared/manifest/read-manifest.ts +39 -15
  12. package/src/shared/manifest/schema.ts +15 -0
  13. package/src/shared/paths.ts +42 -0
  14. package/src/shared/product.ts +7 -1
  15. package/src/shared/profile.ts +8 -0
  16. package/src/shared/read-cache.ts +77 -0
  17. package/src/shared/secrets/doppler.ts +10 -5
  18. package/src/shared/secrets/index.ts +1 -0
  19. package/src/shared/secrets/keys.ts +2 -0
  20. package/src/shared/secrets/secret-env.ts +149 -0
  21. package/src/shared/secrets/types.ts +10 -0
  22. package/src/shared/stacks/config.ts +6 -0
  23. package/src/shared/stacks/runtime.ts +51 -3
  24. package/src/shared/stacks/status.ts +109 -1
  25. package/src/shared/stacks/types.ts +8 -0
  26. package/src/shared/types/index.ts +15 -0
  27. package/src/ui/cli/index.ts +151 -10
  28. package/src/ui/cli/mcp-connect.ts +77 -15
  29. package/src/ui/cli/stack-command.ts +47 -5
  30. package/src/ui/cli/workspace-notes.ts +43 -0
  31. package/src/ui/mcp/connect.ts +3 -1
  32. package/src/ui/server/graph-order.ts +22 -0
  33. package/src/ui/server/index.ts +101 -183
  34. package/src/ui/server/lifecycle-exec.ts +23 -0
  35. package/src/ui/server/services-list.ts +210 -0
  36. package/src/ui/server/single-flight.ts +53 -0
  37. package/src/ui/server/workspace-routes.ts +272 -181
@@ -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,15 +61,17 @@ 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,
68
+ prepareServiceSecretEnv,
71
69
  resolveService,
72
70
  listDaemons,
73
71
  unregisterDaemon,
74
72
  checkServiceHealth,
75
73
  loadDaemonsFromFile,
76
- pruneDeadDaemons,
74
+ pruneDeadDaemonsAsync,
77
75
  isInteractiveServiceCommand,
78
76
  rewriteLegacyOpctlKillCommandForContainer,
79
77
  shouldDetachServiceCommand,
@@ -141,6 +139,10 @@ import {
141
139
  import { handleReadonlyRoutes } from "./readonly-routes.js";
142
140
  import { serveUiAsset } from "./ui-assets.js";
143
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";
144
146
 
145
147
  const __filename = fileURLToPath(import.meta.url);
146
148
  const __dirname = path.dirname(__filename);
@@ -208,6 +210,17 @@ function viewerRewrite(
208
210
 
209
211
  const HEALTH_CACHE_TTL_MS = 8_000;
210
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);
211
224
  /** A workspace-scoped run covers a different service set than the default one. */
212
225
  function healthCacheKey(env: Environment, workspace: string | null): string {
213
226
  return `${env}|${workspace ?? "*"}`;
@@ -533,6 +546,15 @@ async function handleRequest(
533
546
  ): Promise<void> {
534
547
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
535
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
+ }
536
558
 
537
559
  // ── Request logging ────────────────────────────────────────────────────────
538
560
  // /api/health fires on the UI's 5s polling interval forever — it dominates
@@ -1018,9 +1040,7 @@ async function handleRequest(
1018
1040
  // direction as the graph (and `den status`). orderForDown is the reverse
1019
1041
  // of the deps-first startup order.
1020
1042
  const rank = new Map(
1021
- orderForDown(
1022
- deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
1023
- ).map((repo, i) => [repo, i]),
1043
+ graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
1024
1044
  );
1025
1045
  const sortedEntries = [...entries].sort((a, b) => {
1026
1046
  const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
@@ -1142,9 +1162,17 @@ async function handleRequest(
1142
1162
  if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1143
1163
  return sendJson(res, hit.results.map(forViewer));
1144
1164
  }
1145
- const results = await computeHealth(env, null, undefined, wsParam);
1146
- healthCache.set(cacheKey, { results, at: Date.now() });
1147
- 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));
1148
1176
  }
1149
1177
 
1150
1178
  // Same checks, streamed: each result is sent the moment it lands so the fast
@@ -1195,169 +1223,15 @@ async function handleRequest(
1195
1223
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1196
1224
  // Optional: caller may request services for a specific (non-active) workspace.
1197
1225
  const wsOverride = url.searchParams.get("workspace") ?? null;
1198
-
1199
- // Conventional lifecycle locally: `down` is opctl-conventional (stops
1200
- // running containers with no declared command, no-ops gracefully), and
1201
- // `restart` is orchestrated by the exec runner as down → verify → up — so
1202
- // every service with an `up` can offer both even when the manifest doesn't
1203
- // declare them.
1204
- const withDown = (cmds: string[]): string[] => {
1205
- if (env !== "local") return cmds;
1206
- const out = [...cmds];
1207
- if (!out.includes("down")) out.push("down");
1208
- if (!out.includes("restart") && out.includes("up")) out.push("restart");
1209
- return out;
1210
- };
1211
-
1212
- // Legacy: discovered repos from .den/ symlinks / registered-repos.json
1213
- const legacyRepos = discoverRepos();
1214
- const legacyEntries = legacyRepos.flatMap((repo) => {
1215
- const manifestLayers = loadManifest(repo.repoPath).layers;
1216
- return Object.entries(repo.manifest.services ?? {}).map(
1217
- ([svcName, svc]) => ({
1218
- repo: repo.repoName,
1219
- service: svcName,
1220
- address: `${repo.repoName}/${svcName}`,
1221
- repoPath: repo.repoPath,
1222
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1223
- logs: svc.logs?.[env] ?? null,
1224
- mode: "dev-managed" as const,
1225
- branch: null as string | null,
1226
- fromCatalog: false,
1227
- isEntrypoint: repo.manifest.entrypoint === svcName,
1228
- autostart: true, // legacy discovery has no workspace entry to toggle
1229
- deps: (svc.depends_on ?? []).map(depService),
1230
- cloned: true, // legacy discovery only ever finds on-disk repos
1231
- manifestLayers,
1232
- ...servicePlacement(repo.manifest, svcName, env),
1233
- }),
1234
- );
1235
- });
1236
-
1237
- // Catalog entries (workspace-aware)
1238
- const catalog = readCatalog();
1239
- const wsFile = wsOverride ? readWorkspacesFile() : null;
1240
- const ws = wsOverride
1241
- ? wsFile?.workspaces[wsOverride]
1242
- ? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
1243
- : null
1244
- : getActiveWorkspace();
1245
- const getCurrentBranch = (repoPath: string): string | null => {
1246
- try {
1247
- return execSync("git rev-parse --abbrev-ref HEAD", {
1248
- cwd: repoPath,
1249
- encoding: "utf-8",
1250
- stdio: ["ignore", "pipe", "ignore"],
1251
- timeout: GIT_REMOTE_TIMEOUT_MS,
1252
- }).trim();
1253
- } catch {
1254
- return null;
1255
- }
1256
- };
1257
-
1258
- const noAutostart = ws
1259
- ? noAutostartRepos(ws.config, catalog)
1260
- : new Set<string>();
1261
- const daemonOwners = new Map(
1262
- listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
1263
- );
1264
- // Modules and libraries have no runtime: no card, health or commands.
1265
- // They show as contributors on the Applications tab instead.
1266
- const catalogEntries = ws
1267
- ? listWorkspaceServices(ws.config, catalog, ws.name)
1268
- .filter((r) => hasRuntime(repoKind(r.repoPath)))
1269
- .flatMap((resolved) => {
1270
- // The manifest and the layers it was built from: repo or held,
1271
- // plus override when manifests/<repo>.override.yml applied.
1272
- const { manifest, layers: manifestLayers } = loadManifest(
1273
- resolved.repoPath,
1274
- );
1275
- const svc = manifest?.services?.[resolved.subsystem];
1276
- if (!manifest || !svc) {
1277
- // Workspace membership is config-driven: a managed entry
1278
- // whose clone isn't on disk yet (or a checkout without a usable
1279
- // manifest) must still render everywhere — flagged `cloned:false`
1280
- // so the UI can offer Sync/clone instead of dropping the system.
1281
- // `branch` comes from the workspace entry; never probe git on a
1282
- // path that may not exist.
1283
- return [
1284
- {
1285
- repo: resolved.repoName,
1286
- service: resolved.subsystem,
1287
- address: resolved.address,
1288
- repoPath: resolved.repoPath,
1289
- commands: [] as string[],
1290
- logs: null,
1291
- mode: resolved.mode,
1292
- branch: resolved.branch,
1293
- fromCatalog: true,
1294
- isEntrypoint: false,
1295
- autostart: !noAutostart.has(resolved.repoName),
1296
- owners: daemonOwners.get(resolved.address) ?? [],
1297
- deps: [] as string[],
1298
- cloned: fs.existsSync(resolved.repoPath),
1299
- manifestLayers,
1300
- ...servicePlacement(null, resolved.subsystem, env),
1301
- },
1302
- ];
1303
- }
1304
- return [
1305
- {
1306
- repo: resolved.repoName,
1307
- service: resolved.subsystem,
1308
- address: resolved.address,
1309
- repoPath: resolved.repoPath,
1310
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1311
- logs: svc.logs?.[env] ?? null,
1312
- mode: resolved.mode,
1313
- branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
1314
- fromCatalog: true,
1315
- isEntrypoint: manifest.entrypoint === resolved.subsystem,
1316
- autostart: !noAutostart.has(resolved.repoName),
1317
- owners: daemonOwners.get(resolved.address) ?? [],
1318
- deps: (svc.depends_on ?? []).map((d) =>
1319
- resolveDepAddress(depService(d), catalog, ws.config, ws.name),
1320
- ),
1321
- cloned: true,
1322
- manifestLayers,
1323
- ...servicePlacement(manifest, resolved.subsystem, env),
1324
- },
1325
- ];
1326
- })
1327
- : [];
1328
-
1329
- // Merge: catalog entries take precedence over legacy for same repo.
1330
- // When a workspace filter is active, also exclude legacy entries not in
1331
- // the workspace (legacy discovery is not workspace-scoped).
1332
- const catalogRepos = new Set(Object.keys(catalog));
1333
- const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
1334
- const merged = [
1335
- ...legacyEntries.filter((e) => {
1336
- if (catalogRepos.has(e.repo)) return false;
1337
- if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
1338
- return true;
1339
- }),
1340
- ...catalogEntries,
1341
- ];
1342
-
1343
- // Attach the dependency-graph position so consumers can present systems
1344
- // top-down (entry repo first, infra-stack last) — the same order the
1345
- // health row and `den status` use. Added as a FIELD rather than by
1346
- // re-sorting the array, because four pages consume this payload and some
1347
- // rely on the current order.
1348
- const graphRank = new Map(
1349
- orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
1350
- (repo, i) => [repo, i],
1351
- ),
1352
- );
1353
- 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).
1354
1228
  return sendJson(
1355
1229
  res,
1356
- merged.map((e) => ({
1357
- ...e,
1358
- graphRank: graphRank.get(e.repo) ?? Number.MAX_SAFE_INTEGER,
1359
- infra: infraRepo !== null && e.repo === infraRepo,
1360
- })),
1230
+ await servicesCache.get(
1231
+ `${env}|${wsOverride ?? ""}`,
1232
+ () => buildServicesList(env, wsOverride),
1233
+ url.searchParams.get("refresh") === "1",
1234
+ ),
1361
1235
  );
1362
1236
  }
1363
1237
 
@@ -1477,6 +1351,8 @@ async function handleRequest(
1477
1351
  {
1478
1352
  run: runServiceCommand,
1479
1353
  health: (r) => checkServiceHealth(r, "local"),
1354
+ secretEnv: (address, cmd, workspace) =>
1355
+ prepareServiceSecretEnv(address, cmd, workspace),
1480
1356
  },
1481
1357
  );
1482
1358
  return;
@@ -1833,6 +1709,7 @@ async function handleRequest(
1833
1709
 
1834
1710
  // ── API: running daemons list ──────────────────────────────────────────────
1835
1711
  if (pathname === "/api/service/daemons") {
1712
+ await startupPrune;
1836
1713
  const daemons = listDaemons().map((d) => ({
1837
1714
  address: d.address,
1838
1715
  repoName: d.repoName,
@@ -3203,15 +3080,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
3203
3080
  // service stopped outside den — otherwise reopen as tabs in the web UI and
3204
3081
  // clutter the shutdown prompt. Container liveness (not health) is the signal: a
3205
3082
  // service's shared-infra health check stays green even when the service is down.
3206
- // Runs synchronously at startup so /api/service/daemons never hands the UI a
3207
- // 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.
3208
3086
  loadDaemonsFromFile();
3209
- const prunedDaemons = pruneDeadDaemons();
3210
- if (prunedDaemons.length > 0) {
3211
- console.error(
3212
- `Cleared ${prunedDaemons.length} stale daemon(s): ${prunedDaemons.join(", ")}`,
3213
- );
3214
- }
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
+ });
3215
3097
 
3216
3098
  // Background recovery: after a short delay (long enough for services to be
3217
3099
  // responding), health-check each loaded daemon. Removal requires strong
@@ -3269,6 +3151,16 @@ process.on("unhandledRejection", (reason) => {
3269
3151
  console.error("[server] unhandled rejection (continuing):", reason);
3270
3152
  });
3271
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
+
3272
3164
  // Bind loopback only — this UI runs privileged local actions (sudo-backed
3273
3165
  // service commands), so it must not be reachable from the LAN. Opt out with
3274
3166
  // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
@@ -3372,7 +3264,20 @@ async function stopDaemons(): Promise<void> {
3372
3264
 
3373
3265
  async function handleShutdown(signal: string): Promise<void> {
3374
3266
  clearSudoPassword();
3375
- 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
+ }
3376
3281
  if (daemons.length === 0) {
3377
3282
  process.exit(0);
3378
3283
  }
@@ -3416,16 +3321,29 @@ async function handleShutdown(signal: string): Promise<void> {
3416
3321
  process.exit(0);
3417
3322
  }
3418
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
+
3419
3337
  let _shuttingDown = false;
3420
3338
  process.on("SIGINT", () => {
3421
3339
  if (_shuttingDown) return;
3422
3340
  _shuttingDown = true;
3423
3341
  console.log(""); // newline after ^C
3424
- void handleShutdown("SIGINT");
3342
+ shutdownOn("SIGINT");
3425
3343
  });
3426
3344
 
3427
3345
  process.on("SIGTERM", () => {
3428
3346
  if (_shuttingDown) return;
3429
3347
  _shuttingDown = true;
3430
- void handleShutdown("SIGTERM");
3348
+ shutdownOn("SIGTERM");
3431
3349
  });
@@ -21,6 +21,7 @@ import type { ResolvedService } from "../../shared/types/index.js";
21
21
  import type {
22
22
  RunCommandOptions,
23
23
  RunCommandResult,
24
+ ServiceSecretEnvResult,
24
25
  } from "../../shared/environment-manager/service-runner.js";
25
26
  import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
26
27
 
@@ -34,6 +35,16 @@ export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
34
35
  export interface LifecycleExecDeps {
35
36
  run: (o: RunCommandOptions) => RunCommandResult;
36
37
  health: (r: ResolvedService) => Promise<ServiceHealthResult>;
38
+ /**
39
+ * R16: resolve the service's secret_env (prepareServiceSecretEnv). Unset,
40
+ * nothing is resolved, and the runner refuses to start a service that
41
+ * declares secret_env.
42
+ */
43
+ secretEnv?: (
44
+ address: string,
45
+ cmd: LifecycleExecCmd,
46
+ workspace?: string,
47
+ ) => Promise<ServiceSecretEnvResult>;
37
48
  /** Health poll interval after a detached start (default 1s). */
38
49
  pollMs?: number;
39
50
  /** Stop polling after this long (default 120s). */
@@ -106,12 +117,24 @@ export async function streamLifecycleExec(
106
117
  }
107
118
  }
108
119
 
120
+ // R16: all of the service's secret_env, or nothing starts. The message
121
+ // names keys, refs and the provider, never a value.
122
+ const secrets = deps.secretEnv
123
+ ? await deps.secretEnv(resolved.address, cmd, o.workspace)
124
+ : { ok: true as const, secretEnv: undefined };
125
+ if (!secrets.ok) {
126
+ if (!clientGone) emit({ type: "error", text: secrets.message });
127
+ finish(1, true);
128
+ return;
129
+ }
130
+
109
131
  const result = deps.run({
110
132
  address: resolved.address,
111
133
  command: cmd,
112
134
  workspace: o.workspace,
113
135
  runtime: "daemon",
114
136
  env: o.env,
137
+ secretEnv: secrets.secretEnv,
115
138
  onLine: (text) => {
116
139
  if (!clientGone) emit({ type: "line", text });
117
140
  },
@@ -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>>;