@monster-orange/den 0.2.7 → 0.3.0

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 (139) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +619 -3
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agent-actor.ts +38 -3
  22. package/src/shared/auth/headless-login.ts +1 -1
  23. package/src/shared/auth/sudo-vault.ts +93 -31
  24. package/src/shared/auth/token-providers/index.ts +7 -3
  25. package/src/shared/brand.ts +7 -0
  26. package/src/shared/config-error.ts +63 -0
  27. package/src/shared/constants.ts +3 -24
  28. package/src/shared/den-config.ts +185 -7
  29. package/src/shared/edge/config.ts +147 -21
  30. package/src/shared/edge/exec.ts +31 -0
  31. package/src/shared/edge/included.ts +169 -0
  32. package/src/shared/edge/render.ts +69 -14
  33. package/src/shared/edge/runtime.ts +128 -121
  34. package/src/shared/edge/status.ts +68 -8
  35. package/src/shared/edge/tls.ts +328 -0
  36. package/src/shared/edge/types.ts +25 -9
  37. package/src/shared/environment-manager/catalog.ts +52 -1
  38. package/src/shared/environment-manager/default-workspace.ts +26 -3
  39. package/src/shared/environment-manager/git-remote.ts +49 -0
  40. package/src/shared/environment-manager/health.ts +73 -38
  41. package/src/shared/environment-manager/scope.ts +168 -0
  42. package/src/shared/environment-manager/service-runner.ts +789 -43
  43. package/src/shared/environment-manager/up-targets.ts +132 -0
  44. package/src/shared/environment-manager/workspace-manager.ts +67 -25
  45. package/src/shared/environment-manager/workspace-org.ts +26 -11
  46. package/src/shared/health/rollup.ts +48 -0
  47. package/src/shared/manifest/service-links.ts +20 -8
  48. package/src/shared/open-url.ts +21 -2
  49. package/src/shared/org-sources.ts +328 -0
  50. package/src/shared/org.ts +53 -5
  51. package/src/shared/plugins/manifest.ts +406 -0
  52. package/src/shared/plugins/store.ts +358 -0
  53. package/src/shared/process/legacy-adopt.ts +285 -0
  54. package/src/shared/process/process-group.ts +202 -0
  55. package/src/shared/process/spawn-streaming.ts +7 -0
  56. package/src/shared/secrets/doppler.ts +1 -1
  57. package/src/shared/secrets/index.ts +279 -50
  58. package/src/shared/secrets/none.ts +56 -0
  59. package/src/shared/secrets/strategies.ts +202 -0
  60. package/src/shared/secrets/types.ts +1 -1
  61. package/src/shared/smoke/git-remote.ts +40 -0
  62. package/src/shared/stacks/manifest-commands.ts +1 -1
  63. package/src/shared/stacks/namespace.ts +178 -0
  64. package/src/shared/stacks/owners.ts +303 -0
  65. package/src/shared/stacks/render.ts +8 -3
  66. package/src/shared/stacks/runtime.ts +100 -16
  67. package/src/shared/stacks/status.ts +63 -12
  68. package/src/shared/strategies/registry.ts +89 -0
  69. package/src/shared/theme/contract.ts +538 -0
  70. package/src/shared/types/index.ts +24 -5
  71. package/src/ui/cli/clone-plan.ts +154 -23
  72. package/src/ui/cli/create.ts +24 -7
  73. package/src/ui/cli/doctor-command.ts +131 -0
  74. package/src/ui/cli/edge-command.ts +56 -10
  75. package/src/ui/cli/flag-parser.ts +62 -3
  76. package/src/ui/cli/health-display.ts +25 -0
  77. package/src/ui/cli/index.ts +649 -72
  78. package/src/ui/cli/notice.ts +20 -0
  79. package/src/ui/cli/org-command.ts +12 -2
  80. package/src/ui/cli/plugin-command.ts +849 -0
  81. package/src/ui/cli/stack-command.ts +63 -2
  82. package/src/ui/cli/test-runner.ts +11 -9
  83. package/src/ui/cli/theme-command.ts +136 -0
  84. package/src/ui/cli/up-args.ts +15 -0
  85. package/src/ui/cli/web-command.ts +47 -23
  86. package/src/ui/mcp/api.ts +28 -2
  87. package/src/ui/mcp/scope.ts +298 -0
  88. package/src/ui/mcp/server.ts +70 -8
  89. package/src/ui/mcp/tools.ts +151 -24
  90. package/src/ui/server/appearance-prefs.ts +95 -0
  91. package/src/ui/server/audit.ts +90 -3
  92. package/src/ui/server/daemon-reaper.ts +29 -1
  93. package/src/ui/server/engine-stamp.ts +83 -0
  94. package/src/ui/server/github-service.ts +29 -45
  95. package/src/ui/server/graph-page.ts +20 -0
  96. package/src/ui/server/graph-prefs.ts +44 -1
  97. package/src/ui/server/http-errors.ts +293 -0
  98. package/src/ui/server/http-helpers.ts +22 -3
  99. package/src/ui/server/index.ts +590 -45
  100. package/src/ui/server/public/appearance.js +531 -0
  101. package/src/ui/server/public/edge-page.js +248 -0
  102. package/src/ui/server/public/edge.html +251 -0
  103. package/src/ui/server/public/environments.html +65 -83
  104. package/src/ui/server/public/error-page.js +247 -0
  105. package/src/ui/server/public/error.html +260 -0
  106. package/src/ui/server/public/graph-model.js +332 -14
  107. package/src/ui/server/public/graph.css +141 -47
  108. package/src/ui/server/public/graph.js +223 -44
  109. package/src/ui/server/public/header.js +396 -600
  110. package/src/ui/server/public/health-fit.js +248 -0
  111. package/src/ui/server/public/health-rollup.js +128 -0
  112. package/src/ui/server/public/i18n/es.js +287 -0
  113. package/src/ui/server/public/i18n/ja.js +276 -0
  114. package/src/ui/server/public/index.html +139 -438
  115. package/src/ui/server/public/local-dev.html +3468 -4044
  116. package/src/ui/server/public/logs.html +71 -208
  117. package/src/ui/server/public/panel.js +15 -3
  118. package/src/ui/server/public/release.html +175 -389
  119. package/src/ui/server/public/services.html +596 -770
  120. package/src/ui/server/public/settings-page.js +759 -0
  121. package/src/ui/server/public/settings-registry.js +521 -0
  122. package/src/ui/server/public/settings.html +590 -0
  123. package/src/ui/server/public/shell.js +3869 -0
  124. package/src/ui/server/public/topbar.js +1413 -0
  125. package/src/ui/server/public/version-label.js +12 -0
  126. package/src/ui/server/public/workspace-picker.js +74 -34
  127. package/src/ui/server/readonly-routes.ts +24 -4
  128. package/src/ui/server/release-service.ts +25 -3
  129. package/src/ui/server/services-list.ts +94 -17
  130. package/src/ui/server/settings-distribution.ts +74 -0
  131. package/src/ui/server/shell-prefs.ts +192 -0
  132. package/src/ui/server/theme-routes.ts +292 -0
  133. package/src/ui/server/theme-runtime.ts +407 -0
  134. package/src/ui/server/ui-assets.ts +16 -1
  135. package/src/ui/server/ui-language.ts +50 -0
  136. package/src/ui/server/workspace-routes.ts +55 -9
  137. package/themes/den/den-theme.json +72 -0
  138. package/themes/den/favicon.ico +0 -0
  139. package/themes/den/mark.svg +6 -0
