@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
@@ -10,7 +10,10 @@
10
10
  * GET /api/run?pillar=X&... — SSE stream: runs tests, emits output line-by-line
11
11
  */
12
12
 
13
- import { composeHealthCmd } from "../../shared/stacks/manifest-commands.js";
13
+ import {
14
+ composeHealthTarget,
15
+ type ComposeHealthTarget,
16
+ } from "../../shared/stacks/compose-health.js";
14
17
  import { healthCheckFields } from "./health-row.js";
15
18
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
16
19
  import * as http from "http";
@@ -39,7 +42,6 @@ import type {
39
42
  import { getRunDonePayload } from "./perf-report.js";
40
43
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
41
44
  import {
42
- readWorkspacesFile,
43
45
  getActiveWorkspace,
44
46
  getActiveWorkspaces,
45
47
  getFocusedWorkspaceName,
@@ -49,10 +51,7 @@ import {
49
51
  resolveDepAddress,
50
52
  parseAddress,
51
53
  readManifestAnyBranch,
52
- noAutostartRepos,
53
54
  } 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
55
  import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
57
56
  import {
58
57
  resolveHistorySource,
@@ -64,7 +63,9 @@ import {
64
63
  } from "./log-history.js";
65
64
  import {
66
65
  pingHttp,
67
- pingShell,
66
+ pingShellCheck,
67
+ TimeoutStreaks,
68
+ prewarmHealthTls,
68
69
  } from "../../shared/environment-manager/health.js";
69
70
  import {
70
71
  runServiceCommand,
@@ -74,7 +75,7 @@ import {
74
75
  unregisterDaemon,
75
76
  checkServiceHealth,
76
77
  loadDaemonsFromFile,
77
- pruneDeadDaemons,
78
+ pruneDeadDaemonsAsync,
78
79
  isInteractiveServiceCommand,
79
80
  rewriteLegacyOpctlKillCommandForContainer,
80
81
  shouldDetachServiceCommand,
@@ -142,6 +143,10 @@ import {
142
143
  import { handleReadonlyRoutes } from "./readonly-routes.js";
143
144
  import { serveUiAsset } from "./ui-assets.js";
144
145
  import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
146
+ import { singleFlight } from "./single-flight.js";
147
+ import { graphOrder } from "./graph-order.js";
148
+ import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
149
+ import { buildServicesList, type ServicesList } from "./services-list.js";
145
150
 
146
151
  const __filename = fileURLToPath(import.meta.url);
147
152
  const __dirname = path.dirname(__filename);
@@ -166,7 +171,10 @@ interface HealthResult {
166
171
  repoPath: string;
167
172
  mode: "dev-managed" | "managed";
168
173
  isEntrypoint: boolean;
174
+ /** true up; false a completed check failed (down); null not known. */
169
175
  ok: boolean | null;
176
+ /** With ok null: a check ran but has not answered (pending, not down). */
177
+ pending?: boolean;
170
178
  status?: number;
171
179
  error?: string;
172
180
  /** Where the app opens for this env (open-in-new-tab), or null. */
@@ -208,7 +216,32 @@ function viewerRewrite(
208
216
  }
209
217
 
210
218
  const HEALTH_CACHE_TTL_MS = 8_000;
219
+ /** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
220
+ const healthTimeouts = new TimeoutStreaks();
211
221
  const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
222
+ /**
223
+ * The full-env health run in flight per cache key. Concurrent polls and
224
+ * streams join it (a stream replays what already landed, then follows), so
225
+ * each cycle runs every check once: a second concurrent run doubled the
226
+ * shell checks' processes and pushed them past their budget under load.
227
+ */
228
+ const healthInFlight = new Map<
229
+ string,
230
+ {
231
+ run: Promise<HealthResult[]>;
232
+ landed: HealthResult[];
233
+ listeners: Set<(r: HealthResult) => void>;
234
+ }
235
+ >();
236
+ /** /api/services builds, shared by concurrent polls and reused briefly. */
237
+ /** The startup sweep of stale daemon records (see below the routes). */
238
+ let startupPrune: Promise<void> = Promise.resolve();
239
+ const SERVICES_CACHE_TTL_MS = 2_000;
240
+ // Manifests, the edge config and the derived graph order are reused across
241
+ // a request and the polls after it (cleared by any mutating request).
242
+ const READ_CACHE_TTL_MS = 5_000;
243
+ enableReadCache(READ_CACHE_TTL_MS);
244
+ const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
212
245
  /** A workspace-scoped run covers a different service set than the default one. */
213
246
  function healthCacheKey(env: Environment, workspace: string | null): string {
214
247
  return `${env}|${workspace ?? "*"}`;
@@ -534,6 +567,15 @@ async function handleRequest(
534
567
  ): Promise<void> {
535
568
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
536
569
  const pathname = url.pathname;
570
+ // A mutating request may change what /api/services would list (exec and
571
+ // sync stream over GET, for EventSource).
572
+ if (
573
+ (req.method !== "GET" && req.method !== "HEAD") ||
574
+ /\/(exec|sync)$/.test(pathname)
575
+ ) {
576
+ servicesCache.clear();
577
+ clearReadCache();
578
+ }
537
579
 
538
580
  // ── Request logging ────────────────────────────────────────────────────────
539
581
  // /api/health fires on the UI's 5s polling interval forever — it dominates
@@ -897,6 +939,8 @@ async function handleRequest(
897
939
  org: string;
898
940
  /** Codes that count as up for a remote target.url probe. */
899
941
  expect?: Expect;
942
+ /** pingShell options (Den's own `stack health` can report unknown). */
943
+ compose: ComposeHealthTarget | null;
900
944
  meta: Pick<HealthResult, "runs" | "target">;
901
945
  }> = [];
902
946
  let edgeDomain: string | null = null;
@@ -929,6 +973,7 @@ async function handleRequest(
929
973
  openUrl: null,
930
974
  links: [],
931
975
  org: orgOf(resolved.repoName, catalog),
976
+ compose: null,
932
977
  meta: { runs: "none", target: null },
933
978
  });
934
979
  continue;
@@ -936,9 +981,9 @@ async function handleRequest(
936
981
  const svcCfg = manifest.services?.[resolved.subsystem];
937
982
  // A compose-run repo with no declared check gets the stack's own health
938
983
  // (container state + Docker healthchecks), as `den status` does.
939
- const composeHealth =
984
+ const compose =
940
985
  env === "local"
941
- ? composeHealthCmd(
986
+ ? composeHealthTarget(
942
987
  manifest,
943
988
  resolved.subsystem,
944
989
  resolved.repoName,
@@ -951,7 +996,7 @@ async function handleRequest(
951
996
  manifest,
952
997
  resolved.subsystem,
953
998
  env,
954
- composeHealth,
999
+ compose?.cmd ?? null,
955
1000
  );
956
1001
  const healthCfg = plan.check;
957
1002
  const remote = plan.placement.runs === "remote";
@@ -1010,6 +1055,7 @@ async function handleRequest(
1010
1055
  links: linksFor(manifest, resolved.subsystem, env),
1011
1056
  org: orgOf(resolved.repoName, catalog),
1012
1057
  ...(plan.expect ? { expect: plan.expect } : {}),
1058
+ compose,
1013
1059
  meta: { runs: plan.placement.runs, target: plan.placement.target },
1014
1060
  });
1015
1061
  }
@@ -1019,9 +1065,7 @@ async function handleRequest(
1019
1065
  // direction as the graph (and `den status`). orderForDown is the reverse
1020
1066
  // of the deps-first startup order.
1021
1067
  const rank = new Map(
1022
- orderForDown(
1023
- deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
1024
- ).map((repo, i) => [repo, i]),
1068
+ graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
1025
1069
  );
1026
1070
  const sortedEntries = [...entries].sort((a, b) => {
1027
1071
  const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
@@ -1041,12 +1085,18 @@ async function handleRequest(
1041
1085
  conventional,
1042
1086
  meta,
1043
1087
  expect,
1088
+ compose,
1044
1089
  }) => {
1045
1090
  if (checkType !== "none") {
1046
1091
  const started = Date.now();
1047
1092
  const ping =
1048
1093
  checkType === "shell"
1049
- ? await pingShell(target, awsProfileEnv(env), repoPath)
1094
+ ? await pingShellCheck(
1095
+ target,
1096
+ awsProfileEnv(env),
1097
+ repoPath,
1098
+ compose,
1099
+ )
1050
1100
  : await pingHttp(
1051
1101
  target,
1052
1102
  env === "local" && meta.runs !== "remote"
@@ -1064,7 +1114,11 @@ async function handleRequest(
1064
1114
  isEntrypoint,
1065
1115
  ...meta,
1066
1116
  ms: Date.now() - started,
1067
- ...ping,
1117
+ // A first timeout is pending; a repeat on the next cycle is down.
1118
+ ...healthTimeouts.judge(
1119
+ `${env}|${repo}|${service}|${checkType}|${target}`,
1120
+ ping,
1121
+ ),
1068
1122
  };
1069
1123
  }
1070
1124
  // No declared health check: try the conventional probe when eligible.
@@ -1117,6 +1171,37 @@ async function handleRequest(
1117
1171
  return results;
1118
1172
  }
1119
1173
 
1174
+ /** Join the full-env health run in flight for (env, workspace), or start one. */
1175
+ function sharedHealthRun(env: Environment, workspace: string | null) {
1176
+ const key = healthCacheKey(env, workspace);
1177
+ const inFlight = healthInFlight.get(key);
1178
+ if (inFlight) return inFlight;
1179
+ const landed: HealthResult[] = [];
1180
+ const listeners = new Set<(r: HealthResult) => void>();
1181
+ const run = computeHealth(
1182
+ env,
1183
+ null,
1184
+ (r) => {
1185
+ landed.push(r);
1186
+ for (const l of listeners)
1187
+ try {
1188
+ l(r);
1189
+ } catch {
1190
+ /* a gone client must not stop the run */
1191
+ }
1192
+ },
1193
+ workspace,
1194
+ )
1195
+ .then((results) => {
1196
+ healthCache.set(key, { results, at: Date.now() });
1197
+ return results;
1198
+ })
1199
+ .finally(() => healthInFlight.delete(key));
1200
+ const shared = { run, landed, listeners };
1201
+ healthInFlight.set(key, shared);
1202
+ return shared;
1203
+ }
1204
+
1120
1205
  // Full-env health, cached. Every page navigation used to re-run all 30 checks
1121
1206
  // from scratch — and the slowest are shell probes that shell out to the aws
1122
1207
  // CLI (~0.45s each) — so the dots took ~0.6s to resolve on EVERY switch. A
@@ -1143,9 +1228,10 @@ async function handleRequest(
1143
1228
  if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1144
1229
  return sendJson(res, hit.results.map(forViewer));
1145
1230
  }
1146
- const results = await computeHealth(env, null, undefined, wsParam);
1147
- healthCache.set(cacheKey, { results, at: Date.now() });
1148
- return sendJson(res, results.map(forViewer));
1231
+ return sendJson(
1232
+ res,
1233
+ (await sharedHealthRun(env, wsParam).run).map(forViewer),
1234
+ );
1149
1235
  }
1150
1236
 
1151
1237
  // Same checks, streamed: each result is sent the moment it lands so the fast
@@ -1177,13 +1263,16 @@ async function handleRequest(
1177
1263
  }
1178
1264
 
1179
1265
  try {
1180
- const results = await computeHealth(
1181
- env,
1182
- null,
1183
- (r) => send({ type: "result", result: forViewer(r) }),
1184
- wsParam,
1185
- );
1186
- healthCache.set(cacheKey, { results, at: Date.now() });
1266
+ const shared = sharedHealthRun(env, wsParam);
1267
+ const forward = (r: HealthResult): void =>
1268
+ send({ type: "result", result: forViewer(r) });
1269
+ for (const r of shared.landed) forward(r);
1270
+ shared.listeners.add(forward);
1271
+ try {
1272
+ await shared.run;
1273
+ } finally {
1274
+ shared.listeners.delete(forward);
1275
+ }
1187
1276
  send({ type: "done", cached: false });
1188
1277
  } catch (e) {
1189
1278
  send({ type: "error", error: (e as Error).message });
@@ -1196,169 +1285,15 @@ async function handleRequest(
1196
1285
  const env = (url.searchParams.get("env") ?? "local") as Environment;
1197
1286
  // Optional: caller may request services for a specific (non-active) workspace.
1198
1287
  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;
1288
+ // Every open tab polls this: concurrent requests share one build, and a
1289
+ // settled one is reused briefly (any mutating request clears it).
1355
1290
  return sendJson(
1356
1291
  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
- })),
1292
+ await servicesCache.get(
1293
+ `${env}|${wsOverride ?? ""}`,
1294
+ () => buildServicesList(env, wsOverride),
1295
+ url.searchParams.get("refresh") === "1",
1296
+ ),
1362
1297
  );
1363
1298
  }
1364
1299
 
@@ -1836,6 +1771,7 @@ async function handleRequest(
1836
1771
 
1837
1772
  // ── API: running daemons list ──────────────────────────────────────────────
1838
1773
  if (pathname === "/api/service/daemons") {
1774
+ await startupPrune;
1839
1775
  const daemons = listDaemons().map((d) => ({
1840
1776
  address: d.address,
1841
1777
  repoName: d.repoName,
@@ -3206,15 +3142,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
3206
3142
  // service stopped outside den — otherwise reopen as tabs in the web UI and
3207
3143
  // clutter the shutdown prompt. Container liveness (not health) is the signal: a
3208
3144
  // 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.
3145
+ // The container listing runs as async child processes (a sync `docker ps`
3146
+ // here held the event loop at startup); /api/service/daemons waits for it,
3147
+ // so the UI never gets a stale entry on its first load.
3211
3148
  loadDaemonsFromFile();
3212
- const prunedDaemons = pruneDeadDaemons();
3213
- if (prunedDaemons.length > 0) {
3214
- console.error(
3215
- `Cleared ${prunedDaemons.length} stale daemon(s): ${prunedDaemons.join(", ")}`,
3216
- );
3217
- }
3149
+ startupPrune = pruneDeadDaemonsAsync()
3150
+ .then((pruned) => {
3151
+ if (pruned.length > 0)
3152
+ console.error(
3153
+ `Cleared ${pruned.length} stale daemon(s): ${pruned.join(", ")}`,
3154
+ );
3155
+ })
3156
+ .catch((e: unknown) => {
3157
+ console.error(`[server] stale-daemon sweep failed: ${String(e)}`);
3158
+ });
3218
3159
 
3219
3160
  // Background recovery: after a short delay (long enough for services to be
3220
3161
  // responding), health-check each loaded daemon. Removal requires strong
@@ -3272,6 +3213,16 @@ process.on("unhandledRejection", (reason) => {
3272
3213
  console.error("[server] unhandled rejection (continuing):", reason);
3273
3214
  });
3274
3215
 
3216
+ // Build, before serving, what the first health poll and /api/services would
3217
+ // otherwise build inside a request: the dependency-graph order and the HTTPS
3218
+ // checks' trust context (both synchronous).
3219
+ try {
3220
+ for (const ws of getActiveWorkspaces()) graphOrder(ws);
3221
+ prewarmHealthTls();
3222
+ } catch (e) {
3223
+ serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
3224
+ }
3225
+
3275
3226
  // Bind loopback only — this UI runs privileged local actions (sudo-backed
3276
3227
  // service commands), so it must not be reachable from the LAN. Opt out with
3277
3228
  // DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
@@ -3375,7 +3326,20 @@ async function stopDaemons(): Promise<void> {
3375
3326
 
3376
3327
  async function handleShutdown(signal: string): Promise<void> {
3377
3328
  clearSudoPassword();
3378
- const daemons = listDaemons();
3329
+ // The registry lives in the state dir, which is resolved from the
3330
+ // distribution root on every call: if that can no longer be found (den.yml
3331
+ // moved or removed while the server ran), still exit. Never product() here:
3332
+ // it resolves the same root.
3333
+ let daemons: ReturnType<typeof listDaemons>;
3334
+ try {
3335
+ daemons = listDaemons();
3336
+ } catch (e) {
3337
+ console.error(
3338
+ `\n[server] ${signal}: cannot read the running-services registry ` +
3339
+ `(${(e as Error).message}); exiting and leaving any services running.`,
3340
+ );
3341
+ process.exit(0);
3342
+ }
3379
3343
  if (daemons.length === 0) {
3380
3344
  process.exit(0);
3381
3345
  }
@@ -3419,16 +3383,29 @@ async function handleShutdown(signal: string): Promise<void> {
3419
3383
  process.exit(0);
3420
3384
  }
3421
3385
 
3386
+ /**
3387
+ * Run the shutdown for `signal`. A failure still ends the process: the guard
3388
+ * below ignores every later signal, so a shutdown that rejected would leave
3389
+ * the server running and deaf to SIGTERM (a service manager then waits out
3390
+ * its timeout and SIGKILLs it).
3391
+ */
3392
+ function shutdownOn(signal: string): void {
3393
+ handleShutdown(signal).catch((e: unknown) => {
3394
+ console.error(`[server] ${signal}: shutdown failed (exiting):`, e);
3395
+ process.exit(1);
3396
+ });
3397
+ }
3398
+
3422
3399
  let _shuttingDown = false;
3423
3400
  process.on("SIGINT", () => {
3424
3401
  if (_shuttingDown) return;
3425
3402
  _shuttingDown = true;
3426
3403
  console.log(""); // newline after ^C
3427
- void handleShutdown("SIGINT");
3404
+ shutdownOn("SIGINT");
3428
3405
  });
3429
3406
 
3430
3407
  process.on("SIGTERM", () => {
3431
3408
  if (_shuttingDown) return;
3432
3409
  _shuttingDown = true;
3433
- void handleShutdown("SIGTERM");
3410
+ shutdownOn("SIGTERM");
3434
3411
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What the version pop-up shows: the newest release only, or every
3
+ * version once the reader asks for them, plus the one button that toggles
4
+ * between the two (null when there is nothing earlier to show).
5
+ */
6
+ export function changelogView(entries, showAll) {
7
+ const list = Array.isArray(entries) ? entries : [];
8
+ const earlier = list.length - 1;
9
+ if (earlier < 1) return { shown: list.slice(0, 1), toggle: null };
10
+ return {
11
+ shown: showAll ? list : list.slice(0, 1),
12
+ toggle: showAll
13
+ ? { label: "Show latest only", expanded: true }
14
+ : { label: `Show all versions (${earlier} earlier)`, expanded: false },
15
+ };
16
+ }