@monster-orange/den 0.4.16 → 0.4.18

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 (33) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +94 -1
  4. package/package.json +1 -1
  5. package/src/ui/cli/agent-ops.ts +50 -0
  6. package/src/ui/cli/agents-command.ts +5 -5
  7. package/src/ui/server/agent-routes.ts +13 -1
  8. package/src/ui/server/check-pool.ts +72 -0
  9. package/src/ui/server/index.ts +105 -10
  10. package/src/ui/server/public/agents-page.js +98 -16
  11. package/src/ui/server/public/agents.html +22 -0
  12. package/src/ui/server/public/environments.html +223 -140
  13. package/src/ui/server/public/graph.css +40 -0
  14. package/src/ui/server/public/graph.js +47 -6
  15. package/src/ui/server/public/header.js +7 -0
  16. package/src/ui/server/public/i18n/es.js +46 -0
  17. package/src/ui/server/public/i18n/ja.js +46 -0
  18. package/src/ui/server/public/index.html +6 -0
  19. package/src/ui/server/public/local-dev.html +199 -27
  20. package/src/ui/server/public/log-source.js +26 -0
  21. package/src/ui/server/public/logs.html +7 -20
  22. package/src/ui/server/public/panel.js +1 -1
  23. package/src/ui/server/public/release.html +19 -1
  24. package/src/ui/server/public/services.html +18 -3
  25. package/src/ui/server/public/shell.js +360 -32
  26. package/src/ui/server/public/svc-panel.js +672 -0
  27. package/src/ui/server/public/topbar.js +34 -1
  28. package/src/ui/server/public/view-scope.js +61 -0
  29. package/src/ui/server/public/workspace-picker.js +2 -0
  30. package/src/ui/server/route-classes.ts +2 -0
  31. package/src/ui/server/run-history.ts +181 -0
  32. package/src/ui/server/session-routes.ts +94 -17
  33. package/src/ui/server/ui-assets.ts +6 -0
@@ -421,7 +421,15 @@ export function runOf(url, base = "http://localhost/") {
421
421
 
422
422
  const LS_LAST_RUN = "__DEN_CLI__-last-run";
423
423
  const SS_PENDING = "__DEN_CLI__-pending-action";
424
- const KIND = ["page", "service", "app", "agent", "action", "setting"];
424
+ const KIND = [
425
+ "page",
426
+ "service",
427
+ "app",
428
+ "agent",
429
+ "workspace",
430
+ "action",
431
+ "setting",
432
+ ];
425
433
  const kindLabel = (k) =>
426
434
  KIND.includes(k)
427
435
  ? t(`kind.${k}`)
@@ -906,6 +914,31 @@ function paletteItems() {
906
914
  run: () => go(`/agents?open=${encodeURIComponent(x.id)}`),
907
915
  });
908
916
  }
917
+ // 0.4.17: Go to workspace <ws>, for every workspace, the front agent's
918
+ // first (on the Agents page); an agent's name finds its workspace too.
919
+ const byWs = new Map();
920
+ const front = document.querySelector("#agr")?.dataset.ws;
921
+ if (front) byWs.set(front, []);
922
+ // Every workspace: the Workspace menu's list (the header read it).
923
+ for (const ws of s?.workspaceNames?.() ?? [])
924
+ if (!byWs.has(ws)) byWs.set(ws, []);
925
+ for (const x of palSessions) {
926
+ if (!x.workspace) continue;
927
+ const names = byWs.get(x.workspace) ?? [];
928
+ names.push(x.label, x.displayName ?? "", x.persona ?? "");
929
+ byWs.set(x.workspace, names);
930
+ }
931
+ const wsBase =
932
+ s?.nav?.().find((n) => n.id === "local-dev")?.href ?? "/local-dev";
933
+ for (const [ws, names] of byWs)
934
+ items.push({
935
+ key: `ws:${ws}`,
936
+ kind: "workspace",
937
+ label: t("pal.goWs", { ws }),
938
+ keywords: [ws, "workspace", "workspaces", ...names.filter(Boolean)],
939
+ hideEmpty: true,
940
+ run: () => go(`${wsBase}?ws=${encodeURIComponent(ws)}`),
941
+ });
909
942
  if (s?.nav?.().some((n) => n.id === "agents"))
