@monster-orange/den 0.1.0 → 0.2.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 (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -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 +16 -1
  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 +321 -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 +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Graph preferences: how the Local Dev graph is laid out for this engineer.
3
+ * Stored in the user config as `graph:` and served by /api/settings, so the
4
+ * layout is remembered between sessions (the page mirrors it to localStorage
5
+ * so the first paint does not wait on the server).
6
+ */
7
+
8
+ export interface GraphPrefs {
9
+ /** Layout direction: top-down or left-right. */
10
+ direction: "down" | "right";
11
+ /** The outer frames: by org or by application. */
12
+ group: "org" | "app";
13
+ /** Card size: detailed (236×104) or compact (176×52). */
14
+ density: "detailed" | "compact";
15
+ /** Details panel width in px. */
16
+ panelWidth: number;
17
+ }
18
+
19
+ export const GRAPH_DEFAULTS: GraphPrefs = {
20
+ direction: "down",
21
+ group: "org",
22
+ density: "detailed",
23
+ panelWidth: 440,
24
+ };
25
+
26
+ const CHOICES = {
27
+ direction: ["down", "right"],
28
+ group: ["org", "app"],
29
+ density: ["detailed", "compact"],
30
+ } as const;
31
+
32
+ const PANEL_MIN = 360;
33
+ const PANEL_MAX = 760;
34
+
35
+ type Check = { ok: true; value: unknown } | { ok: false; error: string };
36
+
37
+ function check(key: keyof GraphPrefs, v: unknown): Check {
38
+ if (key === "panelWidth") {
39
+ if (typeof v !== "number" || !Number.isFinite(v))
40
+ return { ok: false, error: panelError() };
41
+ const w = Math.round(v);
42
+ if (w < PANEL_MIN || w > PANEL_MAX)
43
+ return { ok: false, error: panelError() };
44
+ return { ok: true, value: w };
45
+ }
46
+ const allowed: readonly string[] = CHOICES[key];
47
+ if (typeof v !== "string" || !allowed.includes(v))
48
+ return {
49
+ ok: false,
50
+ error: `graph.${key} must be one of ${allowed.map((a) => `"${a}"`).join(", ")}`,
51
+ };
52
+ return { ok: true, value: v };
53
+ }
54
+
55
+ function panelError(): string {
56
+ return `graph.panelWidth must be a number from ${PANEL_MIN} to ${PANEL_MAX}`;
57
+ }
58
+
59
+ const isObject = (v: unknown): v is Record<string, unknown> =>
60
+ typeof v === "object" && v !== null && !Array.isArray(v);
61
+
62
+ /** The stored block, normalised: a missing or hand-broken field is its default. */
63
+ export function graphPrefs(stored: unknown): GraphPrefs {
64
+ const out: GraphPrefs = { ...GRAPH_DEFAULTS };
65
+ if (!isObject(stored)) return out;
66
+ for (const key of Object.keys(GRAPH_DEFAULTS) as (keyof GraphPrefs)[]) {
67
+ if (!(key in stored)) continue;
68
+ const c = check(key, stored[key]);
69
+ if (c.ok) (out as unknown as Record<string, unknown>)[key] = c.value;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Merge a POSTed (partial) block over the current prefs; any bad field fails it. */
75
+ export function mergeGraphPrefs(
76
+ current: GraphPrefs,
77
+ patch: unknown,
78
+ ): { ok: true; value: GraphPrefs } | { ok: false; error: string } {
79
+ if (!isObject(patch)) return { ok: false, error: "graph must be an object" };
80
+ const out: GraphPrefs = { ...current };
81
+ for (const [key, v] of Object.entries(patch)) {
82
+ if (!(key in GRAPH_DEFAULTS))
83
+ return { ok: false, error: `unknown graph setting "${key}"` };
84
+ const c = check(key as keyof GraphPrefs, v);
85
+ if (!c.ok) return c;
86
+ (out as unknown as Record<string, unknown>)[key] = c.value;
87
+ }
88
+ return { ok: true, value: out };
89
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The fields of a /api/health row that say what was checked. `url` is only
3
+ * ever a URL (or null): a shell check's command (a compose stack's
4
+ * `<cli> stack health …`, or any declared shell check) goes in `check`.
5
+ */
6
+ export type HealthCheckType = "shell" | "http" | "none";
7
+
8
+ export function healthCheckFields(
9
+ checkType: HealthCheckType,
10
+ target: string,
11
+ ): { url: string | null; check?: string } {
12
+ if (checkType === "shell") return { url: null, check: target };
13
+ if (checkType === "http") return { url: target };
14
+ return { url: null };
15
+ }
@@ -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,16 @@ 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
+ setAuditSink,
139
+ } from "./audit.js";
140
+ import { handleReadonlyRoutes } from "./readonly-routes.js";
141
+ import { serveUiAsset } from "./ui-assets.js";
142
+ import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
118
143
 
119
144
  const __filename = fileURLToPath(import.meta.url);
120
145
  const __dirname = path.dirname(__filename);
@@ -132,7 +157,10 @@ interface HealthResult {
132
157
  repo: string;
133
158
  service: string;
134
159
  label: string;
160
+ /** The HTTP check's URL, or null (never a command: see `check`). */
135
161
  url: string | null;
162
+ /** A shell check's command (e.g. a compose stack's `stack health`). */
163
+ check?: string;
136
164
  repoPath: string;
137
165
  mode: "dev-managed" | "managed";
138
166
  isEntrypoint: boolean;
@@ -143,6 +171,9 @@ interface HealthResult {
143
171
  openUrl?: string | null;
144
172
  /** Extra named links for this env. */
145
173
  links?: OpenLink[];
174
+ /** Where it runs for this env, and where a remote one is (R13). */
175
+ runs: Placement["runs"];
176
+ target: Placement["target"];
146
177
  }
147
178
 
148
179
  /**
@@ -267,6 +298,8 @@ interface LogEntry {
267
298
  ts: string;
268
299
  kind: string;
269
300
  text: string;
301
+ /** Set when an agent (via `<cli> mcp`) asked for this action: its label. */
302
+ actor?: string;
270
303
  }
271
304
 
272
305
  const LOG_RING_MAX = 500;
@@ -281,11 +314,12 @@ function stripAnsi(s: string): string {
281
314
  return s.replace(ANSI_RE, "");
282
315
  }
283
316
 
284
- function pushLog(kind: string, text: string): void {
317
+ function pushLog(kind: string, text: string, actor?: string): void {
285
318
  const entry: LogEntry = {
286
319
  ts: new Date().toISOString(),
287
320
  kind,
288
321
  text: stripAnsi(text),
322
+ ...(actor ? { actor } : {}),
289
323
  };
290
324
  logRing.push(entry);
291
325
  if (logRing.length > LOG_RING_MAX) logRing.shift();
@@ -302,11 +336,17 @@ function pushLog(kind: string, text: string): void {
302
336
  * Log to stdout unless --silent was passed, AND fan out to the log ring so the
303
337
  * /logs page and any connected SSE subscribers see the same stream.
304
338
  */
305
- function serverLog(msg: string, kind: string = "info"): void {
306
- pushLog(kind, msg);
339
+ function serverLog(msg: string, kind: string = "info", actor?: string): void {
340
+ pushLog(kind, msg, actor);
307
341
  if (!SILENT) process.stdout.write(msg + "\n");
308
342
  }
309
343
 
344
+ // Every audited action (exec, run, sync) lands in the log as a lifecycle
345
+ // line; an agent's carries its label so the /logs page can mark it.
346
+ setAuditSink((line, actor) =>
347
+ serverLog(line, "lifecycle", actor ? actorLabel(actor) : undefined),
348
+ );
349
+
310
350
  /** ANSI helpers (no chalk dependency in the server) */
311
351
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
312
352
  const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
@@ -552,6 +592,9 @@ async function handleRequest(
552
592
  return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
553
593
  }
554
594
 
595
+ // The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
596
+ if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
597
+
555
598
  if (pathname === "/perf-report.js") {
556
599
  return serveFile(res, path.join(PUBLIC_DIR, "perf-report.js"));
557
600
  }
@@ -687,6 +730,9 @@ async function handleRequest(
687
730
  }
688
731
  }
689
732
 
733
+ // ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
734
+ if (await handleReadonlyRoutes(req, res, pathname, url)) return;
735
+
690
736
  // ── API: test sources ─────────────────────────────────────────────────────
691
737
  // Returns the repos that contribute tests for a given pillar, with their
692
738
  // current branch + short SHA. Lets the Tests UI show "you are testing
@@ -827,6 +873,9 @@ async function handleRequest(
827
873
  links: OpenLink[];
828
874
  /** The GitHub org that owns the repo. */
829
875
  org: string;
876
+ /** Codes that count as up for a remote target.url probe. */
877
+ expect?: Expect;
878
+ meta: Pick<HealthResult, "runs" | "target">;
830
879
  }> = [];
831
880
  let edgeDomain: string | null = null;
832
881
  try {
@@ -858,6 +907,7 @@ async function handleRequest(
858
907
  openUrl: null,
859
908
  links: [],
860
909
  org: orgOf(resolved.repoName, catalog),
910
+ meta: { runs: "none", target: null },
861
911
  });
862
912
  continue;
863
913
  }
@@ -873,11 +923,16 @@ async function handleRequest(
873
923
  ws.name,
874
924
  )
875
925
  : null;
876
- const healthCfg =
877
- svcCfg?.health?.[env] ??
878
- (composeHealth
879
- ? { type: "shell" as const, cmd: composeHealth }
880
- : undefined);
926
+ // One resolution path with `<cli> status` (placement.ts): a remote
927
+ // service is checked by target.env's declaration, else target.url.
928
+ const plan = resolveHealthCheck(
929
+ manifest,
930
+ resolved.subsystem,
931
+ env,
932
+ composeHealth,
933
+ );
934
+ const healthCfg = plan.check;
935
+ const remote = plan.placement.runs === "remote";
881
936
  // Keep services with no health check (checkType "none") so systems like
882
937
  // jobs-runtime still appear — as an "unknown" (muted) dot, matching
883
938
  // `den status`, rather than vanishing from the health row.
@@ -896,7 +951,7 @@ async function handleRequest(
896
951
  deps,
897
952
  repo: resolved.repoName,
898
953
  service: resolved.subsystem,
899
- label: healthCfg?.label ?? resolved.subsystem,
954
+ label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
900
955
  checkType: healthCfg
901
956
  ? healthCfg.type === "shell"
902
957
  ? "shell"
@@ -914,7 +969,7 @@ async function handleRequest(
914
969
  // service with no declared local health check may still serve the stack
915
970
  // convention at http://<repo>/healthz via opctl DNS.
916
971
  conventional: (() => {
917
- if (healthCfg || env !== "local") return null;
972
+ if (healthCfg || env !== "local" || remote) return null;
918
973
  const keys = Object.keys(manifest.services ?? {});
919
974
  const isPrimary = manifest.entrypoint
920
975
  ? resolved.subsystem === manifest.entrypoint
@@ -932,6 +987,8 @@ async function handleRequest(
932
987
  }),
933
988
  links: linksFor(manifest, resolved.subsystem, env),
934
989
  org: orgOf(resolved.repoName, catalog),
990
+ ...(plan.expect ? { expect: plan.expect } : {}),
991
+ meta: { runs: plan.placement.runs, target: plan.placement.target },
935
992
  });
936
993
  }
937
994
 
@@ -961,6 +1018,7 @@ async function handleRequest(
961
1018
  isEntrypoint,
962
1019
  conventional,
963
1020
  meta,
1021
+ expect,
964
1022
  }) => {
965
1023
  if (checkType !== "none") {
966
1024
  const started = Date.now();
@@ -969,15 +1027,16 @@ async function handleRequest(
969
1027
  ? await pingShell(target, awsProfileEnv(env), repoPath)
970
1028
  : await pingHttp(
971
1029
  target,
972
- env === "local"
1030
+ env === "local" && meta.runs !== "remote"
973
1031
  ? HEALTH_CHECK_TIMEOUT_MS
974
1032
  : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
1033
+ expect,
975
1034
  );
976
1035
  return {
977
1036
  repo,
978
1037
  service,
979
1038
  label,
980
- url: target,
1039
+ ...healthCheckFields(checkType, target),
981
1040
  repoPath,
982
1041
  mode,
983
1042
  isEntrypoint,
@@ -1131,23 +1190,28 @@ async function handleRequest(
1131
1190
 
1132
1191
  // Legacy: discovered repos from .den/ symlinks / registered-repos.json
1133
1192
  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
- );
1193
+ const legacyEntries = legacyRepos.flatMap((repo) => {
1194
+ const manifestLayers = loadManifest(repo.repoPath).layers;
1195
+ return Object.entries(repo.manifest.services ?? {}).map(
1196
+ ([svcName, svc]) => ({
1197
+ repo: repo.repoName,
1198
+ service: svcName,
1199
+ address: `${repo.repoName}/${svcName}`,
1200
+ repoPath: repo.repoPath,
1201
+ commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
1202
+ logs: svc.logs?.[env] ?? null,
1203
+ mode: "dev-managed" as const,
1204
+ branch: null as string | null,
1205
+ fromCatalog: false,
1206
+ isEntrypoint: repo.manifest.entrypoint === svcName,
1207
+ autostart: true, // legacy discovery has no workspace entry to toggle
1208
+ deps: (svc.depends_on ?? []).map(depService),
1209
+ cloned: true, // legacy discovery only ever finds on-disk repos
1210
+ manifestLayers,
1211
+ ...servicePlacement(repo.manifest, svcName, env),
1212
+ }),
1213
+ );
1214
+ });
1151
1215
 
1152
1216
  // Catalog entries (workspace-aware)
1153
1217
  const catalog = readCatalog();
@@ -1182,7 +1246,11 @@ async function handleRequest(
1182
1246
  ? listWorkspaceServices(ws.config, catalog, ws.name)
1183
1247
  .filter((r) => hasRuntime(repoKind(r.repoPath)))
1184
1248
  .flatMap((resolved) => {
1185
- const manifest = readManifest(resolved.repoPath);
1249
+ // The manifest and the layers it was built from: repo or held,
1250
+ // plus override when manifests/<repo>.override.yml applied.
1251
+ const { manifest, layers: manifestLayers } = loadManifest(
1252
+ resolved.repoPath,
1253
+ );
1186
1254
  const svc = manifest?.services?.[resolved.subsystem];
1187
1255
  if (!manifest || !svc) {
1188
1256
  // Workspace membership is config-driven: a managed entry
@@ -1207,6 +1275,8 @@ async function handleRequest(
1207
1275
  owners: daemonOwners.get(resolved.address) ?? [],
1208
1276
  deps: [] as string[],
1209
1277
  cloned: fs.existsSync(resolved.repoPath),
1278
+ manifestLayers,
1279
+ ...servicePlacement(null, resolved.subsystem, env),
1210
1280
  },
1211
1281
  ];
1212
1282
  }
@@ -1228,6 +1298,8 @@ async function handleRequest(
1228
1298
  resolveDepAddress(depService(d), catalog, ws.config, ws.name),
1229
1299
  ),
1230
1300
  cloned: true,
1301
+ manifestLayers,
1302
+ ...servicePlacement(manifest, resolved.subsystem, env),
1231
1303
  },
1232
1304
  ];
1233
1305
  })
@@ -1316,6 +1388,14 @@ async function handleRequest(
1316
1388
  if (resolved) {
1317
1389
  repoPath = resolved.repoPath;
1318
1390
  const manifest = readManifest(repoPath);
1391
+ // R13: nothing runs a command on a remote service.
1392
+ const refusal = placementRefusal(
1393
+ `${repoName}/${svcName}`,
1394
+ servicePlacement(manifest, resolved.subsystem, env),
1395
+ (e) => isProductionEnv(e),
1396
+ cmdName === "down" ? "stop" : "start",
1397
+ );
1398
+ if (refusal) return fail(refusal);
1319
1399
  if (manifest) {
1320
1400
  cmdDef =
1321
1401
  manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
@@ -1327,6 +1407,15 @@ async function handleRequest(
1327
1407
  const repos = discoverRepos();
1328
1408
  const repo = repos.find((r) => r.repoName === repoName);
1329
1409
  repoPath = repo?.repoPath;
1410
+ const refusal = repo
1411
+ ? placementRefusal(
1412
+ `${repoName}/${svcName}`,
1413
+ servicePlacement(repo.manifest, svcName, env),
1414
+ (e) => isProductionEnv(e),
1415
+ cmdName === "down" ? "stop" : "start",
1416
+ )
1417
+ : null;
1418
+ if (refusal) return fail(refusal);
1330
1419
  cmdDef = repo?.manifest.services?.[svcName]?.commands?.[env]?.[cmdName];
1331
1420
  }
1332
1421
 
@@ -1373,7 +1462,11 @@ async function handleRequest(
1373
1462
 
1374
1463
  const execId = shortRunId();
1375
1464
  const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
1376
- serverLog(`▶ exec ${execDesc} [${execId}]`, "lifecycle");
1465
+ const execActor = actorFromHeaders(req.headers);
1466
+ recordAction(execActor, {
1467
+ verb: "exec",
1468
+ detail: `${execDesc} [${execId}]`,
1469
+ });
1377
1470
 
1378
1471
  // For detachable commands (up/restart), the service is spawned as a daemon
1379
1472
  // writing to a log file so it survives den restarts. The tail proc
@@ -1397,8 +1490,9 @@ async function handleRequest(
1397
1490
  if (!active) return; // client already closed — don't write to dead response
1398
1491
  const duration = Date.now() - startTime;
1399
1492
  serverLog(
1400
- `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]`,
1493
+ `■ exec ${execDesc} · exit=${exitCode} · ${formatDuration(duration)} [${execId}]${actorSuffix(execActor)}`,
1401
1494
  "lifecycle",
1495
+ execActor ? actorLabel(execActor) : undefined,
1402
1496
  );
1403
1497
  // Dev-tooling telemetry: one invocation per service command run. The
1404
1498
  // action is whitelisted to common lifecycle verbs (low cardinality);
@@ -1675,9 +1769,10 @@ async function handleRequest(
1675
1769
  //
1676
1770
  // Source resolution, in order:
1677
1771
  // 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
1772
+ // 2. The service's running container → `docker logs -f`: a compose
1773
+ // stack's `<stack>-<workspace>-<service>-1`, else an
1774
+ // `opctl_<base>_<hash>` one matched from the repo's op manifest / repo
1775
+ // name — real runtime logs with zero manifest config
1681
1776
  // 3. The den daemon startup log (~/.den/logs/<repo>/<service>.log) —
1682
1777
  // last resort, covers host-run processes with no container
1683
1778
  // A manifest command that exits within the quick-exit window (container not
@@ -1818,13 +1913,13 @@ async function handleRequest(
1818
1913
  let retryCount = 0;
1819
1914
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
1820
1915
 
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.
1916
+ // Fallback tier 1: resolve the service's running container (its compose
1917
+ // stack's, else opctl's `opctl_<base>_<hash>`) and stream
1918
+ // `docker logs -f`. This gives REAL runtime logs for services with no (or
1919
+ // broken) manifest `logs.local` — no den daemon file needed. Returns
1920
+ // false when no such container is running.
1826
1921
  const startDockerFallback = (): boolean => {
1827
- const container = findConventionalContainer(
1922
+ const container = findServiceContainer(
1828
1923
  repoName,
1829
1924
  tailResolved?.repoPath,
1830
1925
  tailSubsystem,
@@ -2084,7 +2179,8 @@ async function handleRequest(
2084
2179
  const runId = shortRunId();
2085
2180
  const modDesc = modules.length ? modules.join(",") : "all";
2086
2181
  const runDesc = `${pillar}/${env} · modules=${modDesc}`;
2087
- serverLog(`▶ run ${runDesc} [${runId}]`, "lifecycle");
2182
+ const runActor = actorFromHeaders(req.headers);
2183
+ recordAction(runActor, { verb: "run", detail: `${runDesc} [${runId}]` });
2088
2184
  if (
2089
2185
  pillar === "synthetic" ||
2090
2186
  pillar === "smoke" ||
@@ -2108,8 +2204,9 @@ async function handleRequest(
2108
2204
  const dur = formatDuration(Date.now() - startTime);
2109
2205
  const extra = note ? ` · ${note}` : "";
2110
2206
  serverLog(
2111
- `■ run ${runDesc} · exit=${exitCode} · ${dur}${extra} [${runId}]`,
2207
+ `■ run ${runDesc} · exit=${exitCode} · ${dur}${extra} [${runId}]${actorSuffix(runActor)}`,
2112
2208
  "lifecycle",
2209
+ runActor ? actorLabel(runActor) : undefined,
2113
2210
  );
2114
2211
  // Dev-tooling telemetry: one invocation per test run (pillar/env are
2115
2212
  // low-cardinality enums; module count only — never module names/args).
@@ -2254,6 +2351,8 @@ async function handleRequest(
2254
2351
  return sendJson(res, {
2255
2352
  pid: process.pid,
2256
2353
  port: PORT,
2354
+ // Den's version: the MCP server compares it with the routes it needs.
2355
+ version: engineVersion() ?? null,
2257
2356
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
2258
2357
  uptimeMs: Date.now() - SERVER_STARTED_AT,
2259
2358
  stale,
@@ -2584,10 +2683,24 @@ async function handleRequest(
2584
2683
  const filePath = url.searchParams.get("path");
2585
2684
  if (!filePath) return send400(res, '"path" query param required');
2586
2685
  try {
2587
- const denYml = fs.existsSync(filePath)
2588
- ? fs.lstatSync(filePath).isDirectory()
2589
- ? path.join(filePath, `${product().manifestYml}`)
2590
- : filePath
2686
+ const isDir =
2687
+ fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory();
2688
+ // A repo folder: every layer that applies (repo or held, override,
2689
+ // merged), flattened into the usual shape; each error names its file.
2690
+ if (isDir) {
2691
+ const layered = validateManifestLayers(filePath);
2692
+ if (layered.layers.length > 0)
2693
+ return sendJson(res, {
2694
+ valid: layered.valid,
2695
+ version: layered.layers[0].result.version,
2696
+ errors: layered.layers.flatMap((l) => l.result.errors),
2697
+ warnings: layered.layers.flatMap((l) => l.result.warnings),
2698
+ manifestLayers: loadManifest(filePath).layers,
2699
+ layers: layered.layers,
2700
+ });
2701
+ }
2702
+ const denYml = isDir
2703
+ ? path.join(filePath, `${product().manifestYml}`)
2591
2704
  : filePath;
2592
2705
  const content = fs.readFileSync(denYml, "utf-8");
2593
2706
  return sendJson(res, validateManifest(content));
@@ -2726,7 +2839,8 @@ async function handleRequest(
2726
2839
 
2727
2840
  // ── API: user settings ────────────────────────────────────────────────────
2728
2841
  // GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages }
2729
- // POST /api/settings body: { editor?, terminal?, editor_overrides? }
2842
+ // POST /api/settings body: { editor?, terminal?, editor_overrides?, graph? }
2843
+ // graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
2730
2844
  if (pathname === "/api/settings") {
2731
2845
  if (req.method === "GET") {
2732
2846
  const user = readUserConfig();
@@ -2740,6 +2854,7 @@ async function handleRequest(
2740
2854
  editor_overrides: user.editor_overrides ?? {},
2741
2855
  languages: SUPPORTED_LANGUAGES,
2742
2856
  log_window_lines: user.log_window_lines ?? 20_000,
2857
+ graph: graphPrefs(user.graph),
2743
2858
  });
2744
2859
  }
2745
2860
  if (req.method === "POST") {
@@ -2748,8 +2863,14 @@ async function handleRequest(
2748
2863
  editor?: string | null;
2749
2864
  terminal?: string | null;
2750
2865
  editor_overrides?: Record<string, string> | null;
2866
+ graph?: unknown;
2751
2867
  };
2752
2868
  const user = readUserConfig();
2869
+ if (body.graph !== undefined) {
2870
+ const g = mergeGraphPrefs(graphPrefs(user.graph), body.graph);
2871
+ if (!g.ok) return send400(res, g.error);
2872
+ user.graph = g.value;
2873
+ }
2753
2874
  if (body.editor === null || body.editor === "") {
2754
2875
  delete user.editor;
2755
2876
  } else if (body.editor !== undefined) {
@@ -2793,6 +2914,7 @@ async function handleRequest(
2793
2914
  editor: user.editor ?? null,
2794
2915
  terminal: user.terminal ?? null,
2795
2916
  editor_overrides: user.editor_overrides ?? {},
2917
+ graph: graphPrefs(user.graph),
2796
2918
  });
2797
2919
  } catch (e) {
2798
2920
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -3078,15 +3200,7 @@ server.listen(PORT, BIND_HOST, () => {
3078
3200
  ? `${product().cli}`
3079
3201
  : "localhost";
3080
3202
 
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
- }
3203
+ const pkgVersion = engineVersion() ?? "?";
3090
3204
 
3091
3205
  console.log();
3092
3206
  for (const line of DEN_BANNER_LINES) {
@@ -20,7 +20,7 @@ import * as fs from "fs";
20
20
  import * as path from "path";
21
21
  import { execFile, spawnSync } from "child_process";
22
22
 
23
- import { findConventionalContainer } from "../../shared/environment-manager/opctl.js";
23
+ import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
24
24
  import {
25
25
  resolveService,
26
26
  hasRunningOpctlContainers,
@@ -90,8 +90,9 @@ export function resolveHistorySource(
90
90
  resolveService(`${repoName}/${svcName}`) ?? resolveService(repoName);
91
91
  const repoPath = resolved?.repoPath;
92
92
 
93
- // 1. Conventional opctl container (opctl_<base>_<hash>).
94
- const conventional = findConventionalContainer(repoName, repoPath, svcName);
93
+ // 1. The service's container: its compose stack's, else opctl's
94
+ // conventional one (opctl_<base>_<hash>).
95
+ const conventional = findServiceContainer(repoName, repoPath, svcName);
95
96
  if (conventional) return { kind: "docker", container: conventional };
96
97
 
97
98
  // 2. Container named by the manifest log command's docker ps filter.