@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.
- package/CHANGELOG.md +137 -0
- package/README.md +1 -1
- package/bin/den +7 -0
- package/docs/distributions.md +255 -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 +16 -1
- 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 +321 -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 +131 -45
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -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 +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -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 +175 -61
- 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 +976 -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 +787 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/ui-assets.ts +73 -0
- 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
|
+
}
|
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,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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1679
|
-
//
|
|
1680
|
-
// `
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
94
|
-
|
|
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.
|