@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +16 -1
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +321 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,565 @@
1
+ /**
2
+ * The MCP tools. Read tools are always registered; action tools only when the
3
+ * org names them in `mcp.actions`. Every tool is a thin call to the web API
4
+ * (see api.ts); the guards (guards.ts) run first, and all text returned is
5
+ * passed through the secret redactor.
6
+ */
7
+ import { z } from "zod";
8
+ import {
9
+ MCP_ACTIONS,
10
+ type McpAction,
11
+ type ResolvedMcpConfig,
12
+ } from "../../shared/den-config.js";
13
+ import { redactSecrets } from "../../shared/redact.js";
14
+ import {
15
+ DenHttpError,
16
+ DenWebDown,
17
+ type DenApi,
18
+ type Query,
19
+ type StreamResult,
20
+ } from "./api.js";
21
+ import {
22
+ Refusal,
23
+ assertLifecycleCmd,
24
+ assertLocalEnv,
25
+ assertLogsEnv,
26
+ assertRunTestsAllowed,
27
+ assertRunsLocally,
28
+ assertSecurityPlanOk,
29
+ assertSyncableRepo,
30
+ type LifecycleCmd,
31
+ type ServiceEntryLike,
32
+ type SecurityPlanLike,
33
+ } from "./guards.js";
34
+
35
+ export interface ToolContext {
36
+ api: DenApi;
37
+ cfg: ResolvedMcpConfig;
38
+ /** The CLI name, for messages (`<cli> web up`). */
39
+ cli: string;
40
+ }
41
+
42
+ type Args = Record<string, unknown>;
43
+
44
+ export interface DenTool {
45
+ name: string;
46
+ title: string;
47
+ description: string;
48
+ action: boolean;
49
+ shape: z.ZodRawShape;
50
+ run(args: Args, ctx: ToolContext): Promise<unknown>;
51
+ }
52
+
53
+ const ENVS = [
54
+ "local",
55
+ "dev",
56
+ "sandbox",
57
+ "staging",
58
+ "beta",
59
+ "prod",
60
+ "production",
61
+ ] as const;
62
+ const PILLARS = [
63
+ "synthetic",
64
+ "integration",
65
+ "performance",
66
+ "smoke",
67
+ "journeys",
68
+ "security",
69
+ "suites",
70
+ ] as const;
71
+
72
+ const envArg = z.enum(ENVS).optional().describe("Environment (default local)");
73
+ const workspaceArg = z
74
+ .string()
75
+ .optional()
76
+ .describe("Den workspace (default: the focused one)");
77
+
78
+ const str = (v: unknown): string | undefined =>
79
+ typeof v === "string" && v.length > 0 ? v : undefined;
80
+ const envOf = (args: Args): string => str(args.env) ?? "local";
81
+
82
+ /** Keep the last `n` lines of a stream's output. */
83
+ function tail(r: StreamResult, n: number): string[] {
84
+ return r.lines.slice(-n);
85
+ }
86
+
87
+ /** One line per stream: how it ended and its exit code. */
88
+ function streamSummary(r: StreamResult, keep: number) {
89
+ return {
90
+ ended: r.ended,
91
+ ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}),
92
+ lines: r.lines.length,
93
+ output: tail(r, keep),
94
+ };
95
+ }
96
+
97
+ // ── Read tools ──────────────────────────────────────────────────────────────
98
+
99
+ const read = (
100
+ name: string,
101
+ title: string,
102
+ description: string,
103
+ shape: z.ZodRawShape,
104
+ run: DenTool["run"],
105
+ ): DenTool => ({ name, title, description, action: false, shape, run });
106
+
107
+ const READ_TOOLS: DenTool[] = [
108
+ read(
109
+ "status",
110
+ "Service health",
111
+ "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command.",
112
+ { env: envArg, workspace: workspaceArg },
113
+ (a, { api }) =>
114
+ api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
115
+ ),
116
+ read(
117
+ "services",
118
+ "Services",
119
+ "The workspace's services: repo, service, mode (managed or dev-managed), lifecycle commands, dependencies.",
120
+ { env: envArg, workspace: workspaceArg },
121
+ (a, { api }) =>
122
+ api.get("/api/services", { env: envOf(a), workspace: str(a.workspace) }),
123
+ ),
124
+ read(
125
+ "applications",
126
+ "Applications",
127
+ "Org > Application > Repo > Services for a workspace (what `apps --json` shows).",
128
+ { workspace: workspaceArg },
129
+ (a, { api }) =>
130
+ api.get("/api/applications", { workspace: str(a.workspace) }),
131
+ ),
132
+ read(
133
+ "graph",
134
+ "Dependency graph",
135
+ "The derived dependency graph and its startup order; `seed` narrows it to one repo's closure.",
136
+ {
137
+ seed: z.string().optional().describe("Repo whose closure to show"),
138
+ workspace: workspaceArg,
139
+ },
140
+ (a, { api }) =>
141
+ api.get("/api/graph", { seed: str(a.seed), workspace: str(a.workspace) }),
142
+ ),
143
+ read(
144
+ "workspaces",
145
+ "Workspaces",
146
+ "Workspaces, which are active and which is focused.",
147
+ {},
148
+ (_a, { api }) => api.get("/api/workspaces"),
149
+ ),
150
+ read(
151
+ "environments",
152
+ "Environments",
153
+ "The environment names this org enables (names only, never their values).",
154
+ {},
155
+ (_a, { api }) => api.get("/api/environments"),
156
+ ),
157
+ read(
158
+ "logs_search",
159
+ "Search logs",
160
+ "Search the full log history of services (repo/service targets) for text. Local sources only unless mcp.logs.remote is on. Secrets are redacted.",
161
+ {
162
+ q: z.string().min(2).describe("Text to find (2+ characters)"),
163
+ targets: z
164
+ .array(z.string())
165
+ .min(1)
166
+ .max(40)
167
+ .describe("repo/service names"),
168
+ env: envArg,
169
+ },
170
+ async (a, { api, cfg }) => {
171
+ const env = envOf(a);
172
+ assertLogsEnv(env, cfg);
173
+ return api.get("/api/logs/search", {
174
+ q: String(a.q),
175
+ env,
176
+ targets: (a.targets as string[]).join(","),
177
+ });
178
+ },
179
+ ),
180
+ read(
181
+ "logs_tail",
182
+ "Tail logs",
183
+ "The last lines of one service's log (no follow). Local sources only unless mcp.logs.remote is on. Secrets are redacted.",
184
+ {
185
+ repo: z.string(),
186
+ service: z.string(),
187
+ lines: z
188
+ .number()
189
+ .int()
190
+ .min(1)
191
+ .max(1000)
192
+ .optional()
193
+ .describe("Lines to return (default 200)"),
194
+ env: envArg,
195
+ },
196
+ async (a, { api, cfg }) => {
197
+ const env = envOf(a);
198
+ assertLogsEnv(env, cfg);
199
+ const n = (a.lines as number | undefined) ?? 200;
200
+ const query: Query = { repo: String(a.repo), service: String(a.service) };
201
+ const r =
202
+ env === "local"
203
+ ? await api.stream("/api/service/taillog", query, {
204
+ maxLines: 5000,
205
+ timeoutMs: 8000,
206
+ idleMs: 1500,
207
+ })
208
+ : await api.stream(
209
+ "/api/service/logs",
210
+ { ...query, env },
211
+ {
212
+ maxLines: 5000,
213
+ timeoutMs: 15000,
214
+ idleMs: 3000,
215
+ },
216
+ );
217
+ return { repo: a.repo, service: a.service, env, lines: tail(r, n) };
218
+ },
219
+ ),
220
+ read(
221
+ "tests_available",
222
+ "Available tests",
223
+ "The test modules of a pillar, and which repos (branch + SHA) they come from.",
224
+ { pillar: z.enum(PILLARS) },
225
+ async (a, { api }) => ({
226
+ modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
227
+ sources: await api.get("/api/test-sources", { pillar: String(a.pillar) }),
228
+ }),
229
+ ),
230
+ read(
231
+ "security_latest",
232
+ "Latest security scans",
233
+ "The newest security (ZAP) scan summary per mode for an environment.",
234
+ { env: envArg },
235
+ (a, { api }) => api.get("/api/security/latest", { env: envOf(a) }),
236
+ ),
237
+ read(
238
+ "release_status",
239
+ "Release status",
240
+ "Recent CD runs per repo and, with env, the deployed-version table for it. Read only: there is no deploy tool.",
241
+ {
242
+ env: z
243
+ .string()
244
+ .optional()
245
+ .describe("Release environment for the verify table"),
246
+ },
247
+ async (a, { api }) => ({
248
+ runs: await api.get("/api/release/runs"),
249
+ ...(str(a.env)
250
+ ? { status: await api.get("/api/release/status", { env: str(a.env) }) }
251
+ : {}),
252
+ }),
253
+ ),
254
+ read(
255
+ "manifest_validate",
256
+ "Validate a manifest",
257
+ "Validate a service manifest: a repo path (or file), or the YAML content itself.",
258
+ {
259
+ path: z.string().optional().describe("Repo directory or manifest file"),
260
+ content: z.string().optional().describe("Manifest YAML to validate"),
261
+ },
262
+ async (a, { api }) => {
263
+ if (str(a.content) !== undefined)
264
+ return api.get("/api/manifest/validate", {}, "POST", {
265
+ content: a.content,
266
+ });
267
+ if (str(a.path) === undefined)
268
+ throw new Refusal("give a path or the manifest content.");
269
+ return api.get("/api/manifest/validate", { path: str(a.path) });
270
+ },
271
+ ),
272
+ read(
273
+ "stacks",
274
+ "Stacks",
275
+ "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health.",
276
+ {
277
+ workspace: workspaceArg,
278
+ health: z
279
+ .boolean()
280
+ .optional()
281
+ .describe("Check container health (default true)"),
282
+ },
283
+ (a, { api }) =>
284
+ api.get("/api/stacks", {
285
+ workspace: str(a.workspace),
286
+ health: a.health === false ? "0" : "1",
287
+ }),
288
+ ),
289
+ read(
290
+ "edge_status",
291
+ "Edge status",
292
+ "The HTTPS edge: up or down, its routes and its cert names.",
293
+ {},
294
+ (_a, { api }) => api.get("/api/edge"),
295
+ ),
296
+ ];
297
+
298
+ // ── Action tools ────────────────────────────────────────────────────────────
299
+
300
+ /** Find the /api/services entry an action names (repo + optional service). */
301
+ async function findService(
302
+ api: DenApi,
303
+ repo: string,
304
+ service: string | undefined,
305
+ workspace: string | undefined,
306
+ ): Promise<ServiceEntryLike & { repo: string; service: string }> {
307
+ const list = (await api.get("/api/services", {
308
+ env: "local",
309
+ workspace,
310
+ })) as (ServiceEntryLike & {
311
+ repo: string;
312
+ service: string;
313
+ isEntrypoint?: boolean;
314
+ })[];
315
+ const inRepo = list.filter((s) => s.repo === repo);
316
+ const hit = service
317
+ ? inRepo.find((s) => s.service === service)
318
+ : inRepo.length === 1
319
+ ? inRepo[0]
320
+ : inRepo.find((s) => s.isEntrypoint);
321
+ if (!hit)
322
+ throw new Refusal(
323
+ inRepo.length === 0
324
+ ? `no service in repo "${repo}" in the workspace.`
325
+ : `name the service: ${repo} has ${inRepo.map((s) => s.service).join(", ")}.`,
326
+ );
327
+ return hit;
328
+ }
329
+
330
+ function lifecycleTool(cmd: LifecycleCmd): DenTool {
331
+ const verb = { up: "Start", down: "Stop", restart: "Restart" }[cmd];
332
+ return {
333
+ name: `service_${cmd}`,
334
+ title: `${verb} a service`,
335
+ description: `${verb} one locally run service (its manifest's local "${cmd}"; env local only).`,
336
+ action: true,
337
+ shape: {
338
+ repo: z.string(),
339
+ service: z
340
+ .string()
341
+ .optional()
342
+ .describe("Service in a multi-service repo"),
343
+ workspace: workspaceArg,
344
+ env: z.string().optional().describe("Must be local (the default)"),
345
+ },
346
+ async run(a, { api }) {
347
+ assertLifecycleCmd(cmd);
348
+ const env = envOf(a);
349
+ assertLocalEnv(env);
350
+ const workspace = str(a.workspace);
351
+ const entry = await findService(
352
+ api,
353
+ String(a.repo),
354
+ str(a.service),
355
+ workspace,
356
+ );
357
+ assertRunsLocally(entry);
358
+ const r = await api.stream(
359
+ "/api/service/exec",
360
+ {
361
+ repo: entry.repo,
362
+ service: entry.service,
363
+ cmd,
364
+ env: "local",
365
+ ...(workspace ? { workspace } : {}),
366
+ },
367
+ {
368
+ method: "GET",
369
+ maxLines: 2000,
370
+ 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",
374
+ },
375
+ );
376
+ return {
377
+ service: `${entry.repo}/${entry.service}`,
378
+ cmd,
379
+ ...streamSummary(r, 200),
380
+ };
381
+ },
382
+ };
383
+ }
384
+
385
+ const ACTION_TOOLS: Record<McpAction, DenTool> = {
386
+ service_up: lifecycleTool("up"),
387
+ service_down: lifecycleTool("down"),
388
+ service_restart: lifecycleTool("restart"),
389
+ workspace_sync: {
390
+ name: "workspace_sync",
391
+ title: "Sync managed repos",
392
+ description:
393
+ "Bring managed clones up to date (down, pull, setup; never starts them). Never touches a dev-managed checkout.",
394
+ action: true,
395
+ shape: {
396
+ repo: z.string().optional().describe("One managed repo (default: all)"),
397
+ },
398
+ async run(a, { api }) {
399
+ const repo = str(a.repo);
400
+ if (repo) {
401
+ const services = (await api.get("/api/services", {
402
+ env: "local",
403
+ })) as ServiceEntryLike[];
404
+ assertSyncableRepo(repo, services);
405
+ }
406
+ const r = await api.stream("/api/workspace/sync", repo ? { repo } : {}, {
407
+ method: "POST",
408
+ maxLines: 5000,
409
+ timeoutMs: 15 * 60_000,
410
+ });
411
+ return streamSummary(r, 300);
412
+ },
413
+ },
414
+ run_tests: {
415
+ name: "run_tests",
416
+ title: "Run tests",
417
+ description:
418
+ "Run a test pillar (optionally some modules) in an allowed env (mcp.run_tests.envs, default local; never production). Security runs must pass the owned-host plan; mode full is local only.",
419
+ action: true,
420
+ shape: {
421
+ pillar: z.enum(PILLARS),
422
+ modules: z.array(z.string()).optional(),
423
+ env: envArg,
424
+ mode: z
425
+ .enum(["baseline", "api", "full"])
426
+ .optional()
427
+ .describe("Security scan mode (default baseline)"),
428
+ },
429
+ async run(a, { api, cfg }) {
430
+ const pillar = String(a.pillar);
431
+ const env = envOf(a);
432
+ const modules = (a.modules as string[] | undefined) ?? [];
433
+ const mode =
434
+ pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
435
+ assertRunTestsAllowed({ pillar, env, mode }, cfg);
436
+ if (pillar === "security") {
437
+ for (const service of modules.length ? modules : [undefined]) {
438
+ const plan = (await api.get("/api/security/plan", {
439
+ env,
440
+ mode,
441
+ ...(service ? { service } : {}),
442
+ })) as SecurityPlanLike;
443
+ assertSecurityPlanOk(plan);
444
+ }
445
+ }
446
+ const r = await api.stream(
447
+ "/api/run",
448
+ {
449
+ pillar,
450
+ ...(modules.length ? { module: modules } : {}),
451
+ env,
452
+ ...(mode ? { mode } : {}),
453
+ },
454
+ { method: "GET", maxLines: 20_000, timeoutMs: 60 * 60_000 },
455
+ );
456
+ return { pillar, env, ...streamSummary(r, 300) };
457
+ },
458
+ },
459
+ };
460
+
461
+ export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
462
+ export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
463
+
464
+ /** The tools to register: every read tool, plus the actions the org named. */
465
+ export function toolsFor(cfg: ResolvedMcpConfig): DenTool[] {
466
+ return [
467
+ ...READ_TOOLS,
468
+ ...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
469
+ (a) => ACTION_TOOLS[a],
470
+ ),
471
+ ];
472
+ }
473
+
474
+ /**
475
+ * The Den version that added each web route a tool reads. The web server keeps
476
+ * running its old code until it is restarted, so an MCP server can be newer
477
+ * than the web server it talks to.
478
+ */
479
+ export const ROUTE_SINCE: Readonly<Record<string, string>> = {
480
+ "/api/stacks": "0.2.0",
481
+ "/api/edge": "0.2.0",
482
+ "/api/security/plan": "0.2.0",
483
+ };
484
+
485
+ /** `a` is older than `b`, by major.minor.patch (a pre-release counts as its release). */
486
+ export function versionBefore(a: string, b: string): boolean {
487
+ const core = (v: string) =>
488
+ v
489
+ .split("-")[0]
490
+ .split(".")
491
+ .map((n) => parseInt(n, 10) || 0);
492
+ const [x, y] = [core(a), core(b)];
493
+ for (let i = 0; i < 3; i++)
494
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
495
+ return false;
496
+ }
497
+
498
+ /** The running web server's version, or undefined when it reports none. */
499
+ async function webServerVersion(api: DenApi): Promise<string | undefined> {
500
+ try {
501
+ const info = (await api.get("/api/server-info")) as { version?: unknown };
502
+ return typeof info?.version === "string" ? info.version : undefined;
503
+ } catch (e) {
504
+ if (e instanceof DenHttpError) return undefined;
505
+ throw e;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * What a 404 means for `tool`. For a route newer than the web server (read
511
+ * from /api/server-info only now, once a call has 404'd), the restart that
512
+ * fixes it; otherwise the plain error.
513
+ */
514
+ async function notFoundMessage(
515
+ e: DenHttpError,
516
+ tool: string,
517
+ ctx: ToolContext,
518
+ ): Promise<string> {
519
+ const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
520
+ const since = ROUTE_SINCE[e.path];
521
+ if (!since) return plain;
522
+ const version = await webServerVersion(ctx.api);
523
+ if (version !== undefined && !versionBefore(version, since)) return plain;
524
+ return (
525
+ `web server is Den ${version ?? `< ${since}`}; ` +
526
+ `${tool} needs >= ${since}: run ${ctx.cli} web restart`
527
+ );
528
+ }
529
+
530
+ /**
531
+ * Run one tool: its result as (redacted) text, or an error naming the guard
532
+ * that refused, how to start the web server, or (when it runs an older Den
533
+ * than the tool needs) how to restart it.
534
+ */
535
+ export async function callTool(
536
+ name: string,
537
+ args: Args,
538
+ ctx: ToolContext,
539
+ ): Promise<{ text: string; isError: boolean }> {
540
+ const tool = toolsFor(ctx.cfg).find((t) => t.name === name);
541
+ if (!tool)
542
+ return {
543
+ text: `Unknown or disabled tool "${name}". Actions must be named in mcp.actions.`,
544
+ isError: true,
545
+ };
546
+ try {
547
+ const out = await tool.run(args, ctx);
548
+ const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
549
+ return { text: redactSecrets(text), isError: false };
550
+ } catch (e) {
551
+ if (e instanceof DenHttpError && e.status === 404) {
552
+ const text = await notFoundMessage(e, name, ctx).catch(
553
+ (inner: Error) => `Error: ${inner.message}`,
554
+ );
555
+ return { text: redactSecrets(text), isError: true };
556
+ }
557
+ const msg =
558
+ e instanceof Refusal
559
+ ? `Refused: ${e.message}`
560
+ : e instanceof DenWebDown
561
+ ? e.message
562
+ : `Error: ${(e as Error).message}`;
563
+ return { text: redactSecrets(msg), isError: true };
564
+ }
565
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The action audit trail: every service exec, test run and sync, with who
3
+ * asked (a person in the UI, or an agent through `<cli> mcp`). Each action
4
+ * becomes a lifecycle line in the server log, the stream the /logs page shows
5
+ * as the web-server log, so agent actions sit beside the UI's own; the
6
+ * structured entries are kept in a small in-memory ring.
7
+ */
8
+ import {
9
+ actorLabel,
10
+ type Actor,
11
+ type AgentActor,
12
+ } from "../../shared/agent-actor.js";
13
+
14
+ export { actorFromHeaders } from "../../shared/agent-actor.js";
15
+
16
+ export interface AuditEntry {
17
+ ts: string;
18
+ actor: "agent" | "user";
19
+ client?: string;
20
+ clientVersion?: string;
21
+ mcpPid?: number | null;
22
+ session?: string;
23
+ /** exec | run | sync */
24
+ verb: string;
25
+ detail: string;
26
+ }
27
+
28
+ type Sink = (line: string, actor: AgentActor | null) => void;
29
+
30
+ const RING_MAX = 200;
31
+ const ring: AuditEntry[] = [];
32
+ let sink: Sink | null = null;
33
+
34
+ /** Where audit lines go (the server log); null to disconnect. */
35
+ export function setAuditSink(fn: Sink | null): void {
36
+ sink = fn;
37
+ }
38
+
39
+ /** " · by agent …" for an agent, "" for a person. */
40
+ export function actorSuffix(actor: Actor): string {
41
+ return actor ? ` · by ${actorLabel(actor)}` : "";
42
+ }
43
+
44
+ export function recordAction(
45
+ actor: Actor,
46
+ action: { verb: string; detail: string },
47
+ ): AuditEntry {
48
+ const entry: AuditEntry = {
49
+ ts: new Date().toISOString(),
50
+ actor: actor ? "agent" : "user",
51
+ ...(actor
52
+ ? {
53
+ client: actor.client,
54
+ clientVersion: actor.clientVersion,
55
+ mcpPid: actor.mcpPid,
56
+ session: actor.session,
57
+ }
58
+ : {}),
59
+ verb: action.verb,
60
+ detail: action.detail,
61
+ };
62
+ ring.push(entry);
63
+ if (ring.length > RING_MAX) ring.shift();
64
+ sink?.(`▶ ${action.verb} ${action.detail}${actorSuffix(actor)}`, actor);
65
+ return entry;
66
+ }
67
+
68
+ /** The most recent actions, oldest first. */
69
+ export function recentActions(): AuditEntry[] {
70
+ return ring.slice();
71
+ }
@@ -84,6 +84,17 @@ export function parseChangelog(raw: string): ChangelogEntry[] {
84
84
  line.replace(/^ {2}- /, "").trim(),
85
85
  );
86
86
  }
87
+ } else if (currentSection && /^ {2,}\S/.test(line)) {
88
+ // A wrapped continuation line: it belongs to the bullet (or sub-bullet)
89
+ // above it. Dropping it cut every multi-line bullet mid-sentence.
90
+ const bullets = current.sections[currentSection];
91
+ const last = bullets[bullets.length - 1];
92
+ if (last) {
93
+ const text = line.trim();
94
+ if (/^ {4,}/.test(line) && last.sub.length > 0)
95
+ last.sub[last.sub.length - 1] += " " + text;
96
+ else last.text += " " + text;
97
+ }
87
98
  }
88
99
  }
89
100
  if (current) {