@monster-orange/den 0.2.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.
@@ -85,15 +85,61 @@ function tail(r: StreamResult, n: number): string[] {
85
85
  }
86
86
 
87
87
  /** One line per stream: how it ended and its exit code. */
88
+ /**
89
+ * Whether a streamed action failed: a non-zero exit, a detached service whose
90
+ * launcher exited non-zero, or a run that never finished (timeout).
91
+ */
92
+ export function streamFailed(r: StreamResult): boolean {
93
+ if (r.ended === "timeout") return true;
94
+ if (r.exitCode !== undefined && r.exitCode !== 0) return true;
95
+ return r.events.some(
96
+ (e) =>
97
+ e.type === "service-exited" &&
98
+ typeof e.exitCode === "number" &&
99
+ e.exitCode !== 0,
100
+ );
101
+ }
102
+
103
+ /** An action that ran and failed: its output goes back with isError set. */
104
+ export class ActionFailed extends Error {
105
+ constructor(readonly out: Record<string, unknown>) {
106
+ super("action failed");
107
+ this.name = "ActionFailed";
108
+ }
109
+ }
110
+
111
+ /**
112
+ * How the call ended, in words an agent reads about the service: a stream
113
+ * stopped at `ready` is "ready", at a launcher's exit "handed-off".
114
+ */
115
+ function endedLabel(r: StreamResult): string {
116
+ if (r.ended !== "stopped") return r.ended;
117
+ const last = r.events.at(-1)?.type;
118
+ if (last === "ready") return "ready";
119
+ if (last === "service-exited") return "handed-off";
120
+ return r.ended;
121
+ }
122
+
88
123
  function streamSummary(r: StreamResult, keep: number) {
89
124
  return {
90
- ended: r.ended,
125
+ ended: endedLabel(r),
91
126
  ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}),
92
127
  lines: r.lines.length,
93
128
  output: tail(r, keep),
94
129
  };
95
130
  }
96
131
 
132
+ /** The action's result; thrown as ActionFailed when the stream failed. */
133
+ function actionResult(
134
+ r: StreamResult,
135
+ keep: number,
136
+ head: Record<string, unknown> = {},
137
+ ): Record<string, unknown> {
138
+ const out = { ...head, ...streamSummary(r, keep) };
139
+ if (streamFailed(r)) throw new ActionFailed({ failed: true, ...out });
140
+ return out;
141
+ }
142
+
97
143
  // ── Read tools ──────────────────────────────────────────────────────────────
98
144
 
99
145
  const read = (
@@ -354,7 +400,7 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
354
400
  str(a.service),
355
401
  workspace,
356
402
  );
357
- assertRunsLocally(entry);
403
+ assertRunsLocally(entry, cmd);
358
404
  const r = await api.stream(
359
405
  "/api/service/exec",
360
406
  {
@@ -368,16 +414,20 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
368
414
  method: "GET",
369
415
  maxLines: 2000,
370
416
  timeoutMs: 180_000,
371
- // A detached `up` keeps streaming the service's log; its launch
372
- // script exiting is the hand-off.
373
- stopOn: (e) => e.type === "service-exited",
417
+ // A detached start keeps streaming the service's log. It is done
418
+ // when the server says it is ready (health green, or still running
419
+ // with no check), or when its launch script exits (the hand-off).
420
+ stopOn: (e) => e.type === "ready" || e.type === "service-exited",
374
421
  },
375
422
  );
376
- return {
423
+ const ready = r.events.find((e) => e.type === "ready");
424
+ return actionResult(r, 200, {
377
425
  service: `${entry.repo}/${entry.service}`,
378
426
  cmd,
379
- ...streamSummary(r, 200),
380
- };
427
+ ...(ready
428
+ ? { ready: true, healthChecked: ready.checked === true }
429
+ : {}),
430
+ });
381
431
  },
382
432
  };
383
433
  }
@@ -408,7 +458,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
408
458
  maxLines: 5000,
409
459
  timeoutMs: 15 * 60_000,
410
460
  });
411
- return streamSummary(r, 300);
461
+ return actionResult(r, 300);
412
462
  },
413
463
  },
414
464
  run_tests: {
@@ -453,7 +503,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
453
503
  },
454
504
  { method: "GET", maxLines: 20_000, timeoutMs: 60 * 60_000 },
455
505
  );
456
- return { pillar, env, ...streamSummary(r, 300) };
506
+ return actionResult(r, 300, { pillar, env });
457
507
  },
458
508
  },
459
509
  };