@@ -50,10 +50,18 @@ import {
50
50
  getWorkspaceByName,
51
51
  isWorkspaceActive,
52
52
  listWorkspaceServices,
53
+ readWorkspacesFile,
53
54
  resolveDepAddress,
54
55
  parseAddress,
55
56
  readManifestAnyBranch,
56
57
  } from "../../shared/environment-manager/workspaces.js";
58
+ import {
59
+ parseScope,
60
+ scopeKey,
61
+ scopePayload,
62
+ scopeView,
63
+ storedView,
64
+ } from "../../shared/environment-manager/scope.js";
57
65
  import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
58
66
  import {
59
67
  resolveHistorySource,
@@ -81,6 +89,8 @@ import {
81
89
  loadDaemonsFromFile,
82
90
  pruneDeadDaemonsAsync,
83
91
  daemonRootOf,
92
+ daemonStaleReason,
93
+ runningContainerBasesAsync,
84
94
  isInteractiveServiceCommand,
85
95
  rewriteLegacyOpctlKillCommandForContainer,
86
96
  shouldDetachServiceCommand,
@@ -96,6 +106,7 @@ import {
96
106
  getServiceLogPath,
97
107
  } from "../../shared/process/spawn-streaming.js";
98
108
  import {
109
+ assertDenConfigValid,
99
110
  readDenConfig,
100
111
  isProductionEnv,
101
112
  isLocalEnabled,
@@ -106,8 +117,17 @@ import {
106
117
  writeUserReleaseConfig,
107
118
  type ReleaseConfig,
108
119
  } from "../../shared/den-config.js";
109
- import { readOrg } from "../../shared/org.js";
110
- import { engineVersion, getDistRoot } from "../../shared/paths.js";
120
+ import {
121
+ CONFIG_ERROR_STATUS,
122
+ configErrorBody,
123
+ isConfigError,
124
+ } from "../../shared/config-error.js";
125
+ import { readOrgView } from "../../shared/org.js";
126
+ import {
127
+ engineVersion,
128
+ getDistRoot,
129
+ getEngineRoot,
130
+ } from "../../shared/paths.js";
111
131
  import { enginePath } from "../../shared/engine.js";
112
132
  import {
113
133
  releaseService,
@@ -131,11 +151,16 @@ import {
131
151
  type Placement,
132
152
  } from "../../shared/manifest/placement.js";
133
153
  import {
134
- isOpSignedIn,
135
- hasSudoInVault,
136
154
  readSudoFromVault,
137
155
  saveSudoToVault,
156
+ sudoRef,
157
+ sudoVaultStatus,
158
+ SUDO_REF_NAME,
138
159
  } from "../../shared/auth/sudo-vault.js";
160
+ import {
161
+ notConfiguredMessage,
162
+ secretsConfigWarnings,
163
+ } from "../../shared/secrets/index.js";
139
164
  import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
140
165
  import { actorLabel } from "../../shared/agent-actor.js";
141
166
  import {
@@ -143,11 +168,32 @@ import {
143
168
  actorSuffix,
144
169
  recordAction,
145
170
  recordAgentRefusal,
171
+ repoAtPath,
146
172
  setAuditSink,
147
173
  } from "./audit.js";
148
174
  import { handleReadonlyRoutes } from "./readonly-routes.js";
149
175
  import { serveUiAsset } from "./ui-assets.js";
150
- import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
176
+ import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
177
+ import { graphPageHtml } from "./graph-page.js";
178
+ import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
179
+ import { distributionSettings, userWebPort } from "./settings-distribution.js";
180
+ import {
181
+ appearancePrefs,
182
+ mergeAppearancePrefs,
183
+ storedAppearance,
184
+ } from "./appearance-prefs.js";
185
+ import {
186
+ activeTheme,
187
+ handleThemeRoutes,
188
+ type ThemeRouteDeps,
189
+ } from "./theme-routes.js";
190
+ import { findTheme } from "./theme-runtime.js";
191
+ import {
192
+ UI_LANGUAGES,
193
+ isUiLanguage,
194
+ localizePage,
195
+ uiLanguage,
196
+ } from "./ui-language.js";
151
197
  import { singleFlight } from "./single-flight.js";
152
198
  import { graphOrder } from "./graph-order.js";
153
199
  import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
@@ -189,6 +235,13 @@ interface HealthResult {
189
235
  /** Where it runs for this env, and where a remote one is (R13). */
190
236
  runs: Placement["runs"];
191
237
  target: Placement["target"];
238
+ /** From the focused workspace: the header sorts these first. */
239
+ focused?: boolean;
240
+ /**
241
+ * The workspaces in view that define this service (focus first). More
242
+ * than one means a shared service: it is listed once, with its owners.
243
+ */
244
+ workspaces?: string[];
192
245
  }
193
246
 
194
247
  /**
@@ -247,9 +300,12 @@ const SERVICES_CACHE_TTL_MS = 2_000;
247
300
  const READ_CACHE_TTL_MS = 5_000;
248
301
  enableReadCache(READ_CACHE_TTL_MS);
249
302
  const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
250
- /** A workspace-scoped run covers a different service set than the default one. */
303
+ /**
304
+ * A workspace-scoped run covers a different service set than the default
305
+ * one, and the default one covers whatever the view scope has in view.
306
+ */
251
307
  function healthCacheKey(env: Environment, workspace: string | null): string {
252
- return `${env}|${workspace ?? "*"}`;
308
+ return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
253
309
  }
254
310
  import {
255
311
  LOG_SWEEP_INTERVAL_MS,
@@ -381,6 +437,98 @@ function serverLog(msg: string, kind: string = "info", actor?: string): void {
381
437
  if (!SILENT) process.stdout.write(msg + "\n");
382
438
  }
383
439
 
440
+ // ── Theme ────────────────────────────────────────────────────────────────────
441
+ // The active theme (user's, else the org config's `theme:`, else den). A
442
+ // theme switch is a write of user.yml `appearance:` and a new /theme.css:
443
+ // nothing else (no edge, no services, no restart).
444
+ const themeDeps: ThemeRouteDeps = {
445
+ roots: () => ({ distRoot: getDistRoot(), engineRoot: getEngineRoot() }),
446
+ prefs: () => appearancePrefs(readUserConfig().appearance),
447
+ distributionTheme: () => readBrand().theme,
448
+ warn: (line) => serverLog(line, "warn"),
449
+ denFavicon: path.join(PUBLIC_DIR, "favicon.ico"),
450
+ };
451
+ // The pre-paint script in each page reads the user's mode and the theme's
452
+ // default mode from these placeholders, so the first paint is right.
453
+ // The UI language (R19): a page in another language than English loads
454
+ // its catalog before shell.js, so the first paint is in it.
455
+ setHtmlFill((html) => {
456
+ let mode = "system";
457
+ let def = "dark";
458
+ let lang = uiLanguage(undefined);
459
+ try {
460
+ const user = readUserConfig();
461
+ lang = uiLanguage(user.language);
462
+ mode = appearancePrefs(user.appearance).mode;
463
+ def = activeTheme(themeDeps)?.theme.default_mode ?? "dark";
464
+ } catch {
465
+ /* defaults */
466
+ }
467
+ let out = localizePage(html, lang)
468
+ .split("@@DEN_MODE@@")
469
+ .join(mode)
470
+ .split("@@DEN_DEFAULT_MODE@@")
471
+ .join(def);
472
+ // MD-2: every page says when the engine on disk is not the one running.
473
+ const engine = engineCheck();
474
+ if (engine.stale)
475
+ out = out.replace(
476
+ '<div id="den-shell"',
477
+ () =>
478
+ `<div id="den-shell" data-engine-stale="${escAttr(JSON.stringify(engineBanner(engine)))}"`,
479
+ );
480
+ return out;
481
+ });
482
+
483
+ // ── MD-2: the engine this server loaded, against the one on disk ─────────────
484
+ const ENGINE = engineWatch(getEngineRoot);
485
+ function engineCheck(): EngineCheck {
486
+ return ENGINE.check();
487
+ }
488
+ /** What the shell's banner and the error pages say on a mismatch. */
489
+ function engineBanner(c: EngineCheck): {
490
+ loaded: string | null;
491
+ onDisk: string | null;
492
+ message: string;
493
+ } {
494
+ return {
495
+ loaded: c.loaded.version,
496
+ onDisk: c.onDisk.version,
497
+ message: engineStaleMessage(c, product().cli),
498
+ };
499
+ }
500
+ function escAttr(v: string): string {
501
+ return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
502
+ }
503
+
504
+ /**
505
+ * R26a: the shell's error page for a failed page request (404 or 500).
506
+ * HEAD gets the status and headers only.
507
+ */
508
+ function sendErrorPage(
509
+ req: http.IncomingMessage,
510
+ res: http.ServerResponse,
511
+ data: Omit<ErrorPageData, "time" | "version" | "engine">,
512
+ ): void {
513
+ const engine = engineCheck();
514
+ const full: ErrorPageData = {
515
+ ...data,
516
+ time: new Date().toISOString(),
517
+ version: engine.loaded.version,
518
+ ...(engine.stale ? { engine: engineBanner(engine) } : {}),
519
+ };
520
+ if (res.headersSent) {
521
+ res.end();
522
+ return;
523
+ }
524
+ serveFile(
525
+ res,
526
+ path.join(PUBLIC_DIR, "error.html"),
527
+ (html) => fillErrorPage(html, full),
528
+ data.status,
529
+ );
530
+ }
531
+
384
532
  // Every audited action (exec, run, sync) lands in the log as a lifecycle
385
533
  // line; an agent's carries its label so the /logs page can mark it.
386
534
  setAuditSink((line, actor) =>
@@ -417,6 +565,46 @@ const STATIC_EXTS = new Set([
417
565
  ".woff2",
418
566
  ]);
419
567
 
568
+ /** Every workspace's checkouts (active or not), for repoAtPath. */
569
+ function allWorkspaceCheckouts(): ResolvedService[] {
570
+ const catalog = readCatalog();
571
+ return Object.entries(readWorkspacesFile().workspaces).flatMap(
572
+ ([name, config]) => listWorkspaceServices(config, catalog, name),
573
+ );
574
+ }
575
+
576
+ /**
577
+ * API routes that answer whatever the config holds: the identity probe the
578
+ * CLI and MCP use to find this server, its own log (where a config error is
579
+ * also written), the changelog, and restarting it. And (0.3.0-dev.40, R26a's
580
+ * 422 page) what the shell and the Settings and Logs pages need, so the
581
+ * rail stays usable to reach the fix: the UI prefs (theme, mode, language,
582
+ * the rail's pins) and editor settings (/api/settings: user.yml, and the
583
+ * org config's Distribution rows read without validation), the installed
584
+ * themes, the view scope and a workspace's editor (workspaces.yml), and
585
+ * whether a sudo password is held. None of them reads org data.
586
+ */
587
+ const CONFIG_FREE_ROUTES = new Set([
588
+ "/api/server-info",
589
+ "/api/server-log",
590
+ "/api/changelog",
591
+ "/api/web/restart",
592
+ "/api/settings",
593
+ "/api/themes",
594
+ "/api/scope",
595
+ "/api/workspaces/settings",
596
+ "/api/sudo-password/status",
597
+ // The env picker on every page (falls back to local; a consumer's dev.40 finding).
598
+ "/api/environments",
599
+ ]);
600
+
601
+ /**
602
+ * Pages that load whatever the config holds (0.3.0-dev.40): the rail's way
603
+ * to the fix (Settings, Logs). Any other page navigation with a config that
604
+ * fails validation gets the 422 "Config is invalid" page.
605
+ */
606
+ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
607
+
420
608
  // ── Sudo password store ───────────────────────────────────────────────────────
421
609
  import {
422
610
  setSudoPassword,
@@ -435,8 +623,23 @@ import {
435
623
  serveFile,
436
624
  send400,
437
625
  send404,
626
+ setHtmlFill,
438
627
  } from "./http-helpers.js";
439
628
  import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
629
+ import {
630
+ engineStaleMessage,
631
+ engineWatch,
632
+ type EngineCheck,
633
+ } from "./engine-stamp.js";
634
+ import {
635
+ fillErrorPage,
636
+ jsonApiErrors,
637
+ newRequestId,
638
+ pluginForPath,
639
+ routeTemplate,
640
+ wantsHtmlPage,
641
+ type ErrorPageData,
642
+ } from "./http-errors.js";
440
643
  import {
441
644
  readOrgContext,
442
645
  resolveAllWorkspaceOrgs,
@@ -456,14 +659,17 @@ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
456
659
  import { handleWorkspaceRoutes } from "./workspace-routes.js";
457
660
  import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
458
661
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
662
+ import { edgeHttps } from "../../shared/edge/render.js";
459
663
  import {
460
664
  linksFor,
461
665
  openUrlFor,
462
666
  rehost,
667
+ type EdgeOrigin,
463
668
  type OpenLink,
464
669
  } from "../../shared/open-url.js";
465
670
 
466
671
  import { product } from "../../shared/product.js";
672
+ import { readBrand } from "../../shared/brand.js";
467
673
  /** config `web.port` (user > team), falling back to the built-in default. */
468
674
  function readWebPortSafe(): number {
469
675
  try {
@@ -577,6 +783,12 @@ async function handleRequest(
577
783
  ): Promise<void> {
578
784
  const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
579
785
  const pathname = url.pathname;
786
+ // R26: one id per request, on every response and on its failure's log line.
787
+ const requestId = requestIdOf(req);
788
+ if (!res.headersSent) res.setHeader("X-Request-Id", requestId);
789
+ // R26b: every /api error answers JSON, whatever the route sends.
790
+ if (pathname === "/api" || pathname.startsWith("/api/"))
791
+ jsonApiErrors(res, requestId);
580
792
  // A mutating request may change what /api/services would list (exec and
581
793
  // sync stream over GET, for EventSource).
582
794
  if (
@@ -602,14 +814,45 @@ async function handleRequest(
602
814
  res.on("finish", () => {
603
815
  const ms = dim(`${Date.now() - start}ms`);
604
816
  serverLog(
605
- ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}`,
817
+ ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
818
+ (res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
606
819
  "request",
607
820
  );
608
821
  });
609
822
  }
610
823
  }
611
824
 
825
+ // ── Config validation ──────────────────────────────────────────────────────
826
+ // A config that fails validation answers every API route that reads it as
827
+ // the ConfigError the handler below maps to 422 JSON, before any route's own
828
+ // catch can turn it into a 400, a 500 or an empty list. Pages still load.
829
+ if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
830
+ assertDenConfigValid();
831
+ // A page navigation, likewise: the shell frame with the 422 page, not a
832
+ // page whose every call then fails (R26a). Static files never get here
833
+ // as a navigation (wantsHtmlPage needs Accept: text/html).
834
+ if (
835
+ wantsHtmlPage(req, pathname) &&
836
+ !CONFIG_FREE_PAGES.has(pathname) &&
837
+ (path.extname(pathname) === ".html" ||
838
+ !STATIC_EXTS.has(path.extname(pathname)))
839
+ )
840
+ assertDenConfigValid();
841
+
612
842
  // ── Static UI ──────────────────────────────────────────────────────────────
843
+ // The Settings page (R21). Local Dev's old settings panel link
844
+ // (/local-dev?settings=1) lands on its category there.
845
+ if (pathname === "/settings" || pathname === "/settings.html") {
846
+ return serveFile(res, path.join(PUBLIC_DIR, "settings.html"));
847
+ }
848
+ if (
849
+ (pathname === "/local-dev" || pathname === "/local-dev.html") &&
850
+ url.searchParams.has("settings")
851
+ ) {
852
+ res.writeHead(302, { Location: "/settings#editor" });
853
+ res.end();
854
+ return;
855
+ }
613
856
  // Local Dev is the default page when local environment is enabled.
614
857
  if (
615
858
  pathname === "/" ||
@@ -624,6 +867,20 @@ async function handleRequest(
624
867
  return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
625
868
  }
626
869
 
870
+ // The Graph page (R22): Local Dev's page in its full-width graph view.
871
+ if (pathname === "/graph" || pathname === "/graph.html") {
872
+ if (!isLocalEnabled()) {
873
+ res.writeHead(302, { Location: "/tests" });
874
+ res.end();
875
+ return;
876
+ }
877
+ return serveFile(
878
+ res,
879
+ path.join(PUBLIC_DIR, "local-dev.html"),
880
+ graphPageHtml,
881
+ );
882
+ }
883
+
627
884
  if (pathname === "/tests" || pathname === "/index.html") {
628
885
  return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
629
886
  }
@@ -639,6 +896,11 @@ async function handleRequest(
639
896
  return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
640
897
  }
641
898
 
899
+ // The Edge page (PL-SPEC §10): read-only, from /api/edge.
900
+ if (pathname === "/edge" || pathname === "/edge.html") {
901
+ return serveFile(res, path.join(PUBLIC_DIR, "edge.html"));
902
+ }
903
+
642
904
  if (pathname === "/logs") {
643
905
  return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
644
906
  }
@@ -647,6 +909,9 @@ async function handleRequest(
647
909
  return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
648
910
  }
649
911
 
912
+ // The active theme as CSS, its mark and fonts, and the theme list.
913
+ if (handleThemeRoutes(req, res, pathname, themeDeps)) return;
914
+
650
915
  // The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
651
916
  if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
652
917
 
@@ -690,6 +955,9 @@ async function handleRequest(
690
955
 
691
956
  // ── API: environments ──────────────────────────────────────────────────────
692
957
  // Orgs the catalog's repos belong to, with config/den.yml display names.
958
+ // ORG-1d: a repo's org is an org listing it, else one whose source claims
959
+ // its owner, else its GitHub owner; a claimed owner is not listed. Service
960
+ // rows keep `org` as the repo's GitHub owner (C3).
693
961
  // Each entry also says whether it is the default org (`github_org`) and
694
962
  // which workspaces on this machine belong to it (ORG-1a). Nothing else
695
963
  // changes: same entries, order and fields (ORG-SPEC C3).
@@ -704,7 +972,7 @@ async function handleRequest(
704
972
  );
705
973
  if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
706
974
  const repos = Object.fromEntries(
707
- Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
975
+ Object.keys(catalog).map((r) => [r, orgOf(r, catalog, ctx.config.orgs)]),
708
976
  );
709
977
  return sendJson(res, { orgs, repos });
710
978
  }
@@ -809,6 +1077,17 @@ async function handleRequest(
809
1077
  } catch {
810
1078
  return send400(res, "invalid JSON");
811
1079
  }
1080
+ // A call that named a path (manifest_validate) is recorded under the
1081
+ // catalog repo that path is inside, across every workspace's checkouts.
1082
+ if (typeof report.repo !== "string" && report.path !== undefined) {
1083
+ let repo: string | null = null;
1084
+ try {
1085
+ repo = repoAtPath(report.path, allWorkspaceCheckouts());
1086
+ } catch {
1087
+ /* unresolvable workspaces: recorded under the tool only */
1088
+ }
1089
+ if (repo) report = { ...report, repo };
1090
+ }
812
1091
  const entry = recordAgentRefusal(actor, report);
813
1092
  return sendJson(res, { recorded: true, ts: entry.ts });
814
1093
  }
@@ -886,9 +1165,11 @@ async function handleRequest(
886
1165
 
887
1166
  // ── API: health check ─────────────────────────────────────────────────────
888
1167
  /**
889
- * Workspaces a health run covers: one named workspace, else EVERY active one
890
- * (focus first). Health is machine-level — a service running under a
891
- * non-focused active workspace is still up, and its card must say so.
1168
+ * Workspaces a health run covers: one named workspace, else the view
1169
+ * scope's (every active one by default, focus first). Health is
1170
+ * machine-level: a service running under a non-focused active workspace
1171
+ * is still up, and its card must say so. The pages and `status` read the
1172
+ * same scope (src/shared/environment-manager/scope.ts).
892
1173
  */
893
1174
  function healthWorkspaces(
894
1175
  workspaceName: string | null,
@@ -897,12 +1178,7 @@ async function handleRequest(
897
1178
  const named = getWorkspaceByName(workspaceName);
898
1179
  return named ? [named] : [];
899
1180
  }
900
- const focus = getFocusedWorkspaceName();
901
- const names = [
902
- ...(focus ? [focus] : []),
903
- ...getActiveWorkspaces().filter((n) => n !== focus),
904
- ];
905
- return names.flatMap((n) => getWorkspaceByName(n) ?? []);
1181
+ return scopeView().workspaces.filter((w) => w.config);
906
1182
  }
907
1183
 
908
1184
  /**
@@ -929,11 +1205,16 @@ async function handleRequest(
929
1205
  string,
930
1206
  { svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
931
1207
  >();
1208
+ const owners = new Map<string, string[]>();
932
1209
  for (const ws of workspaces)
933
- for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
1210
+ for (const svc of listWorkspaceServices(ws.config, catalog, ws.name)) {
934
1211
  // Modules and libraries have no runtime, so no health dot.
935
- if (!byAddress.has(svc.address) && hasRuntime(repoKind(svc.repoPath)))
1212
+ if (!hasRuntime(repoKind(svc.repoPath))) continue;
1213
+ if (!byAddress.has(svc.address))
936
1214
  byAddress.set(svc.address, { svc, ws });
1215
+ const o = owners.get(svc.address) ?? [];
1216
+ if (!o.includes(ws.name)) owners.set(svc.address, [...o, ws.name]);
1217
+ }
937
1218
  let services = [...byAddress.values()];
938
1219
  if (addressFilter) {
939
1220
  services = services.filter((s) => s.svc.address === addressFilter);
@@ -962,11 +1243,19 @@ async function handleRequest(
962
1243
  compose: ComposeHealthTarget | null;
963
1244
  /** A `type: container` check: answered from the shared listing. */
964
1245
  container: ContainerHealthTarget | null;
965
- meta: Pick<HealthResult, "runs" | "target">;
1246
+ meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
966
1247
  }> = [];
1248
+ const focusName = getFocusedWorkspaceName();
967
1249
  let edgeDomain: string | null = null;
1250
+ // The links follow the edge's TLS: http on its http port for `tls: none`
1251
+ // (0.3.0-dev.40, DOC-1: the panel's Opens said https).
1252
+ let edgeOrigin: EdgeOrigin | undefined;
968
1253
  try {
969
- edgeDomain = readEdgeConfig().domain;
1254
+ const edge = readEdgeConfig();
1255
+ edgeDomain = edge.domain;
1256
+ edgeOrigin = edgeHttps(edge)
1257
+ ? { https: true, port: edge.ports?.https ?? 443 }
1258
+ : { https: false, port: edge.ports?.http ?? 80 };
970
1259
  } catch {
971
1260
  /* no edge configured: local links come from manifests only */
972
1261
  }
@@ -996,7 +1285,12 @@ async function handleRequest(
996
1285
  org: orgOf(resolved.repoName, catalog),
997
1286
  compose: null,
998
1287
  container: null,
999
- meta: { runs: "none", target: null },
1288
+ meta: {
1289
+ runs: "none",
1290
+ target: null,
1291
+ focused: ws.name === focusName,
1292
+ workspaces: owners.get(resolved.address) ?? [ws.name],
1293
+ },
1000
1294
  });
1001
1295
  continue;
1002
1296
  }
@@ -1074,6 +1368,7 @@ async function handleRequest(
1074
1368
  env,
1075
1369
  healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
1076
1370
  edgeDomain,
1371
+ edgeOrigin,
1077
1372
  }),
1078
1373
  links: linksFor(manifest, resolved.subsystem, env),
1079
1374
  org: orgOf(resolved.repoName, catalog),
@@ -1081,7 +1376,12 @@ async function handleRequest(
1081
1376
  compose,
1082
1377
  container:
1083
1378
  healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
1084
- meta: { runs: plan.placement.runs, target: plan.placement.target },
1379
+ meta: {
1380
+ runs: plan.placement.runs,
1381
+ target: plan.placement.target,
1382
+ focused: ws.name === focusName,
1383
+ workspaces: owners.get(resolved.address) ?? [ws.name],
1384
+ },
1085
1385
  });
1086
1386
  }
1087
1387
 
@@ -1318,7 +1618,8 @@ async function handleRequest(
1318
1618
  return sendJson(
1319
1619
  res,
1320
1620
  await servicesCache.get(
1321
- `${env}|${wsOverride ?? ""}`,
1621
+ // No workspace named: what the view scope has in view.
1622
+ `${env}|${wsOverride ?? `*:${scopeKey(scopeView())}`}`,
1322
1623
  () => buildServicesList(env, wsOverride),
1323
1624
  url.searchParams.get("refresh") === "1",
1324
1625
  ),
@@ -2392,10 +2693,10 @@ async function handleRequest(
2392
2693
  // `org` resolved (id defaults to edge.org) for the header's org label.
2393
2694
  const config = readDenConfig();
2394
2695
  // `default_org` (ORG-1a) is the default work org, `github_org`; `org`
2395
- // stays the instance org (readOrg).
2696
+ // stays the instance org (readOrgView).
2396
2697
  return sendJson(res, {
2397
2698
  ...config,
2398
- org: readOrg(),
2699
+ org: readOrgView(),
2399
2700
  default_org: config.github_org?.trim() || null,
2400
2701
  });
2401
2702
  }
@@ -2403,11 +2704,17 @@ async function handleRequest(
2403
2704
  // ── API: server info + staleness (drives the header "stale server" pill) ────
2404
2705
  if (pathname === "/api/server-info" && req.method === "GET") {
2405
2706
  const { stale, scanned } = computeStale();
2707
+ const engine = engineCheck();
2406
2708
  return sendJson(res, {
2407
2709
  pid: process.pid,
2408
2710
  port: PORT,
2409
- // Den's version: the MCP server compares it with the routes it needs.
2410
- version: engineVersion() ?? null,
2711
+ // The Den this server runs (its routes): the MCP server compares it
2712
+ // with the routes it needs. loaded and onDisk (MD-2): the engine at
2713
+ // startup and on disk now; engineStale when they differ.
2714
+ version: ENGINE.loaded.version ?? engineVersion() ?? null,
2715
+ loaded: engine.loaded,
2716
+ onDisk: engine.onDisk,
2717
+ engineStale: engine.stale,
2411
2718
  // Whose server this is: `<cli> web up|down`, `mcp connect` and
2412
2719
  // `service install` compare both with their own and never act on
2413
2720
  // another distribution's server (see cli/server-identity.ts).
@@ -2807,8 +3114,9 @@ async function handleRequest(
2807
3114
  // ── API: sudo password ────────────────────────────────────────────────────
2808
3115
  // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
2809
3116
  // GET /api/sudo-password/status → { set: boolean }
2810
- // GET /api/sudo-password/vault-status → { opAvailable, hasStored }
2811
- // POST /api/sudo-password/load-from-vault → { ok, reason? }
3117
+ // GET /api/sudo-password/vault-status → { backend, label, configured,
3118
+ // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3119
+ // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
2812
3120
  if (pathname === "/api/sudo-password") {
2813
3121
  if (req.method !== "POST") {
2814
3122
  res.writeHead(405);
@@ -2860,11 +3168,10 @@ async function handleRequest(
2860
3168
  }
2861
3169
 
2862
3170
  // Advertises vault capability to the UI so it knows whether to show the
2863
- // "Save to 1Password" checkbox and whether auto-load is worth trying.
3171
+ // "Save to <backend>" checkbox and whether auto-load is worth trying. With
3172
+ // no secrets.refs.sudo it says "not configured" and spawns nothing.
2864
3173
  if (pathname === "/api/sudo-password/vault-status") {
2865
- const opAvailable = await isOpSignedIn();
2866
- const hasStored = opAvailable ? await hasSudoInVault() : false;
2867
- return sendJson(res, { opAvailable, hasStored });
3174
+ return sendJson(res, await sudoVaultStatus());
2868
3175
  }
2869
3176
 
2870
3177
  // Auto-unlock path: UI calls this when status.set is false. We read from
@@ -2877,6 +3184,13 @@ async function handleRequest(
2877
3184
  res.end();
2878
3185
  return;
2879
3186
  }
3187
+ if (!sudoRef()) {
3188
+ return sendJson(res, {
3189
+ ok: false,
3190
+ reason: "not-configured",
3191
+ message: notConfiguredMessage(SUDO_REF_NAME),
3192
+ });
3193
+ }
2880
3194
  const password = await readSudoFromVault();
2881
3195
  if (!password) {
2882
3196
  return sendJson(res, { ok: false, reason: "not-found" });
@@ -2898,9 +3212,43 @@ async function handleRequest(
2898
3212
  }
2899
3213
 
2900
3214
  // ── API: user settings ────────────────────────────────────────────────────
2901
- // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages }
2902
- // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph? }
3215
+ // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages,
3216
+ // log_window_lines, graph, appearance, shell, distribution,
3217
+ // user_web_port, local_dev, cli_tabs, language, ui_languages }
3218
+ // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph?, appearance?, shell?,
3219
+ // language? }
2903
3220
  // graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
3221
+ // appearance: { theme: id | null, mode: system|dark|light } (appearance-prefs.ts)
3222
+ // shell: { pins, collapsed, hidden, order, dock, dockHeight } (shell-prefs.ts)
3223
+ // language: the UI's language, en | ja | es (ui-language.ts); null or
3224
+ // "en" clears it (English is the default)
3225
+ // It writes config/local/user.yml only. `distribution` is read-only: what
3226
+ // the org config (tracked) sets, for the Settings page's Distribution rows.
3227
+ // Every key POST accepts has a row in the Settings page's registry
3228
+ // (settings-registry.js; settings-inventory.test.ts checks).
3229
+ // The view scope: which active workspaces the pages, the health row and
3230
+ // `status` show. A view preference only (user.yml `view`): it changes no
3231
+ // edge route, container or service, and never the focus.
3232
+ if (pathname === "/api/scope") {
3233
+ if (req.method === "GET") return sendJson(res, scopePayload(scopeView()));
3234
+ if (req.method === "POST") {
3235
+ let body: { scope?: unknown };
3236
+ try {
3237
+ body = JSON.parse(await readBody(req)) as { scope?: unknown };
3238
+ } catch {
3239
+ return send400(res, "invalid JSON");
3240
+ }
3241
+ const parsed = parseScope(body.scope, getActiveWorkspaces());
3242
+ if (!parsed.ok) return send400(res, parsed.error);
3243
+ const user = readUserConfig();
3244
+ const view = storedView(parsed.value);
3245
+ if (view) user.view = view;
3246
+ else delete user.view;
3247
+ writeUserConfig(user);
3248
+ return sendJson(res, scopePayload(scopeView(parsed.value)));
3249
+ }
3250
+ }
3251
+
2904
3252
  if (pathname === "/api/settings") {
2905
3253
  if (req.method === "GET") {
2906
3254
  const user = readUserConfig();
@@ -2915,6 +3263,16 @@ async function handleRequest(
2915
3263
  languages: SUPPORTED_LANGUAGES,
2916
3264
  log_window_lines: user.log_window_lines ?? 20_000,
2917
3265
  graph: graphPrefs(user.graph),
3266
+ appearance: appearancePrefs(user.appearance),
3267
+ shell: shellPrefs(user.shell),
3268
+ distribution: distributionSettings(getDistRoot()),
3269
+ // Set from the command line (`<cli> config`), shown read-only.
3270
+ user_web_port: userWebPort(user as Record<string, unknown>),
3271
+ local_dev: user.local_dev !== false,
3272
+ cli_tabs: user.cli_tabs === true,
3273
+ // The UI's language (R19), and the ones it has catalogs for.
3274
+ language: uiLanguage(user.language),
3275
+ ui_languages: UI_LANGUAGES,
2918
3276
  });
2919
3277
  }
2920
3278
  if (req.method === "POST") {
@@ -2924,12 +3282,57 @@ async function handleRequest(
2924
3282
  terminal?: string | null;
2925
3283
  editor_overrides?: Record<string, string> | null;
2926
3284
  graph?: unknown;
3285
+ appearance?: unknown;
3286
+ shell?: unknown;
3287
+ language?: unknown;
2927
3288
  };
2928
3289
  const user = readUserConfig();
2929
- if (body.graph !== undefined) {
2930
- const g = mergeGraphPrefs(graphPrefs(user.graph), body.graph);
3290
+ // null clears (0.3.0-dev.32, a consumer's nit): a block or a key
3291
+ // set to null is removed from user.yml, and a block left empty
3292
+ // goes too, so setting a pref and clearing it again leaves the
3293
+ // file byte-identical.
3294
+ // The rail's pins and collapse (R14): a view preference only.
3295
+ if (body.shell === null) delete user.shell;
3296
+ else if (body.shell !== undefined) {
3297
+ const sh = mergeShellPrefs(shellPrefs(user.shell), body.shell);
3298
+ if (!sh.ok) return send400(res, sh.error);
3299
+ const stored = storedShell(sh.value);
3300
+ if (stored) user.shell = stored;
3301
+ else delete user.shell;
3302
+ }
3303
+ if (body.graph === null) delete user.graph;
3304
+ else if (body.graph !== undefined) {
3305
+ const g = patchStoredGraph(user.graph, body.graph);
2931
3306
  if (!g.ok) return send400(res, g.error);
2932
- user.graph = g.value;
3307
+ if (g.value) user.graph = g.value as UserConfig["graph"];
3308
+ else delete user.graph;
3309
+ }
3310
+ // A UI preference only: the page swaps /theme.css and data-mode.
3311
+ if (body.appearance === null) delete user.appearance;
3312
+ else if (body.appearance !== undefined) {
3313
+ const a = mergeAppearancePrefs(
3314
+ appearancePrefs(user.appearance),
3315
+ body.appearance,
3316
+ (id) => !!findTheme(themeDeps.roots(), id)?.ok,
3317
+ );
3318
+ if (!a.ok) return send400(res, a.error);
3319
+ const stored = storedAppearance(a.value);
3320
+ if (stored) user.appearance = stored;
3321
+ else delete user.appearance;
3322
+ }
3323
+ // The UI's language (R19): stored only when it isn't English.
3324
+ if (body.language !== undefined) {
3325
+ const clear =
3326
+ body.language === null ||
3327
+ body.language === "" ||
3328
+ body.language === "en";
3329
+ if (!clear && !isUiLanguage(body.language))
3330
+ return send400(
3331
+ res,
3332
+ `language must be one of ${UI_LANGUAGES.map((l) => `"${l}"`).join(", ")}`,
3333
+ );
3334
+ if (clear) delete user.language;
3335
+ else user.language = body.language as string;
2933
3336
  }
2934
3337
  if (body.editor === null || body.editor === "") {
2935
3338
  delete user.editor;
@@ -2975,6 +3378,9 @@ async function handleRequest(
2975
3378
  terminal: user.terminal ?? null,
2976
3379
  editor_overrides: user.editor_overrides ?? {},
2977
3380
  graph: graphPrefs(user.graph),
3381
+ appearance: appearancePrefs(user.appearance),
3382
+ shell: shellPrefs(user.shell),
3383
+ language: uiLanguage(user.language),
2978
3384
  });
2979
3385
  } catch (e) {
2980
3386
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -3142,22 +3548,157 @@ async function handleRequest(
3142
3548
  }
3143
3549
  }
3144
3550
 
3551
+ // A route that throws, for the error frame's tests and screenshots only
3552
+ // (DEN_DEBUG_ROUTES=1): a page and an API call.
3553
+ if (
3554
+ process.env.DEN_DEBUG_ROUTES === "1" &&
3555
+ (pathname === "/_den/throw" || pathname === "/api/_den/throw")
3556
+ ) {
3557
+ throw new Error("a debug route threw (DEN_DEBUG_ROUTES)");
3558
+ }
3559
+
3145
3560
  // ── 404 ────────────────────────────────────────────────────────────────────
3561
+ sendNotFound(req, res, pathname, requestId);
3562
+ }
3563
+
3564
+ /**
3565
+ * No route answered. /api: the R26b JSON (an `engine_stale` code naming
3566
+ * both versions when the engine on disk is newer: its routes are the ones
3567
+ * that 404 here). A page: the shell's 404 page (R26a). Anything else (an
3568
+ * asset): plain text, as before.
3569
+ */
3570
+ function sendNotFound(
3571
+ req: http.IncomingMessage,
3572
+ res: http.ServerResponse,
3573
+ pathname: string,
3574
+ requestId: string,
3575
+ ): void {
3576
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3577
+ const engine = engineCheck();
3578
+ if (engine.stale)
3579
+ return sendJson(
3580
+ res,
3581
+ {
3582
+ ok: false,
3583
+ error: engineStaleMessage(engine, product().cli),
3584
+ code: "engine_stale",
3585
+ loaded: engine.loaded.version,
3586
+ onDisk: engine.onDisk.version,
3587
+ },
3588
+ 404,
3589
+ );
3590
+ return sendJson(
3591
+ res,
3592
+ {
3593
+ ok: false,
3594
+ error: `No such API route: ${req.method ?? "GET"} ${pathname}`,
3595
+ code: "not_found",
3596
+ },
3597
+ 404,
3598
+ );
3599
+ }
3600
+ if (wantsHtmlPage(req, pathname))
3601
+ return sendErrorPage(req, res, {
3602
+ status: 404,
3603
+ path: pathname,
3604
+ requestId,
3605
+ plugin: pluginForPath(pathname, ROOT),
3606
+ });
3146
3607
  res.writeHead(404, { "Content-Type": "text/plain" });
3147
3608
  res.end("Not found");
3148
3609
  }
3149
3610
 
3611
+ /** The request's id: assigned once (the handler and its catch share it). */
3612
+ const REQUEST_IDS = new WeakMap<http.IncomingMessage, string>();
3613
+ function requestIdOf(req: http.IncomingMessage): string {
3614
+ let id = REQUEST_IDS.get(req);
3615
+ if (!id) {
3616
+ id = newRequestId();
3617
+ REQUEST_IDS.set(req, id);
3618
+ }
3619
+ return id;
3620
+ }
3621
+
3150
3622
  const server = http.createServer((req, res) => {
3151
3623
  // An async handler with no catch turns any route throw into an unhandled
3152
3624
  // rejection — which CRASHES the whole server on modern Node (freezing every
3153
3625
  // health dot) and leaves the request hanging. Fail the one request instead.
3154
- handleRequest(req, res).catch((err) => {
3155
- console.error(`[server] ${req.method} ${req.url} failed:`, err);
3626
+ handleRequest(req, res).catch((err: unknown) => {
3627
+ // R26: the log has the stack under the request id; the browser gets the
3628
+ // id, never the stack. The route is its template: no query values.
3629
+ const requestId = requestIdOf(req);
3630
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
3631
+ const route = routeTemplate(pathname);
3632
+ const method = req.method ?? "GET";
3633
+ // A config that fails validation is the user's to fix, not a crash: the
3634
+ // /api frame answers 422 config_invalid (jsonApiErrors adds requestId), a
3635
+ // page gets the error page saying so, anything else the message as text.
3636
+ if (isConfigError(err)) {
3637
+ serverLog(
3638
+ ` ⚠ ${method} ${route} (req ${requestId}): ${err.message}`,
3639
+ "error",
3640
+ );
3641
+ try {
3642
+ if (res.headersSent) {
3643
+ res.end();
3644
+ return;
3645
+ }
3646
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3647
+ sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
3648
+ return;
3649
+ }
3650
+ if (wantsHtmlPage(req, pathname)) {
3651
+ sendErrorPage(req, res, {
3652
+ status: CONFIG_ERROR_STATUS,
3653
+ path: pathname,
3654
+ message: err.message,
3655
+ issues: configErrorBody(err).issues,
3656
+ cli: product().cli,
3657
+ requestId,
3658
+ });
3659
+ return;
3660
+ }
3661
+ res.writeHead(CONFIG_ERROR_STATUS, { "Content-Type": "text/plain" });
3662
+ res.end(`${err.message} (request ${requestId})`);
3663
+ } catch {
3664
+ /* socket already gone */
3665
+ }
3666
+ return;
3667
+ }
3668
+ const stack =
3669
+ err instanceof Error ? (err.stack ?? err.message) : String(err);
3670
+ serverLog(
3671
+ ` ✗ ${method} ${route} failed (req ${requestId}): ${stack}`,
3672
+ "error",
3673
+ );
3674
+ if (SILENT)
3675
+ console.error(
3676
+ `[server] ${method} ${route} failed (req ${requestId}):`,
3677
+ err,
3678
+ );
3156
3679
  try {
3157
- if (!res.headersSent) {
3158
- res.writeHead(500, { "Content-Type": "text/plain" });
3680
+ if (res.headersSent) {
3681
+ res.end();
3682
+ return;
3683
+ }
3684
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
3685
+ sendJson(
3686
+ res,
3687
+ {
3688
+ ok: false,
3689
+ error: `${method} ${route} failed on the server`,
3690
+ code: "internal",
3691
+ },
3692
+ 500,
3693
+ );
3694
+ return;
3695
+ }
3696
+ if (wantsHtmlPage(req, pathname)) {
3697
+ sendErrorPage(req, res, { status: 500, method, route, requestId });
3698
+ return;
3159
3699
  }
3160
- res.end("internal server error");
3700
+ res.writeHead(500, { "Content-Type": "text/plain" });
3701
+ res.end(`internal server error (request ${requestId})`);
3161
3702
  } catch {
3162
3703
  /* socket already gone */
3163
3704
  }
@@ -3215,6 +3756,8 @@ void (async () => {
3215
3756
  delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3216
3757
  retryDelayMs: DAEMON_RETRY_DELAY_MS,
3217
3758
  rootOf: daemonRootOf,
3759
+ staleReason: daemonStaleReason,
3760
+ runningContainers: runningContainerBasesAsync,
3218
3761
  });
3219
3762
  if (reaped.removed.length > 0)
3220
3763
  console.error(
@@ -3292,6 +3835,8 @@ server.listen(PORT, BIND_HOST, () => {
3292
3835
  console.log();
3293
3836
  console.log(` Web UI → http://${host}:${PORT}`);
3294
3837
  console.log();
3838
+ // Once per start: a secrets config that leaves Den's own features off.
3839
+ for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
3295
3840
  });
3296
3841
 
3297
3842
  // ── Graceful shutdown ─────────────────────────────────────────────────────────