@monster-orange/den 0.2.0 → 0.2.2
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 +156 -0
- package/README.md +1 -1
- package/bin/den +3 -2
- package/docs/distributions.md +101 -9
- package/package.json +1 -1
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +218 -4
- package/src/shared/manifest/placement.ts +15 -3
- package/src/shared/manifest/validate-layers.ts +40 -1
- package/src/shared/stacks/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +20 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +238 -45
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +9 -0
- package/src/ui/mcp/api.ts +2 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +6 -2
- package/src/ui/mcp/server.ts +84 -1
- package/src/ui/mcp/tools.ts +89 -10
- package/src/ui/server/audit.ts +69 -3
- package/src/ui/server/index.ts +83 -2
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/public/graph.css +4 -0
- package/src/ui/server/public/panel.js +6 -3
- package/src/ui/server/readonly-routes.ts +11 -2
- package/src/ui/server/workspace-routes.ts +1 -1
package/src/ui/server/index.ts
CHANGED
|
@@ -135,6 +135,7 @@ import {
|
|
|
135
135
|
actorFromHeaders,
|
|
136
136
|
actorSuffix,
|
|
137
137
|
recordAction,
|
|
138
|
+
recordAgentRefusal,
|
|
138
139
|
setAuditSink,
|
|
139
140
|
} from "./audit.js";
|
|
140
141
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
@@ -409,6 +410,7 @@ import { parseChangelog } from "./changelog-parser.js";
|
|
|
409
410
|
import { GitHubService } from "./github-service.js";
|
|
410
411
|
import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
411
412
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
413
|
+
import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
|
|
412
414
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
413
415
|
import {
|
|
414
416
|
linksFor,
|
|
@@ -730,6 +732,25 @@ async function handleRequest(
|
|
|
730
732
|
}
|
|
731
733
|
}
|
|
732
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
|
+
|
|
733
754
|
// ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
|
|
734
755
|
if (await handleReadonlyRoutes(req, res, pathname, url)) return;
|
|
735
756
|
|
|
@@ -1361,7 +1382,15 @@ async function handleRequest(
|
|
|
1361
1382
|
const emit = (obj: unknown): void => {
|
|
1362
1383
|
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
1363
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).
|
|
1364
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
|
+
});
|
|
1365
1394
|
emit({ type: "error", text: msg });
|
|
1366
1395
|
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1367
1396
|
res.end();
|
|
@@ -1393,13 +1422,65 @@ async function handleRequest(
|
|
|
1393
1422
|
`${repoName}/${svcName}`,
|
|
1394
1423
|
servicePlacement(manifest, resolved.subsystem, env),
|
|
1395
1424
|
(e) => isProductionEnv(e),
|
|
1396
|
-
cmdName
|
|
1425
|
+
cmdName,
|
|
1397
1426
|
);
|
|
1398
1427
|
if (refusal) return fail(refusal);
|
|
1399
1428
|
if (manifest) {
|
|
1400
1429
|
cmdDef =
|
|
1401
1430
|
manifest.services?.[resolved.subsystem]?.commands?.[env]?.[cmdName];
|
|
1402
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
|
+
}
|
|
1403
1484
|
}
|
|
1404
1485
|
|
|
1405
1486
|
if (!cmdDef) {
|
|
@@ -1412,7 +1493,7 @@ async function handleRequest(
|
|
|
1412
1493
|
`${repoName}/${svcName}`,
|
|
1413
1494
|
servicePlacement(repo.manifest, svcName, env),
|
|
1414
1495
|
(e) => isProductionEnv(e),
|
|
1415
|
-
cmdName
|
|
1496
|
+
cmdName,
|
|
1416
1497
|
)
|
|
1417
1498
|
: null;
|
|
1418
1499
|
if (refusal) return fail(refusal);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/service/exec` for the lifecycle commands (up, down, restart): run
|
|
3
|
+
* through the service runner, as the CLI does, instead of executing the
|
|
4
|
+
* manifest's command string directly. That gives the web UI and MCP the CLI's
|
|
5
|
+
* semantics:
|
|
6
|
+
*
|
|
7
|
+
* - restart without a declared `restart` is down + up;
|
|
8
|
+
* - up on a service that is already healthy starts nothing ("already up");
|
|
9
|
+
* - up attaches to a running shared instance instead of starting a second;
|
|
10
|
+
* - down honours workspace ownership (refcounted).
|
|
11
|
+
*
|
|
12
|
+
* Events are the route's existing SSE shapes: line, started, service-exited,
|
|
13
|
+
* error, done; plus `ready` once a detached start is up: its health check
|
|
14
|
+
* passes (`checked: true`), or, with no health check, its launcher is still
|
|
15
|
+
* running after a settle window (`checked: false`). A launcher that keeps
|
|
16
|
+
* running in the foreground (a dev server) never sends `service-exited`, so
|
|
17
|
+
* `ready` is how a caller knows the start worked.
|
|
18
|
+
*/
|
|
19
|
+
import type { ChildProcess } from "child_process";
|
|
20
|
+
import type { ResolvedService } from "../../shared/types/index.js";
|
|
21
|
+
import type {
|
|
22
|
+
RunCommandOptions,
|
|
23
|
+
RunCommandResult,
|
|
24
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
25
|
+
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
26
|
+
|
|
27
|
+
export const LIFECYCLE_EXEC_CMDS = ["up", "down", "restart"] as const;
|
|
28
|
+
export type LifecycleExecCmd = (typeof LIFECYCLE_EXEC_CMDS)[number];
|
|
29
|
+
|
|
30
|
+
export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
|
|
31
|
+
return (LIFECYCLE_EXEC_CMDS as readonly string[]).includes(c);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LifecycleExecDeps {
|
|
35
|
+
run: (o: RunCommandOptions) => RunCommandResult;
|
|
36
|
+
health: (r: ResolvedService) => Promise<ServiceHealthResult>;
|
|
37
|
+
/** Health poll interval after a detached start (default 1s). */
|
|
38
|
+
pollMs?: number;
|
|
39
|
+
/** Stop polling after this long (default 120s). */
|
|
40
|
+
readyTimeoutMs?: number;
|
|
41
|
+
/** With no health check: ready after this long still running (default 5s). */
|
|
42
|
+
settleMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Restart: a green check counts only after the check went red (the old
|
|
45
|
+
* instance stopped) or this long has passed (default 5s).
|
|
46
|
+
*/
|
|
47
|
+
restartMinMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LifecycleExecOptions {
|
|
51
|
+
resolved: ResolvedService;
|
|
52
|
+
cmd: LifecycleExecCmd;
|
|
53
|
+
workspace?: string;
|
|
54
|
+
env: NodeJS.ProcessEnv;
|
|
55
|
+
emit: (e: Record<string, unknown>) => void;
|
|
56
|
+
/** Called once, after the final `done` event. */
|
|
57
|
+
end: () => void;
|
|
58
|
+
/** Registers a callback for the client going away. */
|
|
59
|
+
onClientClose: (fn: () => void) => void;
|
|
60
|
+
/** Called with the exit code when the command completes. */
|
|
61
|
+
onExit?: (exitCode: number, durationMs: number) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function streamLifecycleExec(
|
|
65
|
+
o: LifecycleExecOptions,
|
|
66
|
+
deps: LifecycleExecDeps,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const { resolved, cmd, emit } = o;
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
let finished = false;
|
|
71
|
+
let clientGone = false;
|
|
72
|
+
const finish = (exitCode: number, explained = false): void => {
|
|
73
|
+
if (finished) return;
|
|
74
|
+
finished = true;
|
|
75
|
+
const duration = Date.now() - start;
|
|
76
|
+
o.onExit?.(exitCode, duration);
|
|
77
|
+
if (clientGone) return;
|
|
78
|
+
if (exitCode !== 0 && !explained)
|
|
79
|
+
emit({ type: "error", text: `Command failed (exit ${exitCode})` });
|
|
80
|
+
emit({ type: "done", exitCode, duration });
|
|
81
|
+
o.end();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let proc: ChildProcess | null = null;
|
|
85
|
+
let onServiceExitHook: (() => void) | undefined;
|
|
86
|
+
o.onClientClose(() => {
|
|
87
|
+
clientGone = true;
|
|
88
|
+
try {
|
|
89
|
+
proc?.kill();
|
|
90
|
+
} catch {
|
|
91
|
+
/* already exited */
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Up on a healthy service is a no-op: starting a second copy only fails on
|
|
96
|
+
// its port (and an agent waits out the detach window to learn that).
|
|
97
|
+
if (cmd === "up") {
|
|
98
|
+
const h = await deps.health(resolved).catch(() => null);
|
|
99
|
+
if (h?.health === "ok") {
|
|
100
|
+
emit({
|
|
101
|
+
type: "line",
|
|
102
|
+
text: `${resolved.address} is already up (healthy) — nothing to start`,
|
|
103
|
+
});
|
|
104
|
+
finish(0);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = deps.run({
|
|
110
|
+
address: resolved.address,
|
|
111
|
+
command: cmd,
|
|
112
|
+
workspace: o.workspace,
|
|
113
|
+
runtime: "daemon",
|
|
114
|
+
env: o.env,
|
|
115
|
+
onLine: (text) => {
|
|
116
|
+
if (!clientGone) emit({ type: "line", text });
|
|
117
|
+
},
|
|
118
|
+
onClose: finish,
|
|
119
|
+
onServiceExit: (exitCode) => {
|
|
120
|
+
onServiceExitHook?.();
|
|
121
|
+
if (clientGone) return;
|
|
122
|
+
emit({
|
|
123
|
+
type: "service-exited",
|
|
124
|
+
exitCode,
|
|
125
|
+
repoName: resolved.repoName,
|
|
126
|
+
subsystem: resolved.subsystem,
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
proc = result.proc;
|
|
131
|
+
|
|
132
|
+
// Backstop: the route refuses an interactive command before calling this,
|
|
133
|
+
// so the runner should never hand back a TTY-bound process here.
|
|
134
|
+
if (result.interactive) {
|
|
135
|
+
try {
|
|
136
|
+
proc.kill();
|
|
137
|
+
} catch {
|
|
138
|
+
/* already exited */
|
|
139
|
+
}
|
|
140
|
+
emit({
|
|
141
|
+
type: "error",
|
|
142
|
+
text: `"${cmd}" requires an interactive terminal (HITL). Run it from your terminal.`,
|
|
143
|
+
});
|
|
144
|
+
finish(1, true);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (result.detachable) {
|
|
148
|
+
// The runner has already said where the log is.
|
|
149
|
+
emit({ type: "started", detached: true });
|
|
150
|
+
watchReady();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function watchReady(): void {
|
|
154
|
+
const pollMs = deps.pollMs ?? 1000;
|
|
155
|
+
const timeoutMs = deps.readyTimeoutMs ?? 120_000;
|
|
156
|
+
const settleMs = deps.settleMs ?? 5000;
|
|
157
|
+
const restartMinMs = deps.restartMinMs ?? 5000;
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
let exited = false;
|
|
160
|
+
let sawDown = false;
|
|
161
|
+
const prevExit = onServiceExitHook;
|
|
162
|
+
onServiceExitHook = () => {
|
|
163
|
+
exited = true;
|
|
164
|
+
prevExit?.();
|
|
165
|
+
};
|
|
166
|
+
const tick = async (): Promise<void> => {
|
|
167
|
+
if (finished || clientGone || exited) return;
|
|
168
|
+
const elapsed = Date.now() - t0;
|
|
169
|
+
if (elapsed > timeoutMs) return;
|
|
170
|
+
const h = await deps.health(resolved).catch(() => null);
|
|
171
|
+
if (finished || clientGone || exited) return;
|
|
172
|
+
if (h?.health === "ok") {
|
|
173
|
+
if (cmd !== "restart" || sawDown || elapsed >= restartMinMs) {
|
|
174
|
+
emit({ type: "ready", checked: true });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
} else if (h?.health === "error") {
|
|
178
|
+
sawDown = true;
|
|
179
|
+
} else if (elapsed >= settleMs) {
|
|
180
|
+
// No health check to wait for: still running after the settle window.
|
|
181
|
+
emit({ type: "ready", checked: false });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setTimeout(() => void tick(), pollMs);
|
|
185
|
+
};
|
|
186
|
+
setTimeout(() => void tick(), pollMs);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -54,6 +54,9 @@ const repoEntry = (r) => entryOf(C.model(), r.repo);
|
|
|
54
54
|
const anyHere = (r) => r.services.some(runsHere);
|
|
55
55
|
const LIFECYCLE = ["up", "down", "restart"];
|
|
56
56
|
|
|
57
|
+
/** Restart is the declared command, or down + up when both exist (as the CLI). */
|
|
58
|
+
const canRestart = (s) => has(s, "restart") || (has(s, "up") && has(s, "down"));
|
|
59
|
+
|
|
57
60
|
export const ACTIONS = [
|
|
58
61
|
// ── service ──
|
|
59
62
|
{
|
|
@@ -97,7 +100,7 @@ export const ACTIONS = [
|
|
|
97
100
|
label: "Restart",
|
|
98
101
|
for: ["service"],
|
|
99
102
|
tier: "common",
|
|
100
|
-
when: (s) => runsHere(s) &&
|
|
103
|
+
when: (s) => runsHere(s) && canRestart(s),
|
|
101
104
|
run: (s) => C.hooks.runCmd(s, "restart"),
|
|
102
105
|
},
|
|
103
106
|
{
|
|
@@ -133,7 +136,7 @@ export const ACTIONS = [
|
|
|
133
136
|
label: "Restart",
|
|
134
137
|
for: ["repo"],
|
|
135
138
|
tier: "common",
|
|
136
|
-
when: (r) => runsHere(repoEntry(r)) &&
|
|
139
|
+
when: (r) => runsHere(repoEntry(r)) && canRestart(repoEntry(r)),
|
|
137
140
|
run: (r) => C.hooks.runCmd(repoEntry(r), "restart"),
|
|
138
141
|
},
|
|
139
142
|
{
|
|
@@ -778,7 +781,7 @@ function activityList(x) {
|
|
|
778
781
|
.then(({ entries = [] }) => {
|
|
779
782
|
if (!live) return;
|
|
780
783
|
el.innerHTML = entries.length
|
|
781
|
-
? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? "[agent]" + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}</span></li>`).join("")}</ol>`
|
|
784
|
+
? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? "[agent]" + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
|
|
782
785
|
: none(
|
|
783
786
|
"No actions yet. Starts, restarts and syncs, yours and agents', show here.",
|
|
784
787
|
);
|
|
@@ -91,11 +91,20 @@ export function activityFor(
|
|
|
91
91
|
entries: AuditEntry[],
|
|
92
92
|
target: { address?: string; repo?: string },
|
|
93
93
|
): AuditEntry[] {
|
|
94
|
+
// A repo-level entry ("core down (local)") is an attempt on the repo
|
|
95
|
+
// without naming a service, as agents usually do: it shows for the repo
|
|
96
|
+
// and for each of its services.
|
|
97
|
+
const repoLevel = (e: AuditEntry, repo: string): boolean =>
|
|
98
|
+
e.verb === "exec" && e.detail.startsWith(repo + " ");
|
|
94
99
|
const matches = (e: AuditEntry): boolean => {
|
|
95
100
|
if (target.address)
|
|
96
|
-
return
|
|
101
|
+
return (
|
|
102
|
+
(e.verb === "exec" && e.detail.startsWith(target.address + " ")) ||
|
|
103
|
+
repoLevel(e, target.address.split("/")[0])
|
|
104
|
+
);
|
|
97
105
|
const repo = target.repo ?? "";
|
|
98
|
-
if (e.verb === "exec")
|
|
106
|
+
if (e.verb === "exec")
|
|
107
|
+
return e.detail.startsWith(repo + "/") || repoLevel(e, repo);
|
|
99
108
|
if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
|
|
100
109
|
return false;
|
|
101
110
|
};
|