910
943
  items.push({
911
944
  key: "agent:start",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One rule for org and workspace on a page (0.4.17, ORG-SPEC §5): a page
3
+ * shows its own org picker only when it filters by org. Otherwise it
4
+ * follows the top bar's View (Org and Workspace ▾), and says so with the
5
+ * same note. Environments and Releases both use this.
6
+ *
7
+ * viewScope() → { scope: "all" | <workspace>, inView: [names] }
8
+ * viewNoteHtml(view) → the "Follows the top bar" note's HTML
9
+ * viewQuery(view) → "&workspace=<ws>" for one workspace, else ""
10
+ * viewRepos(view) → the repos in view (a Set), or null for all
11
+ */
12
+
13
+ const esc = (s) =>
14
+ String(s ?? "").replace(
15
+ /[&<>"']/g,
16
+ (c) =>
17
+ ({
18
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;",
23
+ })[c],
24
+ );
25
+
26
+ /** The top bar's View: all active workspaces, or one. */
27
+ export async function viewScope() {
28
+ const v = await fetch("/api/scope", { headers: { accept: "json" } })
29
+ .then((r) => (r.ok ? r.json() : null))
30
+ .catch(() => null);
31
+ return {
32
+ scope: typeof v?.scope === "string" && v.scope ? v.scope : "all",
33
+ inView: Array.isArray(v?.inView) ? v.inView : [],
34
+ };
35
+ }
36
+
37
+ /** The same note on every page that follows the View. */
38
+ export function viewNoteHtml(view) {
39
+ const what =
40
+ view.scope === "all"
41
+ ? "all active workspaces"
42
+ : `workspace <b>${esc(view.scope)}</b>`;
43
+ return `<span class="note view-note" data-view="${esc(view.scope)}">Shows ${what}, as the top bar's View. Change it there.</span>`;
44
+ }
45
+
46
+ /** The health and services query for the View: one workspace, or none. */
47
+ export function viewQuery(view) {
48
+ return view.scope === "all"
49
+ ? ""
50
+ : `&workspace=${encodeURIComponent(view.scope)}`;
51
+ }
52
+
53
+ /** The repos in the View's workspace, or null when it is all active. */
54
+ export async function viewRepos(view) {
55
+ if (view.scope === "all") return null;
56
+ const d = await fetch("/api/workspaces", { headers: { accept: "json" } })
57
+ .then((r) => (r.ok ? r.json() : null))
58
+ .catch(() => null);
59
+ const ws = d?.workspaces?.[view.scope];
60
+ return ws ? new Set(Object.keys(ws.services ?? {})) : null;
61
+ }
@@ -93,6 +93,8 @@ export function buildScopeMenuModel(scope, data, t = enT) {
93
93
  return {
94
94
  label: active.length === 0 ? t("ws.noneLabel") : (one ?? t("ws.all")),
95
95
  items,
96
+ // Every workspace, active or not: ⌘K's Go to workspace (0.4.17).
97
+ names: Object.keys(data?.workspaces ?? {}),
96
98
  focus: focus ?? null,
97
99
  title: t(focus ? "ws.viewTitleFocus" : "ws.viewTitle", {
98
100
  ws: inView.join(", ") || t("ws.noneLabel"),
@@ -127,6 +127,8 @@ export const ROUTES: readonly RouteEntry[] = [
127
127
  "/api/release/config",
128
128
  "/api/release/status",
129
129
  "/api/release/runs",
130
+ // 0.4.17: each repo's last test run, from the run-history file.
131
+ "/api/runs/last",
130
132
  "/api/release/branches",
131
133
  "/api/release/deploy/active",
132
134
  "/api/changelog",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The server's test-run history (0.4.17): every `/api/run` from every
3
+ * browser, kept in `<state>/run-history.jsonl`, one JSON line per start and
4
+ * one per end. Workspaces' "Last test run" column reads each repo's latest
5
+ * from it, so it fills in whoever ran it and survives a restart.
6
+ *
7
+ * The file is bounded: past MAX_LINES it keeps the newest KEEP_LINES.
8
+ * Never throws: a history it can't read or write is an empty one.
9
+ */
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+
13
+ /** One test run, as its lines describe it. */
14
+ export interface RunEntry {
15
+ id: string;
16
+ pillar: string;
17
+ env: string;
18
+ /** The repos it tested (the pillar's sources), when known. */
19
+ repos: string[];
20
+ startedAt: string;
21
+ endedAt?: string;
22
+ exitCode?: number;
23
+ }
24
+
25
+ type Line =
26
+ | {
27
+ t: "start";
28
+ id: string;
29
+ pillar: string;
30
+ env: string;
31
+ repos: string[];
32
+ at: string;
33
+ }
34
+ | { t: "end"; id: string; at: string; exitCode: number };
35
+
36
+ /** What `reposOfRun` asks about the repos (index.ts gives the real ones). */
37
+ export interface RunRepoSources {
38
+ /** synthetic, smoke, journeys, security: the repos the pillar reads. */
39
+ pillarSources(pillar: string): string[];
40
+ /** suites: the repos whose `tests:` suites the run will run. */
41
+ suiteRepos(env: string, only: string[]): string[];
42
+ /** performance: every module's name (a repo's is `<repo>/<module>`). */
43
+ perfModules(): Promise<string[]>;
44
+ /** Every repo's name. */
45
+ repoNames(): string[];
46
+ }
47
+
48
+ /**
49
+ * The repos a run tests, for its history (0.4.17): every run kind that
50
+ * runs a repo's own tests fills its Last test run. integration is Den's
51
+ * own: no repo. Never throws: a source it can't read gives none.
52
+ */
53
+ export async function reposOfRun(
54
+ pillar: string,
55
+ env: string,
56
+ modules: string[],
57
+ src: RunRepoSources,
58
+ ): Promise<string[]> {
59
+ const uniq = (xs: string[]) => [...new Set(xs.filter(Boolean))];
60
+ try {
61
+ if (["synthetic", "smoke", "journeys", "security"].includes(pillar))
62
+ return uniq(src.pillarSources(pillar));
63
+ if (pillar === "suites") return uniq(src.suiteRepos(env, modules));
64
+ if (pillar === "performance") {
65
+ const repos = new Set(src.repoNames());
66
+ const chosen = modules.length ? modules : await src.perfModules();
67
+ return uniq(
68
+ chosen
69
+ .filter((n) => n.includes("/"))
70
+ .map((n) => n.split("/")[0])
71
+ .filter((r) => repos.has(r)),
72
+ );
73
+ }
74
+ } catch {
75
+ /* no repos known: the history still has the run */
76
+ }
77
+ return [];
78
+ }
79
+
80
+ export const MAX_LINES = 2000;
81
+ export const KEEP_LINES = 1000;
82
+
83
+ export function runHistoryFile(stateDir: string): string {
84
+ return path.join(stateDir, "run-history.jsonl");
85
+ }
86
+
87
+ function append(stateDir: string, line: Line): void {
88
+ const file = runHistoryFile(stateDir);
89
+ try {
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, { mode: 0o600 });
92
+ const text = fs.readFileSync(file, "utf-8");
93
+ const lines = text.split("\n").filter(Boolean);
94
+ if (lines.length > MAX_LINES) {
95
+ const tmp = `${file}.${process.pid}.tmp`;
96
+ fs.writeFileSync(tmp, `${lines.slice(-KEEP_LINES).join("\n")}\n`, {
97
+ mode: 0o600,
98
+ });
99
+ fs.renameSync(tmp, file);
100
+ }
101
+ } catch {
102
+ /* no history: the column says "—" */
103
+ }
104
+ }
105
+
106
+ /** A run began: its pillar, environment and the repos it tests. */
107
+ export function recordRunStart(
108
+ stateDir: string,
109
+ run: { id: string; pillar: string; env: string; repos: string[] },
110
+ now = new Date(),
111
+ ): void {
112
+ append(stateDir, { t: "start", ...run, at: now.toISOString() });
113
+ }
114
+
115
+ /** A run ended with `exitCode`. */
116
+ export function recordRunEnd(
117
+ stateDir: string,
118
+ id: string,
119
+ exitCode: number,
120
+ now = new Date(),
121
+ ): void {
122
+ append(stateDir, { t: "end", id, at: now.toISOString(), exitCode });
123
+ }
124
+
125
+ /** Every run in the file, oldest first. */
126
+ export function readRuns(stateDir: string): RunEntry[] {
127
+ let text = "";
128
+ try {
129
+ text = fs.readFileSync(runHistoryFile(stateDir), "utf-8");
130
+ } catch {
131
+ return [];
132
+ }
133
+ const byId = new Map<string, RunEntry>();
134
+ for (const raw of text.split("\n")) {
135
+ if (!raw) continue;
136
+ let l: Partial<Line> & Record<string, unknown>;
137
+ try {
138
+ l = JSON.parse(raw) as typeof l;
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (typeof l.id !== "string" || typeof l.at !== "string") continue;
143
+ if (l.t === "start") {
144
+ byId.set(l.id, {
145
+ id: l.id,
146
+ pillar: String(l.pillar ?? ""),
147
+ env: String(l.env ?? ""),
148
+ repos: Array.isArray(l.repos) ? l.repos.map(String) : [],
149
+ startedAt: l.at,
150
+ });
151
+ } else if (l.t === "end") {
152
+ const r = byId.get(l.id);
153
+ if (r) {
154
+ r.endedAt = l.at;
155
+ r.exitCode = typeof l.exitCode === "number" ? l.exitCode : 1;
156
+ }
157
+ }
158
+ }
159
+ return [...byId.values()];
160
+ }
161
+
162
+ /**
163
+ * Each repo's latest run. A run that never ended counts as running only
164
+ * while this server runs it (`live`); one a restart cut off has no end and
165
+ * is skipped for an earlier one.
166
+ */
167
+ export function lastRunsByRepo(
168
+ stateDir: string,
169
+ live: ReadonlySet<string> = new Set(),
170
+ ): Record<string, RunEntry & { running: boolean }> {
171
+ const out: Record<string, RunEntry & { running: boolean }> = {};
172
+ for (const r of readRuns(stateDir)) {
173
+ const running = r.endedAt === undefined && live.has(r.id);
174
+ if (r.endedAt === undefined && !running) continue;
175
+ for (const repo of r.repos) {
176
+ const cur = out[repo];
177
+ if (!cur || cur.startedAt <= r.startedAt) out[repo] = { ...r, running };
178
+ }
179
+ }
180
+ return out;
181
+ }
@@ -362,18 +362,101 @@ const TEXT = (code: number) =>
362
362
  : "Service Unavailable";
363
363
 
364
364
  /**
365
- * A plain HTTP request to an io route: it is a WebSocket. 426 for a GET,
366
- * 404 for anything else under /api/sessions/.
365
+ * Whether a session is one the io route serves: this distribution's, in a
366
+ * workspace workspaces.yml names, active or not (0.4.17: an agent you can
367
+ * start and see, you can open). The person's and the agent's checks are
368
+ * the request guard's, before this.
367
369
  */
368
- export function sendSessionHttp(
370
+ function servedSession(
371
+ s: SessionInfo,
372
+ target: string,
373
+ d: Resolved,
374
+ active: ReadonlySet<string>,
375
+ ): boolean {
376
+ const p = parseSessionName(s.name, d.dist());
377
+ return (
378
+ p !== null &&
379
+ (active.has(p.workspace) || d.workspaceExists(p.workspace)) &&
380
+ (s.ref.id === target || s.name === target)
381
+ );
382
+ }
383
+
384
+ /**
385
+ * Why the io route would refuse `target`, or null when it would open it:
386
+ * what a browser can't read from a refused upgrade (it sees only a close),
387
+ * so the page asks this with a plain GET (0.4.17).
388
+ */
389
+ async function ioRefusal(
390
+ target: string,
391
+ d: Resolved,
392
+ ): Promise<{ status: number; error: string; code: string } | null> {
393
+ let c: HostClient | null;
394
+ try {
395
+ c = await d.connectExisting();
396
+ } catch (e) {
397
+ return {
398
+ status: 503,
399
+ error: `the session host: ${(e as Error).message}`,
400
+ code: "session_host",
401
+ };
402
+ }
403
+ if (!c)
404
+ return {
405
+ status: 404,
406
+ error: `no session "${target}" (no session host is running)`,
407
+ code: "not_found",
408
+ };
409
+ try {
410
+ const active = new Set(d.activeWorkspaces());
411
+ const found = (await c.list()).some((s) =>
412
+ servedSession(s, target, d, active),
413
+ );
414
+ return found
415
+ ? null
416
+ : {
417
+ status: 404,
418
+ error: `no session "${target}" here`,
419
+ code: "not_found",
420
+ };
421
+ } catch (e) {
422
+ return {
423
+ status: 503,
424
+ error: `the session host: ${(e as Error).message}`,
425
+ code: "session_host",
426
+ };
427
+ } finally {
428
+ c.close();
429
+ }
430
+ }
431
+
432
+ /**
433
+ * A plain HTTP request to an io route: it is a WebSocket. For a GET, the
434
+ * refusal its upgrade would get (404, 503), else 426; 404 for anything
435
+ * else under /api/sessions/.
436
+ */
437
+ export async function sendSessionHttp(
369
438
  req: http.IncomingMessage,
370
439
  res: http.ServerResponse,
371
440
  pathname: string,
372
- ): void {
373
- if (
374
- IO_PATH.test(pathname) &&
375
- (req.method === "GET" || req.method === "HEAD")
376
- ) {
441
+ deps: SessionRouteDeps = {},
442
+ ): Promise<void> {
443
+ const m = IO_PATH.exec(pathname);
444
+ if (m && (req.method === "GET" || req.method === "HEAD")) {
445
+ let target = m[1];
446
+ try {
447
+ target = decodeURIComponent(target);
448
+ } catch {
449
+ /* as given */
450
+ }
451
+ const why = await ioRefusal(target, resolve(deps));
452
+ if (why) {
453
+ sendJson(
454
+ res,
455
+ { ok: false, error: why.error, code: why.code },
456
+ why.status,
457
+ );
458
+ return;
459
+ }
377
460
  res.setHeader("Upgrade", "websocket");
378
461
  res.setHeader("Connection", "Upgrade");
379
462
  sendJson(
@@ -444,16 +527,10 @@ export async function handleSessionUpgrade(
444
527
  const client = c;
445
528
  let info: SessionInfo | undefined;
446
529
  try {
447
- const dist = d.dist();
448
530
  const active = new Set(d.activeWorkspaces());
449
- info = (await client.list()).find((s) => {
450
- const p = parseSessionName(s.name, dist);
451
- return (
452
- p !== null &&
453
- active.has(p.workspace) &&
454
- (s.ref.id === target || s.name === target)
455
- );
456
- });
531
+ info = (await client.list()).find((s) =>
532
+ servedSession(s, target, d, active),
533
+ );
457
534
  } catch (e) {
458
535
  client.close();
459
536
  return refuse(
@@ -46,6 +46,12 @@ export const UI_MODULES: readonly string[] = [
46
46
  // The UI's catalogs other than English (R19; en is in shell.js).
47
47
  "/i18n/ja.js",
48
48
  "/i18n/es.js",
49
+ // 0.4.17: one org and workspace rule for Environments and Releases.
50
+ "/view-scope.js",
51
+ // 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
52
+ "/svc-panel.js",
53
+ // 0.4.18: where a service's live log comes from (Logs page and panel).
54
+ "/log-source.js",
49
55
  ];
50
56
 
51
57
  /** URL path → file inside node_modules. */