@@ -532,6 +582,28 @@ async function notFoundMessage(
532
582
  * that refused, how to start the web server, or (when it runs an older Den
533
583
  * than the tool needs) how to restart it.
534
584
  */
585
+ /**
586
+ * Tell the web server a guard refused an action, so the activity trail shows
587
+ * the attempt (F2). Best effort: a report that fails changes nothing.
588
+ */
589
+ async function reportRefusal(
590
+ ctx: ToolContext,
591
+ tool: string,
592
+ args: Args,
593
+ reason: string,
594
+ ): Promise<void> {
595
+ try {
596
+ await ctx.api.get("/api/activity", {}, "POST", {
597
+ tool,
598
+ ...(typeof args.repo === "string" ? { repo: args.repo } : {}),
599
+ ...(typeof args.service === "string" ? { service: args.service } : {}),
600
+ reason,
601
+ });
602
+ } catch {
603
+ /* the refusal is still returned to the agent */
604
+ }
605
+ }
606
+
535
607
  export async function callTool(
536
608
  name: string,
537
609
  args: Args,
@@ -548,12 +620,19 @@ export async function callTool(
548
620
  const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
549
621
  return { text: redactSecrets(text), isError: false };
550
622
  } catch (e) {
623
+ if (e instanceof ActionFailed)
624
+ return {
625
+ text: redactSecrets(JSON.stringify(e.out, null, 2)),
626
+ isError: true,
627
+ };
551
628
  if (e instanceof DenHttpError && e.status === 404) {
552
629
  const text = await notFoundMessage(e, name, ctx).catch(
553
630
  (inner: Error) => `Error: ${inner.message}`,
554
631
  );
555
632
  return { text: redactSecrets(text), isError: true };
556
633
  }
634
+ if (e instanceof Refusal && tool.action)
635
+ await reportRefusal(ctx, name, args, e.message);
557
636
  const msg =
558
637
  e instanceof Refusal
559
638
  ? `Refused: ${e.message}`
@@ -20,9 +20,13 @@ export interface AuditEntry {
20
20
  clientVersion?: string;
21
21
  mcpPid?: number | null;
22
22
  session?: string;
23
- /** exec | run | sync */
23
+ /** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
24
24
  verb: string;
25
25
  detail: string;
26
+ /** Set when the action never ran: a guard or the server refused it. */
27
+ outcome?: "refused";
28
+ /** Why it was refused (first line). */
29
+ reason?: string;
26
30
  }
27
31
 
28
32
  type Sink = (line: string, actor: AgentActor | null) => void;
@@ -43,7 +47,12 @@ export function actorSuffix(actor: Actor): string {
43
47
 
44
48
  export function recordAction(
45
49
  actor: Actor,
46
- action: { verb: string; detail: string },
50
+ action: {
51
+ verb: string;
52
+ detail: string;
53
+ outcome?: "refused";
54
+ reason?: string;
55
+ },
47
56
  ): AuditEntry {
48
57
  const entry: AuditEntry = {
49
58
  ts: new Date().toISOString(),
@@ -58,13 +67,70 @@ export function recordAction(
58
67
  : {}),
59
68
  verb: action.verb,
60
69
  detail: action.detail,
70
+ ...(action.outcome ? { outcome: action.outcome } : {}),
71
+ ...(action.reason ? { reason: clean(action.reason, 300) } : {}),
61
72
  };
62
73
  ring.push(entry);
63
74
  if (ring.length > RING_MAX) ring.shift();
64
- sink?.(`▶ ${action.verb} ${action.detail}${actorSuffix(actor)}`, actor);
75
+ const mark = entry.outcome === "refused" ? "✗" : "▶";
76
+ const why = entry.reason ? ` — refused: ${entry.reason}` : "";
77
+ sink?.(
78
+ `${mark} ${action.verb} ${action.detail}${why}${actorSuffix(actor)}`,
79
+ actor,
80
+ );
65
81
  return entry;
66
82
  }
67
83
 
84
+ /** One line, printable characters only, at most `max` long. */
85
+ function clean(v: unknown, max: number): string {
86
+ const line = String(v ?? "").split(/[\r\n]/)[0];
87
+ return [...line]
88
+ .filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
89
+ .join("")
90
+ .slice(0, max);
91
+ }
92
+
93
+ const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
94
+ const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
95
+
96
+ /**
97
+ * An MCP guard refused an agent's call before it reached an action route.
98
+ * The agent's own report, so every field is sanitised: a lifecycle tool with
99
+ * a valid repo becomes an "exec" entry for that service (what the panel's
100
+ * activity list shows); anything else is recorded under the tool's name.
101
+ */
102
+ export function recordAgentRefusal(
103
+ actor: Actor,
104
+ report: {
105
+ tool?: unknown;
106
+ repo?: unknown;
107
+ service?: unknown;
108
+ reason?: unknown;
109
+ },
110
+ ): AuditEntry {
111
+ const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
112
+ const repo = typeof report.repo === "string" ? report.repo : "";
113
+ const service = typeof report.service === "string" ? report.service : "";
114
+ const lifecycle = LIFECYCLE_TOOL.exec(tool);
115
+ const reason = clean(report.reason, 300) || "refused";
116
+ if (lifecycle && SAFE_PART.test(repo)) {
117
+ const address =
118
+ service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
119
+ return recordAction(actor, {
120
+ verb: "exec",
121
+ detail: `${address} ${lifecycle[1]} (local)`,
122
+ outcome: "refused",
123
+ reason,
124
+ });
125
+ }
126
+ return recordAction(actor, {
127
+ verb: tool,
128
+ detail: "",
129
+ outcome: "refused",
130
+ reason,
131
+ });
132
+ }
133
+
68
134
  /** The most recent actions, oldest first. */
69
135
  export function recentActions(): AuditEntry[] {
70
136
  return ring.slice();
@@ -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}` });