@crewhaus/hangar-ui 0.5.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 (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Runs & daemons — the fleet supervision board (`#/runs`), one harness's run
3
+ * ledger, and the run console with the live SSE feed.
4
+ *
5
+ * THE BOARD paints twice, like the Library: instantly from the fleet feed
6
+ * (whose rows carry `supervision` and `pendingApprovals` — a cheap fold, no
7
+ * transcript opened), then again as each harness's `/proc` answers with pid,
8
+ * uptime, restart countdown, control availability and last exit. Under the
9
+ * table sit the two fleet-level reads an operator actually acts on: the job
10
+ * queue, and the failure board that turns three red rows into one incident.
11
+ *
12
+ * THE CONSOLE relies on the SSE grammar rather than branching on liveness:
13
+ * `replay` (a whole RunDetail) opens every stream and `done` always closes
14
+ * it, so a finished run replays and terminates through the same code path a
15
+ * live one streams through. Trace events render as feed cards in the M1
16
+ * transcript vocabulary — same chips, same folds — with interleaved prose
17
+ * from the captured (scrubbed) log and a stats/cost HUD folded from the
18
+ * frames as they arrive.
19
+ */
20
+
21
+ import { api, streamRunEvents } from "../api.js";
22
+ import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton } from "../dom.js";
23
+ import { hrefHarness } from "../router.js";
24
+ import { shapeAccent, shapeLabel } from "../shapes.js";
25
+ import {
26
+ failureBoard,
27
+ ledgerExit,
28
+ procRow,
29
+ runHud,
30
+ runLedgerRow,
31
+ sseFrameToFeed,
32
+ } from "../supervision.js";
33
+ import { dirTail, fmtUsd, normalizeRows } from "../util.js";
34
+ import { actionTwin } from "./control.js";
35
+ import { renderJobs } from "./jobs.js";
36
+ import { procActionBar } from "./proc.js";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // #/runs — the fleet board
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const BOARD_COLUMNS = [
43
+ "Harness",
44
+ "State",
45
+ "PID",
46
+ "Uptime",
47
+ "Restarts",
48
+ "Last exit",
49
+ "Control",
50
+ "Actions",
51
+ ];
52
+
53
+ export async function renderRunsBoard(root) {
54
+ clear(root).appendChild(skeleton(6));
55
+ const feed = await api.harnesses();
56
+ const rows = normalizeRows(feed).filter((r) => r.missingSince === null);
57
+ clear(root);
58
+ root.appendChild(
59
+ el("div", { class: "rollup" }, [
60
+ el("span", { text: `${rows.length} registered · supervision board` }),
61
+ el("span", { class: "muted", text: "· loading process detail…" }),
62
+ ]),
63
+ );
64
+ if (rows.length === 0) {
65
+ root.appendChild(emptyState("No harnesses registered yet", "crewhaus harness add <dir>"));
66
+ return;
67
+ }
68
+
69
+ const tbody = el("tbody");
70
+ const table = el("div", { class: "table-scroll" }, [
71
+ el("table", { class: "fleet" }, [
72
+ el(
73
+ "thead",
74
+ null,
75
+ el(
76
+ "tr",
77
+ null,
78
+ BOARD_COLUMNS.map((h) => el("th", { text: h })),
79
+ ),
80
+ ),
81
+ tbody,
82
+ ]),
83
+ ]);
84
+ root.appendChild(table);
85
+
86
+ const failures = el("section", { class: "card" }, [
87
+ el("h3", { class: "card-title", text: "Failure board" }),
88
+ el("p", { class: "muted", text: "loading…" }),
89
+ ]);
90
+ const jobsCard = el("section", { class: "card" }, [
91
+ el("h3", { class: "card-title", text: "Job queue" }),
92
+ ]);
93
+ const jobsBody = el("div");
94
+ jobsCard.appendChild(jobsBody);
95
+ root.appendChild(el("div", { class: "ov-grid" }, [failures, jobsCard]));
96
+ renderJobs(jobsBody);
97
+
98
+ const reload = () => renderRunsBoard(root);
99
+ const nowMs = Date.now();
100
+ const placeholders = new Map();
101
+ for (const row of rows) {
102
+ const tr = boardRow(row, null, nowMs, reload);
103
+ placeholders.set(row.id, tr);
104
+ tbody.appendChild(tr);
105
+ }
106
+
107
+ // Hydrate each row with its process detail. One request per harness, all
108
+ // in flight together; a failing one leaves its cold row standing.
109
+ const details = await Promise.allSettled(rows.map((r) => api.proc(r.id)));
110
+ if (!root.isConnected) return;
111
+ const exits = [];
112
+ rows.forEach((row, i) => {
113
+ const settled = details[i];
114
+ const payload = settled.status === "fulfilled" ? settled.value : null;
115
+ const tr = placeholders.get(row.id);
116
+ if (tr === undefined) return;
117
+ tr.replaceWith(boardRow(row, payload, Date.now(), reload));
118
+ // The live snapshot when this manager supervised the run; the harness's
119
+ // own ledger when it did not (a console restart must not erase failures).
120
+ const exit = payload === null ? null : (payload.lastExit ?? ledgerExit(payload.recentRuns));
121
+ if (exit !== null) {
122
+ exits.push({ id: row.id, name: row.specName || dirTail(row.dir, 1), lastExit: exit });
123
+ }
124
+ });
125
+ const rollup = root.querySelector(".rollup .muted");
126
+ if (rollup !== null) rollup.remove();
127
+ clear(failures).appendChild(el("h3", { class: "card-title", text: "Failure board" }));
128
+ const groups = failureBoard(exits);
129
+ if (groups.length === 0) {
130
+ failures.appendChild(
131
+ el("p", { class: "muted", text: "No harness has a failing last exit — nothing to group." }),
132
+ );
133
+ } else {
134
+ for (const group of groups) {
135
+ failures.appendChild(
136
+ el("div", { class: "fail-group" }, [
137
+ el("div", { class: "fail-head" }, [
138
+ dot(group.dot, group.label),
139
+ // The classification's own sentence, when it says more than the
140
+ // count line already does.
141
+ group.title !== "" && !group.label.includes(group.title)
142
+ ? el("span", { class: "muted", text: group.title })
143
+ : null,
144
+ ]),
145
+ group.remediation !== null
146
+ ? el("div", { class: "muted reason", text: group.remediation })
147
+ : null,
148
+ el(
149
+ "div",
150
+ { class: "fail-members" },
151
+ group.harnesses.map((h) =>
152
+ el("a", { class: "chip", href: hrefHarness(h.id, "runs"), text: h.name }),
153
+ ),
154
+ ),
155
+ ]),
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ function boardRow(feedRow, proc, nowMs, reload) {
162
+ const name = feedRow.specName || dirTail(feedRow.dir, 1) || feedRow.id;
163
+ // The fleet feed's `supervision` is the instant answer; `/proc` refines it.
164
+ const row = procRow(proc ?? { state: feedRow.supervision ?? "stopped" }, nowMs);
165
+ const cold = proc === null;
166
+ return el("tr", { class: "fleet-row" }, [
167
+ el("td", { class: "cell-name" }, [
168
+ el("div", { class: "name-line" }, [
169
+ el("span", {
170
+ class: "shape-badge",
171
+ style: { "--accent": shapeAccent(feedRow.target) },
172
+ text: shapeLabel(feedRow.target),
173
+ }),
174
+ el("a", { class: "name-link", href: hrefHarness(feedRow.id, "runs"), text: name }),
175
+ ]),
176
+ el("div", { class: "sub mono", title: feedRow.dir, text: dirTail(feedRow.dir, 2) }),
177
+ feedRow.pendingApprovals > 0
178
+ ? el("a", {
179
+ class: "chip chip-warn",
180
+ href: "#/approvals",
181
+ text: `${feedRow.pendingApprovals} approval${feedRow.pendingApprovals === 1 ? "" : "s"} waiting`,
182
+ })
183
+ : null,
184
+ ]),
185
+ el("td", null, [
186
+ dot(row.pill.dot, row.pill.label),
187
+ row.adopted ? el("span", { class: "chip", text: "adopted" }) : null,
188
+ row.draining ? el("span", { class: "chip chip-warn", text: "draining" }) : null,
189
+ ]),
190
+ el("td", { class: "num", text: row.pid === null ? "—" : String(row.pid) }),
191
+ el("td", { class: "num", text: cold ? "…" : row.uptime }),
192
+ el("td", { class: "num" }, [
193
+ el("span", { text: String(row.restartsInWindow) }),
194
+ row.restart !== null ? el("div", { class: "chip chip-warn", text: row.restart.label }) : null,
195
+ ]),
196
+ el("td", null, [
197
+ row.lastExit === null
198
+ ? el("span", { class: "muted", text: cold ? "…" : "—" })
199
+ : el("span", null, [
200
+ dot(row.lastExit.dot, row.lastExit.title),
201
+ row.lastExit.classLabel !== null
202
+ ? el("span", { class: "chip chip-warn", text: row.lastExit.classLabel })
203
+ : null,
204
+ row.lastExit.fromLedger
205
+ ? el("span", {
206
+ class: "chip",
207
+ title: "recorded in the harness's run ledger, not observed by this manager",
208
+ text: "from the ledger",
209
+ })
210
+ : null,
211
+ ]),
212
+ ]),
213
+ el("td", null, [
214
+ dot(row.control.dot, row.control.label),
215
+ !row.control.available && row.control.reason !== null
216
+ ? el("div", { class: "muted reason", text: row.control.reason })
217
+ : null,
218
+ ]),
219
+ el("td", { class: "cell-actions" }, [
220
+ cold
221
+ ? el("span", { class: "muted", text: "…" })
222
+ : procActionBar(feedRow.id, feedRow.dir, row, reload),
223
+ ]),
224
+ ]);
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // #/h/:id/runs — the ledger, and one run's console
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export async function renderRuns(root, ctx) {
232
+ if (ctx.route.runId !== undefined) {
233
+ await renderRunConsole(root, ctx, ctx.route.runId);
234
+ return;
235
+ }
236
+ clear(root).appendChild(skeleton(6));
237
+ const [procRes, runsRes] = await Promise.allSettled([api.proc(ctx.id), api.runs(ctx.id)]);
238
+ clear(root);
239
+ const reload = () => renderRuns(root, ctx);
240
+
241
+ if (procRes.status === "fulfilled" && procRes.value !== null) {
242
+ const row = procRow(procRes.value, Date.now());
243
+ root.appendChild(
244
+ el("section", { class: "card" }, [
245
+ el("h3", { class: "card-title" }, [
246
+ el("span", { text: "Supervision" }),
247
+ dot(row.pill.dot, row.pill.label),
248
+ row.runId !== null
249
+ ? el("a", {
250
+ class: "mono",
251
+ href: hrefHarness(ctx.id, "runs", row.runId),
252
+ text: "watch the live run →",
253
+ })
254
+ : null,
255
+ ]),
256
+ procActionBar(ctx.id, ctx.dir ?? "", row, reload),
257
+ ]),
258
+ );
259
+ }
260
+
261
+ const data = runsRes.status === "fulfilled" ? runsRes.value : null;
262
+ const runs = data && Array.isArray(data.runs) ? data.runs : [];
263
+ if (runs.length === 0) {
264
+ root.appendChild(emptyState("No runs recorded yet", "crewhaus daemon start"));
265
+ return;
266
+ }
267
+ const nowMs = Date.now();
268
+ const tbody = el("tbody");
269
+ for (const entry of runs) {
270
+ const row = runLedgerRow(entry, nowMs);
271
+ tbody.appendChild(
272
+ el("tr", null, [
273
+ el("td", null, [
274
+ el("a", {
275
+ class: "mono name-link",
276
+ href: hrefHarness(ctx.id, "runs", row.runId),
277
+ text: row.runId,
278
+ }),
279
+ ]),
280
+ el("td", { class: "mono", text: row.kind }),
281
+ el("td", { text: row.startedLabel }),
282
+ el("td", { class: "num", text: row.duration }),
283
+ el("td", null, [
284
+ dot(row.dot, row.exitLabel),
285
+ row.forced ? el("span", { class: "chip chip-warn", text: "forced" }) : null,
286
+ ]),
287
+ ]),
288
+ );
289
+ }
290
+ root.appendChild(
291
+ el("div", { class: "table-scroll" }, [
292
+ el("table", { class: "fleet" }, [
293
+ el(
294
+ "thead",
295
+ null,
296
+ el(
297
+ "tr",
298
+ null,
299
+ ["Run", "Kind", "Started", "Duration", "Outcome"].map((h) => el("th", { text: h })),
300
+ ),
301
+ ),
302
+ tbody,
303
+ ]),
304
+ ]),
305
+ );
306
+ if (data && data.truncated === true) {
307
+ root.appendChild(
308
+ el("p", { class: "muted", text: "Long ledger — showing the most recent runs only." }),
309
+ );
310
+ }
311
+ }
312
+
313
+ /**
314
+ * One run's console. Opens the SSE feed, renders `replay` history and any
315
+ * live frames into the same feed, and closes the stream when the view is
316
+ * replaced (a navigation must not leave a reader dangling).
317
+ */
318
+ async function renderRunConsole(root, ctx, runId) {
319
+ clear(root);
320
+ root.appendChild(
321
+ el("div", { class: "crumb-line" }, [
322
+ el("a", { href: hrefHarness(ctx.id, "runs"), text: "← runs" }),
323
+ el("span", { class: "mono muted", text: runId }),
324
+ actionTwin("logs", { dir: ctx.dir ?? "", runId }),
325
+ ]),
326
+ );
327
+
328
+ const hudNode = el("div", { class: "hud" });
329
+ const banner = el("div", { class: "run-banner" });
330
+ const feed = el("div", { class: "transcript run-feed" });
331
+ root.appendChild(hudNode);
332
+ root.appendChild(banner);
333
+ root.appendChild(el("div", { class: "feed-wrap" }, [feed]));
334
+
335
+ const items = [];
336
+ const drawHud = () => {
337
+ const hud = runHud(items);
338
+ clear(hudNode);
339
+ const stat = (label, value) =>
340
+ el("div", { class: "mini" }, [
341
+ el("div", { class: "mini-num", text: value }),
342
+ el("div", { class: "mini-label", text: label }),
343
+ ]);
344
+ hudNode.appendChild(
345
+ el("div", { class: "mini-row" }, [
346
+ stat("events", String(hud.events)),
347
+ stat("turns", String(hud.turns)),
348
+ stat(
349
+ "tool calls",
350
+ `${hud.tools}${hud.toolErrors > 0 ? ` / ${hud.toolErrors} failed` : ""}`,
351
+ ),
352
+ stat("cost", fmtUsd(hud.costUsd)),
353
+ stat("tokens in/out", `${hud.inputTokens}/${hud.outputTokens}`),
354
+ ]),
355
+ );
356
+ };
357
+ drawHud();
358
+
359
+ let done = false;
360
+ const push = (item) => {
361
+ items.push(item);
362
+ feed.appendChild(feedNode(item));
363
+ if (item.type === "exit" && item.banner !== null) {
364
+ clear(banner).appendChild(exitBannerNode(item.banner));
365
+ }
366
+ if (item.type === "done") {
367
+ done = true;
368
+ banner.appendChild(el("span", { class: "chip", text: `stream closed — ${item.detail}` }));
369
+ }
370
+ };
371
+
372
+ // The stream is bound to this node: the first frame that arrives after the
373
+ // router replaced the view aborts the reader instead of leaving it holding
374
+ // a connection.
375
+ const stream = streamRunEvents(ctx.id, runId, (frame) => {
376
+ if (!feed.isConnected) {
377
+ stream.close();
378
+ return;
379
+ }
380
+ if (frame.event === "error") {
381
+ const message = frame.data?.message ? String(frame.data.message) : "stream failed";
382
+ clear(banner).appendChild(dot("bad", message));
383
+ return;
384
+ }
385
+ if (frame.event === "closed") {
386
+ // Only worth saying when the server never sent its terminal `done` —
387
+ // that is the difference between "finished" and "dropped".
388
+ if (!done) {
389
+ banner.appendChild(el("span", { class: "chip chip-warn", text: "connection dropped" }));
390
+ }
391
+ return;
392
+ }
393
+ for (const item of sseFrameToFeed(frame.event, frame.data)) push(item);
394
+ drawHud();
395
+ });
396
+ }
397
+
398
+ function exitBannerNode(banner) {
399
+ return el("div", { class: "exit-banner" }, [
400
+ dot(banner.dot, banner.title),
401
+ banner.classLabel !== null
402
+ ? el("span", { class: "chip chip-warn", text: banner.classLabel })
403
+ : null,
404
+ banner.exitCode !== null
405
+ ? el("span", { class: "chip", text: `exit ${banner.exitCode}` })
406
+ : null,
407
+ banner.restartable
408
+ ? el("span", { class: "chip", text: "restartable" })
409
+ : el("span", { class: "chip chip-warn", text: "will not auto-restart" }),
410
+ banner.remediation !== null
411
+ ? el("div", { class: "muted reason", text: banner.remediation })
412
+ : null,
413
+ ]);
414
+ }
415
+
416
+ /** One feed item → its card, in the M1 transcript vocabulary. */
417
+ function feedNode(item) {
418
+ if (item.type === "prose") {
419
+ return el("pre", { class: "prose-pre feed-prose", text: item.lines.join("\n") });
420
+ }
421
+ if (item.type === "marker") {
422
+ return el("div", { class: "gutter" }, [
423
+ el("span", { class: "chip", text: item.title }),
424
+ item.detail ? el("span", { class: "muted", text: item.detail }) : null,
425
+ ]);
426
+ }
427
+ if (item.type === "state") {
428
+ return el("div", { class: "gutter" }, [dot(item.dot, item.title)]);
429
+ }
430
+ if (item.type === "exit") {
431
+ return el("div", { class: "gutter" }, [dot(item.dot, `exited — ${item.title}`)]);
432
+ }
433
+ if (item.type === "done") {
434
+ return el("div", { class: "gutter" }, [
435
+ el("span", { class: "chip", text: "done" }),
436
+ el("span", { class: "muted", text: item.detail }),
437
+ ]);
438
+ }
439
+ // A trace event: a fold whose summary reads at a glance and whose body is
440
+ // the raw (already masked + scrubbed) payload.
441
+ return collapsible(
442
+ [
443
+ el("span", { class: "chip chip-tool", text: item.kind }),
444
+ item.dot !== null ? dot(item.dot, item.title) : el("span", { text: item.title }),
445
+ el("span", { class: "muted tool-summary", text: item.detail ?? "" }),
446
+ ],
447
+ [
448
+ item.remediation ? el("p", null, [el("strong", { text: "Fix: " }), item.remediation]) : null,
449
+ jsonPre(item.payload),
450
+ ],
451
+ );
452
+ }