@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.
@@ -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 === "down" ? "stop" : "start",
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 === "down" ? "stop" : "start",
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
+ }
@@ -906,6 +906,10 @@ path.dg-e.dim {
906
906
  .dg ol.act span:last-child {
907
907
  overflow-wrap: anywhere;
908
908
  }
909
+ .dg ol.act .refused {
910
+ color: var(--red);
911
+ font-style: normal;
912
+ }
909
913
  .dg-who {
910
914
  font-size: 11.5px;
911
915
  padding: 0 7px;
@@ -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) && has(s, "restart"),
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)) && has(repoEntry(r), "restart"),
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 e.verb === "exec" && e.detail.startsWith(target.address + " ");
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") return e.detail.startsWith(repo + "/");
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
  };
@@ -1490,7 +1490,7 @@ export async function handleWorkspaceRoutes(
1490
1490
  "local",
1491
1491
  ),
1492
1492
  () => false,
1493
- cmd === "down" ? "stop" : "start",
1493
+ cmd,
1494
1494
  );
1495
1495
  if (remote) {
1496
1496
  emit({ type: "line", text: ` ${remote}` });