@monster-orange/den 0.4.3 → 0.4.4

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 (58) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -45,6 +45,14 @@ import {
45
45
  pinWorkspace,
46
46
  type McpScope,
47
47
  } from "./scope.js";
48
+ import {
49
+ graphInScope,
50
+ mergeGraphs,
51
+ orgSummary,
52
+ scopeBlock,
53
+ statusView,
54
+ workspaceRepos,
55
+ } from "./views.js";
48
56
 
49
57
  export interface ToolContext {
50
58
  api: DenApi;
@@ -173,37 +181,104 @@ const READ_TOOLS: DenTool[] = [
173
181
  read(
174
182
  "status",
175
183
  "Service health",
176
- "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 or a container check (`container <project>/<service>`).",
184
+ "Health of every service in the workspace (what `status` shows): `counts`, then each service's `state` (up, down, redirect, pending, unknown, not-checked; `why` when there is no plain verdict), mode, and what was checked: `url` for an HTTP check, `check` for a shell command or a container check (`container <project>/<service>`). A redirect (302 → /login) answers, so it is not down.",
177
185
  { env: envArg, workspace: workspaceArg },
178
- (a, { api }) =>
179
- api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
186
+ async (a, { api, scope }) => {
187
+ const env = envOf(a);
188
+ const view = statusView(
189
+ await api.get("/api/health", { env, workspace: str(a.workspace) }),
190
+ env,
191
+ );
192
+ return scope
193
+ ? await withScope(api, scope, a, view, {
194
+ count: (view.services as unknown[]).length,
195
+ noun: "service",
196
+ })
197
+ : view;
198
+ },
180
199
  ),
181
200
  read(
182
201
  "services",
183
202
  "Services",
184
203
  "The workspace's services: repo, service, mode (managed or dev-managed), lifecycle commands, dependencies.",
185
204
  { env: envArg, workspace: workspaceArg },
186
- (a, { api }) =>
187
- api.get("/api/services", { env: envOf(a), workspace: str(a.workspace) }),
205
+ async (a, { api, scope }) => {
206
+ const list = await api.get("/api/services", {
207
+ env: envOf(a),
208
+ workspace: str(a.workspace),
209
+ });
210
+ if (!scope) return list;
211
+ const services = Array.isArray(list) ? list : [];
212
+ return withScope(
213
+ api,
214
+ scope,
215
+ a,
216
+ { services },
217
+ { count: services.length, noun: "service" },
218
+ );
219
+ },
188
220
  ),
189
221
  read(
190
222
  "applications",
191
223
  "Applications",
192
224
  "Org > Application > Repo > Services for a workspace (what `apps --json` shows).",
193
225
  { workspace: workspaceArg },
194
- (a, { api }) =>
195
- api.get("/api/applications", { workspace: str(a.workspace) }),
226
+ async (a, { api, scope }) => {
227
+ const tree = await api.get("/api/applications", {
228
+ workspace: str(a.workspace),
229
+ });
230
+ if (!scope) return tree;
231
+ const t =
232
+ tree && typeof tree === "object" && !Array.isArray(tree)
233
+ ? (tree as Record<string, unknown>)
234
+ : { applications: tree };
235
+ const apps = Array.isArray(t.applications) ? t.applications : [];
236
+ return withScope(api, scope, a, t, {
237
+ count: apps.length,
238
+ noun: "application",
239
+ });
240
+ },
196
241
  ),
197
242
  read(
198
243
  "graph",
199
244
  "Dependency graph",
200
- "The derived dependency graph and its startup order; `seed` narrows it to one repo's closure.",
245
+ "The derived dependency graph; `seed` narrows it to one repo's closure. Unscoped with no `workspace` it is the org-wide map (`map: \"org-wide\"`: every active workspace's repos and edges, `workspaces` naming them; `order` only when one is active), with `workspace` that workspace's (`map: \"workspace\"`, with its startup order). Scoped, the workspace's own repos and the edges out of them (`outside` names the repos they depend on elsewhere; the org-wide map is `org_summary.graph`).",
201
246
  {
202
247
  seed: z.string().optional().describe("Repo whose closure to show"),
203
248
  workspace: workspaceArg,
204
249
  },
205
- (a, { api }) =>
206
- api.get("/api/graph", { seed: str(a.seed), workspace: str(a.workspace) }),
250
+ async (a, { api, scope }) => {
251
+ // Unscoped and naming nothing: the org-wide map, every active
252
+ // workspace's graph merged (the web server derives one per workspace).
253
+ if (!scope && !str(a.seed) && !str(a.workspace))
254
+ return orgGraph(api, (p, q) => api.get(p, q));
255
+ const graph = await api.get("/api/graph", {
256
+ seed: str(a.seed),
257
+ workspace: str(a.workspace),
258
+ });
259
+ if (!scope)
260
+ return graph && typeof graph === "object" && !Array.isArray(graph)
261
+ ? {
262
+ map: str(a.seed) ? "closure" : "workspace",
263
+ ...(str(a.workspace) ? { workspace: str(a.workspace) } : {}),
264
+ ...(graph as Record<string, unknown>),
265
+ }
266
+ : graph;
267
+ const body = await scopeWorkspacesBody(api, scope);
268
+ const shown = String(a.workspace);
269
+ const view = graphInScope(graph, workspaceRepos(body, shown));
270
+ const { hiddenNodes, ...rest } = view;
271
+ return {
272
+ ...rest,
273
+ scope: scopeBlock(
274
+ scope,
275
+ body,
276
+ shown,
277
+ { count: (view.nodes as unknown[]).length, noun: "repo" },
278
+ { graphNodes: hiddenNodes as number },
279
+ ),
280
+ };
281
+ },
207
282
  ),
208
283
  read(
209
284
  "workspaces",
@@ -212,7 +287,44 @@ const READ_TOOLS: DenTool[] = [
212
287
  {},
213
288
  async (_a, { api, scope }) => {
214
289
  const body = await api.get("/api/workspaces");
215
- return scope ? filterWorkspacesBody(body, scope) : body;
290
+ if (!scope) return body;
291
+ const filtered = filterWorkspacesBody(body, scope);
292
+ return {
293
+ ...filtered,
294
+ scope: {
295
+ ...(filtered.scope as Record<string, unknown>),
296
+ ...scopeBlock(scope, body, scope.workspace, {
297
+ count: 1,
298
+ noun: "workspace",
299
+ }),
300
+ },
301
+ };
302
+ },
303
+ ),
304
+ read(
305
+ "org_summary",
306
+ "Org summary",
307
+ 'The whole org at a glance, for every agent: each workspace and repo with status counts (running, partial, stopped; up, down, redirect, not checked), the dependency graph (names and edge kinds), the edge (up, TLS, whether HTTPS serves) and the environments. Scoped, `yours` adds your own workspace\'s services in full. It never carries logs, env values, commands, URLs or check output from outside your workspace. Start here for "what are our servers?".',
308
+ {},
309
+ async (_a, { api, scope }) => {
310
+ const workspaces = await api.get("/api/workspaces");
311
+ const [health, graph, edge, environments, own] = await Promise.all([
312
+ api.get("/api/health", { env: "local" }),
313
+ // Names and edge kinds only (orgSummary drops the evidence).
314
+ orgGraph(api, (p, q) => softGet(api, p, q), workspaces),
315
+ softGet(api, "/api/edge"),
316
+ api.get("/api/environments"),
317
+ scope
318
+ ? api.get("/api/health", {
319
+ env: "local",
320
+ workspace: scope.workspace,
321
+ })
322
+ : Promise.resolve(undefined),
323
+ ]);
324
+ return orgSummary(
325
+ { workspaces, health, graph, edge, environments, own },
326
+ scope,
327
+ );
216
328
  },
217
329
  ),
218
330
  read(
@@ -372,7 +484,7 @@ const READ_TOOLS: DenTool[] = [
372
484
  read(
373
485
  "stacks",
374
486
  "Stacks",
375
- "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health.",
487
+ "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health. An empty list says why (`why`).",
376
488
  {
377
489
  workspace: workspaceArg,
378
490
  health: z
@@ -380,21 +492,151 @@ const READ_TOOLS: DenTool[] = [
380
492
  .optional()
381
493
  .describe("Check container health (default true)"),
382
494
  },
383
- (a, { api }) =>
384
- api.get("/api/stacks", {
495
+ async (a, { api, scope }) => {
496
+ const body = (await api.get("/api/stacks", {
385
497
  workspace: str(a.workspace),
386
498
  health: a.health === false ? "0" : "1",
387
- }),
499
+ })) as Record<string, unknown>;
500
+ const all = Array.isArray(body?.stacks)
501
+ ? (body.stacks as { repo?: unknown }[])
502
+ : [];
503
+ if (!scope) {
504
+ if (all.length || body?.why !== undefined) return body;
505
+ return {
506
+ ...body,
507
+ why: "no stacks (the web server gave no reason; it predates 0.4.4)",
508
+ };
509
+ }
510
+ const wsBody = await scopeWorkspacesBody(api, scope);
511
+ const shown = String(a.workspace);
512
+ const mine = new Set(workspaceRepos(wsBody, shown));
513
+ const stacks = all.filter((s) => mine.has(String(s.repo)));
514
+ const hiddenStacks = all.length - stacks.length;
515
+ const why = stacks.length
516
+ ? undefined
517
+ : hiddenStacks
518
+ ? `no stacks in workspace "${shown}": the ${hiddenStacks} stack${hiddenStacks === 1 ? "" : "s"} listed belong to repos outside it`
519
+ : ((body?.why as string | undefined) ??
520
+ "no stacks (the web server gave no reason; it predates 0.4.4)");
521
+ return withScope(
522
+ api,
523
+ scope,
524
+ a,
525
+ { ...body, stacks, ...(why ? { why } : {}) },
526
+ { count: stacks.length, noun: "stack" },
527
+ { stacks: hiddenStacks },
528
+ );
529
+ },
388
530
  ),
389
531
  read(
390
532
  "edge_status",
391
533
  "Edge status",
392
- "The HTTPS edge: up or down, its routes and its cert names.",
534
+ "The HTTPS edge: up or down, its routes and its cert names. `https` says the TLS strategy, who supplies the cert (den, or the distribution under tls strategy files) and whether HTTPS is serving; `certIssuedByDen` replaces the deprecated `certIssued`.",
393
535
  {},
394
- (_a, { api }) => api.get("/api/edge"),
536
+ async (_a, { api }) => {
537
+ const edge = await api.get("/api/edge");
538
+ const e =
539
+ edge && typeof edge === "object"
540
+ ? (edge as Record<string, unknown>)
541
+ : null;
542
+ // A web server before 0.4.4: say what certIssued means.
543
+ if (e && e.certIssued !== undefined && e.https === undefined)
544
+ return {
545
+ ...e,
546
+ certIssuedMeans:
547
+ "whether Den minted the cert itself (mkcert); false under tls strategy files, where the distribution supplies it, even while HTTPS serves. Restart the web server for https.serving.",
548
+ };
549
+ return edge;
550
+ },
395
551
  ),
396
552
  ];
397
553
 
554
+ /**
555
+ * The /api/workspaces body a scoped answer is counted against, read at most
556
+ * once every few seconds per scope (one tool call often makes several).
557
+ */
558
+ const WORKSPACES_TTL_MS = 5_000;
559
+ const workspacesSeen = new WeakMap<McpScope, { at: number; body: unknown }>();
560
+ async function scopeWorkspacesBody(
561
+ api: DenApi,
562
+ scope: McpScope,
563
+ ): Promise<unknown> {
564
+ const hit = workspacesSeen.get(scope);
565
+ if (hit && Date.now() - hit.at < WORKSPACES_TTL_MS) return hit.body;
566
+ const body = await api.get("/api/workspaces");
567
+ workspacesSeen.set(scope, { at: Date.now(), body });
568
+ return body;
569
+ }
570
+
571
+ /** `answer` with the scope it was served under (MCP-2.2). */
572
+ async function withScope(
573
+ api: DenApi,
574
+ scope: McpScope,
575
+ args: Args,
576
+ answer: Record<string, unknown>,
577
+ shows: { count: number; noun: string },
578
+ extraHidden: Record<string, number> = {},
579
+ ): Promise<Record<string, unknown>> {
580
+ const body = await scopeWorkspacesBody(api, scope);
581
+ return {
582
+ scope: scopeBlock(
583
+ scope,
584
+ body,
585
+ str(args.workspace) ?? scope.workspace,
586
+ shows,
587
+ extraHidden,
588
+ ),
589
+ ...answer,
590
+ };
591
+ }
592
+
593
+ /** A read whose HTTP error is part of the answer, not the whole call's. */
594
+ async function softGet(
595
+ api: DenApi,
596
+ path: string,
597
+ query?: Query,
598
+ ): Promise<unknown> {
599
+ try {
600
+ return await api.get(path, query);
601
+ } catch (e) {
602
+ if (e instanceof DenHttpError)
603
+ return { error: `${e.status} from ${e.path}` };
604
+ throw e;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * The org-wide graph: each active workspace's /api/graph, merged
610
+ * (mergeGraphs). With no active workspace, the web server's default one.
611
+ * `read`: an /api/workspaces body already read.
612
+ */
613
+ async function orgGraph(
614
+ api: DenApi,
615
+ get: (path: string, query?: Query) => Promise<unknown>,
616
+ read?: unknown,
617
+ ): Promise<unknown> {
618
+ const body = read ?? (await api.get("/api/workspaces"));
619
+ const active =
620
+ body &&
621
+ typeof body === "object" &&
622
+ Array.isArray((body as Record<string, unknown>).active)
623
+ ? ((body as Record<string, unknown>).active as unknown[]).map(String)
624
+ : [];
625
+ if (!active.length) {
626
+ const g = await get("/api/graph");
627
+ return g && typeof g === "object" && !Array.isArray(g)
628
+ ? { map: "org-wide", ...(g as Record<string, unknown>) }
629
+ : g;
630
+ }
631
+ const graphs = await Promise.all(
632
+ active.map(async (workspace) => ({
633
+ workspace,
634
+ graph: await get("/api/graph", { workspace }),
635
+ })),
636
+ );
637
+ return mergeGraphs(graphs);
638
+ }
639
+
398
640
  // ── Action tools ────────────────────────────────────────────────────────────
399
641
 
400
642
  /** Find the /api/services entry an action names (repo + optional service). */
@@ -580,13 +822,73 @@ export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
580
822
  export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
581
823
 
582
824
  /** The tools to register: every read tool, plus the actions the org named. */
583
- export function toolsFor(cfg: ResolvedMcpConfig): DenTool[] {
584
- return [
825
+ export function toolsFor(cfg: ResolvedMcpConfig, scope?: McpScope): DenTool[] {
826
+ const tools = [
585
827
  ...READ_TOOLS,
586
828
  ...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
587
829
  (a) => ACTION_TOOLS[a],
588
830
  ),
589
831
  ];
832
+ return scope ? tools.map((t) => pinnedTool(t, scope)) : tools;
833
+ }
834
+
835
+ /**
836
+ * Tools that answer about one workspace, and so are "this workspace only"
837
+ * on a scoped server. The rest are the org's (environments, the edge,
838
+ * releases, security scans, the org summary) or take no workspace data.
839
+ */
840
+ const WORKSPACE_TOOLS = new Set([
841
+ "status",
842
+ "services",
843
+ "applications",
844
+ "graph",
845
+ "workspaces",
846
+ "logs_search",
847
+ "logs_tail",
848
+ "tests_available",
849
+ "manifest_validate",
850
+ "stacks",
851
+ "service_up",
852
+ "service_down",
853
+ "service_restart",
854
+ "workspace_sync",
855
+ "run_tests",
856
+ ]);
857
+
858
+ /**
859
+ * A tool as a scoped server registers it (MCP-2.2): its description says
860
+ * "this workspace only", and its `workspace` parameter is dropped, since it
861
+ * can only be the pinned one. With grants the parameter stays, to name a
862
+ * granted workspace, and says which those are. callTool still refuses a
863
+ * `workspace` naming another one (a one-shot or an old client may send it).
864
+ */
865
+ export function pinnedTool(t: DenTool, scope: McpScope): DenTool {
866
+ if (!WORKSPACE_TOOLS.has(t.name)) return t;
867
+ const grants = scope.grants ?? [];
868
+ const only =
869
+ ` This workspace only ("${scope.workspace}"` +
870
+ (grants.length ? `, or a granted one: ${grants.join(", ")}` : "") +
871
+ "); every other workspace is in org_summary, names and counts only.";
872
+ let shape = t.shape;
873
+ if ("workspace" in t.shape) {
874
+ const rest = { ...t.shape };
875
+ delete rest.workspace;
876
+ // A string, not an enum of the allowed names: a schema rejection
877
+ // ("expected one of …") would hide Den's refusal, which names
878
+ // agents.<id>.grants and how a person grants one (MCP-2.3).
879
+ shape = grants.length
880
+ ? {
881
+ ...rest,
882
+ workspace: z
883
+ .string()
884
+ .optional()
885
+ .describe(
886
+ `"${scope.workspace}" (the default) or a granted workspace: ${grants.map((g) => `"${g}"`).join(", ")}; any other is refused`,
887
+ ),
888
+ }
889
+ : rest;
890
+ }
891
+ return { ...t, description: t.description + only, shape };
590
892
  }
591
893
 
592
894
  /**
@@ -719,7 +1021,12 @@ async function scopeArgs(
719
1021
  scope: McpScope,
720
1022
  ): Promise<Args> {
721
1023
  if (!verifiedScopes.has(scope)) {
722
- await assertScopeExists(ctx.api, scope);
1024
+ // The body is the one scoped answers count against (one read).
1025
+ await assertScopeExists(
1026
+ ctx.api,
1027
+ scope,
1028
+ await scopeWorkspacesBody(ctx.api, scope),
1029
+ );
723
1030
  verifiedScopes.add(scope);
724
1031
  }
725
1032
  if (!("workspace" in tool.shape)) return args;