@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.
- package/CHANGELOG.md +204 -0
- package/README.md +1 -1
- package/bin/den +8 -0
- package/docs/distributions.md +283 -12
- package/package.json +4 -2
- package/src/pillars/journeys/sandbox.ts +2 -5
- package/src/pillars/security/discover.ts +17 -1
- package/src/pillars/security/run.ts +31 -3
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/den-config.ts +122 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/environment-manager/health.ts +54 -8
- package/src/shared/environment-manager/load-applications.ts +31 -1
- package/src/shared/environment-manager/log-tail.ts +5 -4
- package/src/shared/environment-manager/opctl.ts +2 -1
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-runner.ts +19 -2
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +25 -1
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/placement.ts +333 -0
- package/src/shared/manifest/read-manifest.ts +156 -38
- package/src/shared/manifest/schema.ts +14 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +66 -6
- package/src/shared/paths.ts +12 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/stacks/manifest-commands.ts +30 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/types/index.ts +23 -0
- package/src/ui/cli/applications-command.ts +34 -2
- package/src/ui/cli/edge-command.ts +9 -19
- package/src/ui/cli/graph-command.ts +50 -4
- package/src/ui/cli/index.ts +184 -55
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/stack-command.ts +13 -40
- package/src/ui/cli/test-runner.ts +8 -1
- package/src/ui/mcp/api.ts +286 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +145 -0
- package/src/ui/mcp/server.ts +149 -0
- package/src/ui/mcp/tools.ts +644 -0
- package/src/ui/server/audit.ts +137 -0
- package/src/ui/server/changelog-parser.ts +11 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/index.ts +256 -61
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/log-history.ts +4 -3
- package/src/ui/server/public/environments.html +4 -2
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +980 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +7 -5
- package/src/ui/server/public/local-dev.html +130 -2191
- package/src/ui/server/public/logs.html +11 -4
- package/src/ui/server/public/panel.js +790 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +209 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +39 -8
package/src/ui/server/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1679
|
-
//
|
|
1680
|
-
// `
|
|
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
|
|
1822
|
-
//
|
|
1823
|
-
// This gives REAL runtime logs for services with no (or
|
|
1824
|
-
// manifest `logs.local` — no den daemon file needed. Returns
|
|
1825
|
-
// when no
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
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
|
-
|
|
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) {
|