@monster-orange/den 0.1.0 → 0.2.1

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 (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +1 -1
  3. package/bin/den +8 -0
  4. package/docs/distributions.md +283 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +19 -2
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +333 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +184 -55
  35. package/src/ui/cli/mcp-connect.ts +350 -0
  36. package/src/ui/cli/mcp-main.ts +13 -0
  37. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  38. package/src/ui/cli/named-target.ts +11 -0
  39. package/src/ui/cli/stack-command.ts +13 -40
  40. package/src/ui/cli/test-runner.ts +8 -1
  41. package/src/ui/mcp/api.ts +286 -0
  42. package/src/ui/mcp/connect.ts +304 -0
  43. package/src/ui/mcp/guards.ts +145 -0
  44. package/src/ui/mcp/server.ts +149 -0
  45. package/src/ui/mcp/tools.ts +644 -0
  46. package/src/ui/server/audit.ts +137 -0
  47. package/src/ui/server/changelog-parser.ts +11 -0
  48. package/src/ui/server/graph-prefs.ts +89 -0
  49. package/src/ui/server/health-row.ts +15 -0
  50. package/src/ui/server/index.ts +256 -61
  51. package/src/ui/server/lifecycle-exec.ts +188 -0
  52. package/src/ui/server/log-history.ts +4 -3
  53. package/src/ui/server/public/environments.html +4 -2
  54. package/src/ui/server/public/graph-model.js +416 -0
  55. package/src/ui/server/public/graph.css +980 -0
  56. package/src/ui/server/public/graph.js +986 -0
  57. package/src/ui/server/public/header.js +7 -5
  58. package/src/ui/server/public/local-dev.html +130 -2191
  59. package/src/ui/server/public/logs.html +11 -4
  60. package/src/ui/server/public/panel.js +790 -0
  61. package/src/ui/server/public/services.html +6 -1
  62. package/src/ui/server/readonly-routes.ts +209 -0
  63. package/src/ui/server/ui-assets.ts +73 -0
  64. package/src/ui/server/workspace-routes.ts +39 -8
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { composeHealthCmd } from "../../shared/stacks/manifest-commands.js";
14
+ import { healthCheckFields } from "./health-row.js";
14
15
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
15
16
  import * as http from "http";
16
17
  import * as fs from "fs";
@@ -52,7 +53,7 @@ import {
52
53
  } from "../../shared/environment-manager/workspaces.js";
53
54
  import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
54
55
  import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
55
- import { findConventionalContainer } from "../../shared/environment-manager/opctl.js";
56
+ import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
56
57
  import {
57
58
  resolveHistorySource,
58
59
  ensureSnapshot,
@@ -78,7 +79,10 @@ import {
78
79
  shouldDetachServiceCommand,
79
80
  } from "../../shared/environment-manager/service-runner.js";
80
81
  import { reapDeadDaemons } from "./daemon-reaper.js";
81
- import { readManifest } from "../../shared/manifest/read-manifest.js";
82
+ import {
83
+ loadManifest,
84
+ readManifest,
85
+ } from "../../shared/manifest/read-manifest.js";
82
86
  import {
83
87
  getServiceLogRoot,
84
88
  spawnStreaming,
@@ -86,6 +90,7 @@ import {
86
90
  } from "../../shared/process/spawn-streaming.js";
87
91
  import {
88
92
  readDenConfig,
93
+ isProductionEnv,
89
94
  isLocalEnabled,
90
95
  readUserConfig,
91
96
  writeUserConfig,
@@ -95,7 +100,7 @@ import {
95
100
  type ReleaseConfig,
96
101
  } from "../../shared/den-config.js";
97
102
  import { readOrg } from "../../shared/org.js";
98
- import { getDistRoot } from "../../shared/paths.js";
103
+ import { engineVersion, getDistRoot } from "../../shared/paths.js";
99
104
  import { enginePath } from "../../shared/engine.js";
100
105
  import {
101
106
  releaseService,
@@ -106,8 +111,18 @@ import {
106
111
  import { deploySessions } from "./release-deploy-session.js";
107
112
  import { spawnDetachedWebRestart } from "../cli/web-command.js";
108
113
  import { recordInvocation } from "../../shared/dev-metrics/emit.js";
109
- import { validateManifest } from "../../shared/manifest/validate-manifest.js";
114
+ import {
115
+ validateManifest,
116
+ validateManifestLayers,
117
+ } from "../../shared/manifest/validate-manifest.js";
110
118
  import { depService } from "../../shared/manifest/deps.js";
119
+ import {
120
+ placementRefusal,
121
+ resolveHealthCheck,
122
+ servicePlacement,
123
+ type Expect,
124
+ type Placement,
125
+ } from "../../shared/manifest/placement.js";
111
126
  import {
112
127
  isOpSignedIn,
113
128
  hasSudoInVault,
@@ -115,6 +130,17 @@ import {
115
130
  saveSudoToVault,
116
131
  } from "../../shared/auth/sudo-vault.js";
117
132
  import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
133
+ import { actorLabel } from "../../shared/agent-actor.js";
134
+ import {
135
+ actorFromHeaders,
136
+ actorSuffix,
137
+ recordAction,
138
+ recordAgentRefusal,
139
+ setAuditSink,
140
+ } from "./audit.js";
141
+ import { handleReadonlyRoutes } from "./readonly-routes.js";
142
+ import { serveUiAsset } from "./ui-assets.js";
143
+ import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
118
144
 
119
145
  const __filename = fileURLToPath(import.meta.url);
120
146
  const __dirname = path.dirname(__filename);
@@ -132,7 +158,10 @@ interface HealthResult {
132
158
  repo: string;
133
159
  service: string;
134
160
  label: string;
161
+ /** The HTTP check's URL, or null (never a command: see `check`). */
135
162
  url: string | null;
163
+ /** A shell check's command (e.g. a compose stack's `stack health`). */
164
+ check?: string;
136
165
  repoPath: string;
137
166
  mode: "dev-managed" | "managed";
138
167
  isEntrypoint: boolean;
@@ -143,6 +172,9 @@ interface HealthResult {
143
172
  openUrl?: string | null;
144
173
  /** Extra named links for this env. */
145
174
  links?: OpenLink[];
175
+ /** Where it runs for this env, and where a remote one is (R13). */
176
+ runs: Placement["runs"];
177
+ target: Placement["target"];
146
178
  }
147
179
 
148
180
  /**
@@ -267,6 +299,8 @@ interface LogEntry {
267
299
  ts: string;
268
300
  kind: string;
269
301
  text: string;
302
+ /** Set when an agent (via `<cli> mcp`) asked for this action: its label. */
303
+ actor?: string;
270
304
  }
271
305
 
272
306
  const LOG_RING_MAX = 500;
@@ -281,11 +315,12 @@ function stripAnsi(s: string): string {
281
315
  return s.replace(ANSI_RE, "");
282
316
  }
283
317
 
284
- function pushLog(kind: string, text: string): void {
318
+ function pushLog(kind: string, text: string, actor?: string): void {
285
319
  const entry: LogEntry = {
286
320
  ts: new Date().toISOString(),
287
321
  kind,
288
322
  text: stripAnsi(text),
323
+ ...(actor ? { actor } : {}),
289
324
  };
290
325
  logRing.push(entry);
291
326
  if (logRing.length > LOG_RING_MAX) logRing.shift();
@@ -302,11 +337,17 @@ function pushLog(kind: string, text: string): void {
302
337
  * Log to stdout unless --silent was passed, AND fan out to the log ring so the
303
338
  * /logs page and any connected SSE subscribers see the same stream.
304
339
  */
305
- function serverLog(msg: string, kind: string = "info"): void {
306
- pushLog(kind, msg);
340
+ function serverLog(msg: string, kind: string = "info", actor?: string): void {
341
+ pushLog(kind, msg, actor);
307
342
  if (!SILENT) process.stdout.write(msg + "\n");
308
343
  }
309
344
 
345
+ // Every audited action (exec, run, sync) lands in the log as a lifecycle
346
+ // line; an agent's carries its label so the /logs page can mark it.
347
+ setAuditSink((line, actor) =>
348
+ serverLog(line, "lifecycle", actor ? actorLabel(actor) : undefined),
349
+ );
350
+
310
351
  /** ANSI helpers (no chalk dependency in the server) */
311
352
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
312
353
  const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
@@ -369,6 +410,7 @@ import { parseChangelog } from "./changelog-parser.js";
369
410
  import { GitHubService } from "./github-service.js";
370
411
  import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
371
412
  import { handleWorkspaceRoutes } from "./workspace-routes.js";
413
+ import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
372
414
  import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
373
415
  import {
374
416
  linksFor,
@@ -552,6 +594,9 @@ async function handleRequest(
552
594
  return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
553
595
  }
554
596
 
597
+ // The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
598
+ if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
599
+
555
600
  if (pathname === "/perf-report.js") {
556
601
  return serveFile(res, path.join(PUBLIC_DIR, "perf-report.js"));
557
602
  }
@@ -687,6 +732,28 @@ async function handleRequest(
687
732
  }
688
733
  }
689
734
 
735
+ // ── API: an agent's refused call (F2) ─────────────────────────────────────
736
+ // The MCP server's guards refuse some calls before they reach an action
737
+ // route; it reports them here so the activity trail shows failed attempts.
738
+ // Agents only (the audit header), and the entry is all it can write.
739
+ if (pathname === "/api/activity" && req.method === "POST") {
740
+ const actor = actorFromHeaders(req.headers);
741
+ if (!actor) return sendJson(res, { error: "agents only" }, 403);
742
+ const raw = await readBody(req).catch(() => "");
743
+ if (raw.length > 4096) return send400(res, "report too large");
744
+ let report: Record<string, unknown> = {};
745
+ try {
746
+ report = JSON.parse(raw || "{}") as Record<string, unknown>;
747
+ } catch {
748
+ return send400(res, "invalid JSON");
749
+ }
750
+ const entry = recordAgentRefusal(actor, report);
751
+ return sendJson(res, { recorded: true, ts: entry.ts });
752
+ }
753
+
754
+ // ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
755
+ if (await handleReadonlyRoutes(req, res, pathname, url)) return;
756
+
690
757
  // ── API: test sources ─────────────────────────────────────────────────────
691
758
  // Returns the repos that contribute tests for a given pillar, with their
692
759
  // current branch + short SHA. Lets the Tests UI show "you are testing
@@ -827,6 +894,9 @@ async function handleRequest(
827
894
  links: OpenLink[];
828
895
  /** The GitHub org that owns the repo. */
829
896
  org: string;
897
+ /** Codes that count as up for a remote target.url probe. */
898
+ expect?: Expect;
899
+ meta: Pick<HealthResult, "runs" | "target">;
830
900
  }> = [];
831
901
  let edgeDomain: string | null = null;
832
902
  try {
@@ -858,6 +928,7 @@ async function handleRequest(
858
928
  openUrl: null,
859
929
  links: [],
860
930
  org: orgOf(resolved.repoName, catalog),
931
+ meta: { runs: "none", target: null },
861
932
  });
862
933
  continue;
863
934
  }
@@ -873,11 +944,16 @@ async function handleRequest(
873
944
  ws.name,
874
945
  )
875
946
  : null;
876
- const healthCfg =
877
- svcCfg?.health?.[env] ??
878
- (composeHealth
879
- ? { type: "shell" as const, cmd: composeHealth }
880
- : undefined);
947
+ // One resolution path with `<cli> status` (placement.ts): a remote
948
+ // service is checked by target.env's declaration, else target.url.
949
+ const plan = resolveHealthCheck(
950
+ manifest,
951
+ resolved.subsystem,
952
+ env,
953
+ composeHealth,
954
+ );
955
+ const healthCfg = plan.check;
956
+ const remote = plan.placement.runs === "remote";
881
957
  // Keep services with no health check (checkType "none") so systems like
882
958
  // jobs-runtime still appear — as an "unknown" (muted) dot, matching
883
959
  // `den status`, rather than vanishing from the health row.
@@ -896,7 +972,7 @@ async function handleRequest(
896
972
  deps,
897
973
  repo: resolved.repoName,
898
974
  service: resolved.subsystem,
899
- label: healthCfg?.label ?? resolved.subsystem,
975
+ label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
900
976
  checkType: healthCfg
901
977
  ? healthCfg.type === "shell"
902
978
  ? "shell"
@@ -914,7 +990,7 @@ async function handleRequest(
914
990
  // service with no declared local health check may still serve the stack
915
991
  // convention at http://<repo>/healthz via opctl DNS.
916
992
  conventional: (() => {
917
- if (healthCfg || env !== "local") return null;
993
+ if (healthCfg || env !== "local" || remote) return null;
918
994
  const keys = Object.keys(manifest.services ?? {});
919
995
  const isPrimary = manifest.entrypoint
920
996
  ? resolved.subsystem === manifest.entrypoint
@@ -932,6 +1008,8 @@ async function handleRequest(
932
1008
  }),
933
1009
  links: linksFor(manifest, resolved.subsystem, env),
934
1010
  org: orgOf(resolved.repoName, catalog),
1011
+ ...(plan.expect ? { expect: plan.expect } : {}),
1012
+ meta: { runs: plan.placement.runs, target: plan.placement.target },
935
1013
  });
936
1014
  }
937
1015
 
@@ -961,6 +1039,7 @@ async function handleRequest(
961
1039
  isEntrypoint,
962
1040
  conventional,
963
1041
  meta,
1042
+ expect,
964
1043
  }) => {
965
1044
  if (checkType !== "none") {
966
1045
  const started = Date.now();
@@ -969,15 +1048,16 @@ async function handleRequest(
969
1048
  ? await pingShell(target, awsProfileEnv(env), repoPath)
970
1049
  : await pingHttp(
971
1050
  target,
972
- env === "local"
1051
+ env === "local" && meta.runs !== "remote"
973
1052
  ? HEALTH_CHECK_TIMEOUT_MS
974
1053
  : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
1054
+ expect,
975
1055
  );
976
1056
  return {
977
1057
  repo,
978
1058
  service,
979
1059
  label,
980
- url: target,
1060
+ ...healthCheckFields(checkType, target),
981
1061
  repoPath,
982
1062
  mode,
983
1063
  isEntrypoint,
@@ -1131,23 +1211,28 @@ async function handleRequest(
1131
1211
 
1132
1212
  // Legacy: discovered repos from .den/ symlinks / registered-repos.json
1133
1213
  const legacyRepos = discoverRepos();
1134
- const legacyEntries = legacyRepos.flatMap((repo) =>
1135
- Object.entries(repo.manifest.services ?? {}).map(([svcName, svc]) => ({
1136
- repo: repo.repoName,
1137
- service: svcName,
1138
- address: `${repo.repoName}/${svcName}`,
1139
- repoPath: repo.repoPath,
1140
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1141
- logs: svc.logs?.[env] ?? null,
1142
- mode: "dev-managed" as const,
1143
- branch: null as string | null,
1144
- fromCatalog: false,
1145
- isEntrypoint: repo.manifest.entrypoint === svcName,
1146
- autostart: true, // legacy discovery has no workspace entry to toggle
1147
- deps: (svc.depends_on ?? []).map(depService),
1148
- cloned: true, // legacy discovery only ever finds on-disk repos
1149
- })),
1150
- );
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
+ });
1151
1236
 
1152
1237
  // Catalog entries (workspace-aware)
1153
1238
  const catalog = readCatalog();
@@ -1182,7 +1267,11 @@ async function handleRequest(
1182
1267
  ? listWorkspaceServices(ws.config, catalog, ws.name)
1183
1268
  .filter((r) => hasRuntime(repoKind(r.repoPath)))
1184
1269
  .flatMap((resolved) => {
1185
- const manifest = readManifest(resolved.repoPath);
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
+ );
1186
1275
  const svc = manifest?.services?.[resolved.subsystem];
1187
1276
  if (!manifest || !svc) {
1188
1277
  // Workspace membership is config-driven: a managed entry
@@ -1207,6 +1296,8 @@ async function handleRequest(
1207
1296
  owners: daemonOwners.get(resolved.address) ?? [],
1208
1297
  deps: [] as string[],
1209
1298
  cloned: fs.existsSync(resolved.repoPath),
1299
+ manifestLayers,
1300
+ ...servicePlacement(null, resolved.subsystem, env),
1210
1301
  },
1211
1302
  ];
1212
1303
  }
@@ -1228,6 +1319,8 @@ async function handleRequest(
1228
1319
  resolveDepAddress(depService(d), catalog, ws.config, ws.name),
1229
1320
  ),
1230
1321
  cloned: true,
1322
+ manifestLayers,
1323
+ ...servicePlacement(manifest, resolved.subsystem, env),
1231
1324
  },
1232
1325
  ];
1233
1326
  })
@@ -1289,7 +1382,15 @@ async function handleRequest(
1289
1382
  const emit = (obj: unknown): void => {
1290
1383
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
1291
1384
  };
1385
+ // Every fail here is before anything ran: a refusal or a lookup miss.
1386
+ // Recorded, so failed attempts show in the activity trail (F2).
1292
1387
  const fail = (msg: string): void => {
1388
+ recordAction(actorFromHeaders(req.headers), {
1389
+ verb: "exec",
1390
+ detail: `${repoName}/${svcName} ${cmdName} (${env})`,
1391
+ outcome: "refused",
1392
+ reason: msg,
1393
+ });
1293
1394
  emit({ type: "error", text: msg });
1294
1395
  emit({ type: "done", exitCode: 1, duration: 0 });
1295
1396
  res.end();
@@ -1316,10 +1417,70 @@ async function handleRequest(
1316
1417
  if (resolved) {
1317
1418
  repoPath = resolved.repoPath;
1318
1419
  const manifest = readManifest(repoPath);
1420
+ // R13: nothing runs a command on a remote service.
1421
+ const refusal = placementRefusal(
1422
+ `${repoName}/${svcName}`,
1423
+ servicePlacement(manifest, resolved.subsystem, env),
1424
+ (e) => isProductionEnv(e),
1425
+ cmdName,
1426
+ );
1427
+ if (refusal) return fail(refusal);
1319
1428
  if (manifest) {
1320
1429
  cmdDef =
1321
1430
  manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
1322
1431
  }
1432
+ // Lifecycle commands run through the service runner, as the CLI's do:
1433
+ // restart without a declared restart is down + up, up on a healthy
1434
+ // service starts nothing, down honours workspace ownership.
1435
+ if (env === "local" && isLifecycleExecCmd(cmdName)) {
1436
+ if (cmdDef && isInteractiveServiceCommand(cmdDef))
1437
+ return fail(
1438
+ `"${cmdName}" requires an interactive terminal (HITL).\n` +
1439
+ `Run in your terminal:\n ${product().cli} ${cmdName} ${resolved.address}`,
1440
+ );
1441
+ const execId = shortRunId();
1442
+ const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1443
+ const execActor = actorFromHeaders(req.headers);
1444
+ recordAction(execActor, {
1445
+ verb: "exec",
1446
+ detail: `${execDesc} [${execId}]`,
1447
+ });
1448
+ prewarmSudo();
1449
+ void streamLifecycleExec(
1450
+ {
1451
+ resolved,
1452
+ cmd: cmdName,
1453
+ workspace: wsParam ?? undefined,
1454
+ env: { E2E_ENVIRONMENT: env, ...getSudoEnv() },
1455
+ emit: (e) => {
1456
+ if (e.type === "line" && typeof e.text === "string")
1457
+ mirrorLine(execId, e.text);
1458
+ emit(e);
1459
+ },
1460
+ end: () => res.end(),
1461
+ onClientClose: (fn) => res.on("close", fn),
1462
+ onExit: (exitCode, duration) => {
1463
+ serverLog(
1464
+ `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]${actorSuffix(execActor)}`,
1465
+ "lifecycle",
1466
+ execActor ? actorLabel(execActor) : undefined,
1467
+ );
1468
+ recordInvocation({
1469
+ command: "service.exec",
1470
+ surface: "ux",
1471
+ durationMs: duration,
1472
+ exit: exitCode,
1473
+ extra: { action: cmdName, env },
1474
+ });
1475
+ },
1476
+ },
1477
+ {
1478
+ run: runServiceCommand,
1479
+ health: (r) => checkServiceHealth(r, "local"),
1480
+ },
1481
+ );
1482
+ return;
1483
+ }
1323
1484
  }
1324
1485
 
1325
1486
  if (!cmdDef) {
@@ -1327,6 +1488,15 @@ async function handleRequest(
1327
1488
  const repos = discoverRepos();
1328
1489
  const repo = repos.find((r) => r.repoName === repoName);
1329
1490
  repoPath = repo?.repoPath;
1491
+ const refusal = repo
1492
+ ? placementRefusal(
1493
+ `${repoName}/${svcName}`,
1494
+ servicePlacement(repo.manifest, svcName, env),
1495
+ (e) => isProductionEnv(e),
1496
+ cmdName,
1497
+ )
1498
+ : null;
1499
+ if (refusal) return fail(refusal);
1330
1500
  cmdDef = repo?.manifest.services?.[svcName]?.commands?.[env]?.[cmdName];
1331
1501
  }
1332
1502
 
@@ -1373,7 +1543,11 @@ async function handleRequest(
1373
1543
 
1374
1544
  const execId = shortRunId();
1375
1545
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1376
- serverLog(`▶ exec ${execDesc} [${execId}]`, "lifecycle");
1546
+ const execActor = actorFromHeaders(req.headers);
1547
+ recordAction(execActor, {
1548
+ verb: "exec",
1549
+ detail: `${execDesc} [${execId}]`,
1550
+ });
1377
1551
 
1378
1552
  // For detachable commands (up/restart), the service is spawned as a daemon
1379
1553
  // writing to a log file so it survives den restarts. The tail proc
@@ -1397,8 +1571,9 @@ async function handleRequest(
1397
1571
  if (!active) return; // client already closed — don't write to dead response
1398
1572
  const duration = Date.now() - startTime;
1399
1573
  serverLog(
1400
- `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]`,
1574
+ `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]${actorSuffix(execActor)}`,
1401
1575
  "lifecycle",
1576
+ execActor ? actorLabel(execActor) : undefined,
1402
1577
  );
1403
1578
  // Dev-tooling telemetry: one invocation per service command run. The
1404
1579
  // action is whitelisted to common lifecycle verbs (low cardinality);
@@ -1675,9 +1850,10 @@ async function handleRequest(
1675
1850
  //
1676
1851
  // Source resolution, in order:
1677
1852
  // 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
1678
- // 2. Conventional container derivation: a running `opctl_<base>_<hash>`
1679
- // container matched from the repo's op manifest / repo name →
1680
- // `docker logs -f` — real runtime logs with zero manifest config
1853
+ // 2. The service's running container → `docker logs -f`: a compose
1854
+ // stack's `<stack>-<workspace>-<service>-1`, else an
1855
+ // `opctl_<base>_<hash>` one matched from the repo's op manifest / repo
1856
+ // name — real runtime logs with zero manifest config
1681
1857
  // 3. The den daemon startup log (~/.den/logs/<repo>/<service>.log) —
1682
1858
  // last resort, covers host-run processes with no container
1683
1859
  // A manifest command that exits within the quick-exit window (container not
@@ -1818,13 +1994,13 @@ async function handleRequest(
1818
1994
  let retryCount = 0;
1819
1995
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
1820
1996
 
1821
- // Fallback tier 1: resolve the service's running container by opctl
1822
- // naming convention (`opctl_<base>_<hash>`) and stream `docker logs -f`.
1823
- // This gives REAL runtime logs for services with no (or broken)
1824
- // manifest `logs.local` — no den daemon file needed. Returns false
1825
- // when no conventional container is running.
1997
+ // Fallback tier 1: resolve the service's running container (its compose
1998
+ // stack's, else opctl's `opctl_<base>_<hash>`) and stream
1999
+ // `docker logs -f`. This gives REAL runtime logs for services with no (or
2000
+ // broken) manifest `logs.local` — no den daemon file needed. Returns
2001
+ // false when no such container is running.
1826
2002
  const startDockerFallback = (): boolean => {
1827
- const container = findConventionalContainer(
2003
+ const container = findServiceContainer(
1828
2004
  repoName,
1829
2005
  tailResolved?.repoPath,
1830
2006
  tailSubsystem,
@@ -2084,7 +2260,8 @@ async function handleRequest(
2084
2260
  const runId = shortRunId();
2085
2261
  const modDesc = modules.length ? modules.join(",") : "all";
2086
2262
  const runDesc = `${pillar}/${env} · modules=${modDesc}`;
2087
- serverLog(`▶ run ${runDesc} [${runId}]`, "lifecycle");
2263
+ const runActor = actorFromHeaders(req.headers);
2264
+ recordAction(runActor, { verb: "run", detail: `${runDesc} [${runId}]` });
2088
2265
  if (
2089
2266
  pillar === "synthetic" ||
2090
2267
  pillar === "smoke" ||
@@ -2108,8 +2285,9 @@ async function handleRequest(
2108
2285
  const dur = formatDuration(Date.now() - startTime);
2109
2286
  const extra = note ? ` · ${note}` : "";
2110
2287
  serverLog(
2111
- `■ run ${runDesc} · exit=${exitCode} · ${dur}${extra} [${runId}]`,
2288
+ `■ run ${runDesc} · exit=${exitCode} · ${dur}${extra} [${runId}]${actorSuffix(runActor)}`,
2112
2289
  "lifecycle",
2290
+ runActor ? actorLabel(runActor) : undefined,
2113
2291
  );
2114
2292
  // Dev-tooling telemetry: one invocation per test run (pillar/env are
2115
2293
  // low-cardinality enums; module count only — never module names/args).
@@ -2254,6 +2432,8 @@ async function handleRequest(
2254
2432
  return sendJson(res, {
2255
2433
  pid: process.pid,
2256
2434
  port: PORT,
2435
+ // Den's version: the MCP server compares it with the routes it needs.
2436
+ version: engineVersion() ?? null,
2257
2437
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
2258
2438
  uptimeMs: Date.now() - SERVER_STARTED_AT,
2259
2439
  stale,
@@ -2584,10 +2764,24 @@ async function handleRequest(
2584
2764
  const filePath = url.searchParams.get("path");
2585
2765
  if (!filePath) return send400(res, '"path" query param required');
2586
2766
  try {
2587
- const denYml = fs.existsSync(filePath)
2588
- ? fs.lstatSync(filePath).isDirectory()
2589
- ? path.join(filePath, `${product().manifestYml}`)
2590
- : filePath
2767
+ const isDir =
2768
+ fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory();
2769
+ // A repo folder: every layer that applies (repo or held, override,
2770
+ // merged), flattened into the usual shape; each error names its file.
2771
+ if (isDir) {
2772
+ const layered = validateManifestLayers(filePath);
2773
+ if (layered.layers.length > 0)
2774
+ return sendJson(res, {
2775
+ valid: layered.valid,
2776
+ version: layered.layers[0].result.version,
2777
+ errors: layered.layers.flatMap((l) => l.result.errors),
2778
+ warnings: layered.layers.flatMap((l) => l.result.warnings),
2779
+ manifestLayers: loadManifest(filePath).layers,
2780
+ layers: layered.layers,
2781
+ });
2782
+ }
2783
+ const denYml = isDir
2784
+ ? path.join(filePath, `${product().manifestYml}`)
2591
2785
  : filePath;
2592
2786
  const content = fs.readFileSync(denYml, "utf-8");
2593
2787
  return sendJson(res, validateManifest(content));
@@ -2726,7 +2920,8 @@ async function handleRequest(
2726
2920
 
2727
2921
  // ── API: user settings ────────────────────────────────────────────────────
2728
2922
  // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages }
2729
- // POST /api/settings body: { editor?, terminal?, editor_overrides? }
2923
+ // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph? }
2924
+ // graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
2730
2925
  if (pathname === "/api/settings") {
2731
2926
  if (req.method === "GET") {
2732
2927
  const user = readUserConfig();
@@ -2740,6 +2935,7 @@ async function handleRequest(
2740
2935
  editor_overrides: user.editor_overrides ?? {},
2741
2936
  languages: SUPPORTED_LANGUAGES,
2742
2937
  log_window_lines: user.log_window_lines ?? 20_000,
2938
+ graph: graphPrefs(user.graph),
2743
2939
  });
2744
2940
  }
2745
2941
  if (req.method === "POST") {
@@ -2748,8 +2944,14 @@ async function handleRequest(
2748
2944
  editor?: string | null;
2749
2945
  terminal?: string | null;
2750
2946
  editor_overrides?: Record<string, string> | null;
2947
+ graph?: unknown;
2751
2948
  };
2752
2949
  const user = readUserConfig();
2950
+ if (body.graph !== undefined) {
2951
+ const g = mergeGraphPrefs(graphPrefs(user.graph), body.graph);
2952
+ if (!g.ok) return send400(res, g.error);
2953
+ user.graph = g.value;
2954
+ }
2753
2955
  if (body.editor === null || body.editor === "") {
2754
2956
  delete user.editor;
2755
2957
  } else if (body.editor !== undefined) {
@@ -2793,6 +2995,7 @@ async function handleRequest(
2793
2995
  editor: user.editor ?? null,
2794
2996
  terminal: user.terminal ?? null,
2795
2997
  editor_overrides: user.editor_overrides ?? {},
2998
+ graph: graphPrefs(user.graph),
2796
2999
  });
2797
3000
  } catch (e) {
2798
3001
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -3078,15 +3281,7 @@ server.listen(PORT, BIND_HOST, () => {
3078
3281
  ? `${product().cli}`
3079
3282
  : "localhost";
3080
3283
 
3081
- let pkgVersion = "?";
3082
- try {
3083
- const pkg = JSON.parse(
3084
- fs.readFileSync(enginePath("package.json"), "utf-8"),
3085
- ) as { version?: string };
3086
- if (pkg.version) pkgVersion = pkg.version;
3087
- } catch (_e) {
3088
- /* package.json unreadable — version stays "?" */
3089
- }
3284
+ const pkgVersion = engineVersion() ?? "?";
3090
3285
 
3091
3286
  console.log();
3092
3287
  for (const line of DEN_BANNER_LINES) {