@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.
- package/CHANGELOG.md +67 -0
- package/bin/den +3 -2
- package/docs/distributions.md +37 -9
- package/package.json +1 -1
- package/src/shared/environment-manager/service-runner.ts +4 -2
- package/src/shared/manifest/placement.ts +15 -3
- package/src/ui/cli/index.ts +55 -12
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/named-target.ts +11 -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 +2 -0
- 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/mcp/tools.ts
CHANGED
|
@@ -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
|
|
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
|
|
372
|
-
//
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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}`
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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();
|
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
|
};
|