@crewhaus/hangar-ui 0.5.7 → 0.6.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.
- package/README.md +52 -8
- package/assets/hangar.css +205 -0
- package/assets/js/api.js +2 -0
- package/assets/js/app.js +10 -1
- package/assets/js/dom.js +30 -0
- package/assets/js/router.js +16 -0
- package/assets/js/routes.js +80 -2
- package/assets/js/supervision.js +70 -1
- package/assets/js/util.js +1 -0
- package/assets/js/views/advisor.js +631 -0
- package/assets/js/views/library.js +139 -11
- package/assets/js/views/models.js +430 -0
- package/dist/embed-suite.d.ts +1 -0
- package/dist/embed-suite.js +289 -0
- package/dist/index.js +4 -0
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { api } from "../api.js";
|
|
9
|
-
import { asOf, clear, copyBtn, dot, el, emptyState, skeleton, toast } from "../dom.js";
|
|
9
|
+
import { asOf, clear, copyBtn, dot, el, emptyState, skeleton, toast, withTip } from "../dom.js";
|
|
10
10
|
import { hrefHarness } from "../router.js";
|
|
11
11
|
import { shapeAccent, shapeLabel } from "../shapes.js";
|
|
12
12
|
import { procStatePill } from "../supervision.js";
|
|
@@ -27,7 +27,8 @@ import { renderOnboarding, shouldOnboard } from "./onboarding.js";
|
|
|
27
27
|
const state = {
|
|
28
28
|
sortKey: "name",
|
|
29
29
|
sortDir: "asc",
|
|
30
|
-
|
|
30
|
+
// {kind:"all"} | {kind:"group",name} | {kind:"smart",id} | {kind:"hidden"}
|
|
31
|
+
filter: { kind: "all" },
|
|
31
32
|
query: "",
|
|
32
33
|
};
|
|
33
34
|
|
|
@@ -122,13 +123,19 @@ function normalizeGroups(payload, rows) {
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function applyFilter(rows, nowMs) {
|
|
125
|
-
|
|
126
|
+
// Hidden rows are curation, not data loss: they stay registered but stay
|
|
127
|
+
// OUT of every view except the explicit Hidden bucket — "don't show all
|
|
128
|
+
// harnesses unless added".
|
|
129
|
+
let out =
|
|
130
|
+
state.filter.kind === "hidden"
|
|
131
|
+
? rows.filter((r) => r.hidden === true)
|
|
132
|
+
: rows.filter((r) => r.hidden !== true);
|
|
126
133
|
if (state.filter.kind === "group") {
|
|
127
134
|
const name = state.filter.name;
|
|
128
135
|
out = out.filter((r) => r.groups.includes(name));
|
|
129
136
|
} else if (state.filter.kind === "smart") {
|
|
130
137
|
const smart = deriveSmartGroups(rows, nowMs).find((g) => g.id === state.filter.id);
|
|
131
|
-
out = smart ? smart.rows : [];
|
|
138
|
+
out = smart ? smart.rows.filter((r) => r.hidden !== true) : [];
|
|
132
139
|
}
|
|
133
140
|
if (state.query !== "") {
|
|
134
141
|
const q = state.query.toLowerCase();
|
|
@@ -200,16 +207,28 @@ function renderRail(rows, groups, nowMs, redraw, reload) {
|
|
|
200
207
|
);
|
|
201
208
|
return btn;
|
|
202
209
|
};
|
|
210
|
+
const hiddenCount = rows.filter((r) => r.hidden === true).length;
|
|
203
211
|
rail.appendChild(
|
|
204
|
-
item("All harnesses", rows.length, state.filter.kind === "all", null, () => {
|
|
212
|
+
item("All harnesses", rows.length - hiddenCount, state.filter.kind === "all", null, () => {
|
|
205
213
|
state.filter = { kind: "all" };
|
|
206
214
|
redraw();
|
|
207
215
|
}),
|
|
208
216
|
);
|
|
217
|
+
// The curated-out entries get their own bucket rather than vanishing:
|
|
218
|
+
// hiding is reversible, and a bucket with a count is how you find them.
|
|
219
|
+
if (hiddenCount > 0) {
|
|
220
|
+
rail.appendChild(
|
|
221
|
+
item("Hidden", hiddenCount, state.filter.kind === "hidden", null, () => {
|
|
222
|
+
state.filter = { kind: "hidden" };
|
|
223
|
+
redraw();
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
209
227
|
|
|
228
|
+
const shown = rows.filter((r) => r.hidden !== true);
|
|
210
229
|
if (groups.length > 0) rail.appendChild(el("div", { class: "rail-head", text: "Groups" }));
|
|
211
230
|
for (const g of groups) {
|
|
212
|
-
const count =
|
|
231
|
+
const count = shown.filter((r) => r.groups.includes(g.name)).length;
|
|
213
232
|
const active = state.filter.kind === "group" && state.filter.name === g.name;
|
|
214
233
|
rail.appendChild(
|
|
215
234
|
item(g.name, count, active, g.color || null, () => {
|
|
@@ -229,7 +248,7 @@ function renderRail(rows, groups, nowMs, redraw, reload) {
|
|
|
229
248
|
rail.appendChild(newBtn);
|
|
230
249
|
|
|
231
250
|
rail.appendChild(el("div", { class: "rail-head", text: "Smart groups" }));
|
|
232
|
-
for (const g of deriveSmartGroups(
|
|
251
|
+
for (const g of deriveSmartGroups(shown, nowMs)) {
|
|
233
252
|
const active = state.filter.kind === "smart" && state.filter.id === g.id;
|
|
234
253
|
rail.appendChild(
|
|
235
254
|
item(g.label, g.rows.length, active, null, () => {
|
|
@@ -307,10 +326,104 @@ function renderToolbar(reload) {
|
|
|
307
326
|
dirInput.focus();
|
|
308
327
|
};
|
|
309
328
|
|
|
310
|
-
const
|
|
329
|
+
const findBtn = withTip(
|
|
330
|
+
el("button", { class: "btn", type: "button", text: "Find harnesses…" }),
|
|
331
|
+
"Discovery WITHOUT registration: walks the scan roots and lists the harnesses that are not yet added, each one an Add away — the Library only shows what you add.",
|
|
332
|
+
);
|
|
333
|
+
const scanBtn = withTip(
|
|
334
|
+
el("button", { class: "btn btn-ghost", type: "button", text: "Scan (add all)" }),
|
|
335
|
+
"The bulk fallback: registers EVERY harness found under the scan roots. Prefer Find harnesses to pick which ones join the Library.",
|
|
336
|
+
);
|
|
311
337
|
const rootBtn = el("button", { class: "btn", type: "button", text: "Add scan root…" });
|
|
312
|
-
const addBtn =
|
|
313
|
-
|
|
338
|
+
const addBtn = withTip(
|
|
339
|
+
el("button", { class: "btn btn-primary", type: "button", text: "Add harness…" }),
|
|
340
|
+
"Register one harness by its directory — the default gesture.",
|
|
341
|
+
);
|
|
342
|
+
const bar = el("div", { class: "toolbar" }, [search, addBtn, findBtn, rootBtn, scanBtn]);
|
|
343
|
+
|
|
344
|
+
// The find panel: candidates from GET /api/registry/discover, each with an
|
|
345
|
+
// Add. Only one open at a time, sharing the openedForm slot with the two
|
|
346
|
+
// dir forms.
|
|
347
|
+
findBtn.addEventListener("click", async () => {
|
|
348
|
+
if (openedForm !== null) openedForm.remove();
|
|
349
|
+
findBtn.disabled = true;
|
|
350
|
+
let found = null;
|
|
351
|
+
try {
|
|
352
|
+
found = await api.discover();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
toast(`Find failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
|
+
} finally {
|
|
356
|
+
findBtn.disabled = false;
|
|
357
|
+
}
|
|
358
|
+
if (found === null) return;
|
|
359
|
+
const panel = el("div", { class: "card" });
|
|
360
|
+
const candidates = Array.isArray(found.candidates) ? found.candidates : [];
|
|
361
|
+
if (found.roots === 0) {
|
|
362
|
+
toast("No scan roots configured — add one and discovery can search this machine", "info");
|
|
363
|
+
panel.remove();
|
|
364
|
+
rootBtn.click();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
panel.appendChild(
|
|
368
|
+
el("div", { class: "rollup" }, [
|
|
369
|
+
el("strong", {
|
|
370
|
+
text: `Found ${candidates.length} unregistered harness${candidates.length === 1 ? "" : "es"}`,
|
|
371
|
+
}),
|
|
372
|
+
el("span", {
|
|
373
|
+
class: "muted",
|
|
374
|
+
text: ` under ${found.roots} scan root${found.roots === 1 ? "" : "s"} (${Number(found.alreadyRegistered ?? 0)} already added)`,
|
|
375
|
+
}),
|
|
376
|
+
el("button", {
|
|
377
|
+
class: "btn btn-ghost",
|
|
378
|
+
type: "button",
|
|
379
|
+
text: "Close",
|
|
380
|
+
onClick: () => panel.remove(),
|
|
381
|
+
}),
|
|
382
|
+
]),
|
|
383
|
+
);
|
|
384
|
+
if (candidates.length === 0) {
|
|
385
|
+
panel.appendChild(emptyState(String(found.note ?? "Nothing new under the scan roots")));
|
|
386
|
+
}
|
|
387
|
+
for (const candidate of candidates) {
|
|
388
|
+
const addOne = el("button", { class: "btn btn-primary", type: "button", text: "Add" });
|
|
389
|
+
addOne.addEventListener("click", () => {
|
|
390
|
+
addOne.disabled = true;
|
|
391
|
+
tryWrite("Add harness", () => api.addHarness(candidate.dir), reload);
|
|
392
|
+
});
|
|
393
|
+
panel.appendChild(
|
|
394
|
+
el("div", { class: "find-row" }, [
|
|
395
|
+
el("strong", { text: String(candidate.specName ?? "") }),
|
|
396
|
+
el("span", { class: "chip", text: String(candidate.target ?? "unknown") }),
|
|
397
|
+
el("span", {
|
|
398
|
+
class: "mono muted",
|
|
399
|
+
title: String(candidate.dir ?? ""),
|
|
400
|
+
text: String(candidate.dir ?? ""),
|
|
401
|
+
}),
|
|
402
|
+
addOne,
|
|
403
|
+
]),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (candidates.length > 1) {
|
|
407
|
+
const addAll = el("button", {
|
|
408
|
+
class: "btn",
|
|
409
|
+
type: "button",
|
|
410
|
+
text: `Add all ${candidates.length}`,
|
|
411
|
+
});
|
|
412
|
+
addAll.addEventListener("click", () => {
|
|
413
|
+
addAll.disabled = true;
|
|
414
|
+
tryWrite(
|
|
415
|
+
"Add all",
|
|
416
|
+
async () => {
|
|
417
|
+
for (const candidate of candidates) await api.addHarness(candidate.dir);
|
|
418
|
+
},
|
|
419
|
+
reload,
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
panel.appendChild(el("div", { class: "missing-actions" }, [addAll]));
|
|
423
|
+
}
|
|
424
|
+
bar.after(panel);
|
|
425
|
+
openedForm = panel;
|
|
426
|
+
});
|
|
314
427
|
|
|
315
428
|
scanBtn.addEventListener("click", async () => {
|
|
316
429
|
scanBtn.disabled = true;
|
|
@@ -567,9 +680,21 @@ function editorRow(row, groups, reload) {
|
|
|
567
680
|
cb.checked = row.groups.includes(g.name);
|
|
568
681
|
return { name: g.name, cb };
|
|
569
682
|
});
|
|
683
|
+
const hiddenCb = el("input", { type: "checkbox", "aria-label": "hide from the library" });
|
|
684
|
+
hiddenCb.checked = row.hidden === true;
|
|
570
685
|
const form = el("form", { class: "row-editor" }, [
|
|
571
686
|
el("label", { class: "field" }, [el("span", { text: "Tags" }), tags]),
|
|
572
687
|
el("label", { class: "field" }, [el("span", { text: "Notes" }), notes]),
|
|
688
|
+
el("div", { class: "field" }, [
|
|
689
|
+
el("span", { text: "Visibility" }),
|
|
690
|
+
withTip(
|
|
691
|
+
el("label", { class: "check" }, [
|
|
692
|
+
hiddenCb,
|
|
693
|
+
el("span", { text: "Hidden — keep registered, out of the default view" }),
|
|
694
|
+
]),
|
|
695
|
+
"Hiding is curation, not removal: state, groups and history survive, and the Hidden bucket in the rail lists it until you bring it back.",
|
|
696
|
+
),
|
|
697
|
+
]),
|
|
573
698
|
el("div", { class: "field" }, [
|
|
574
699
|
el("span", { text: groups.length > 0 ? "Groups" : "Groups (none defined yet)" }),
|
|
575
700
|
el(
|
|
@@ -589,13 +714,16 @@ function editorRow(row, groups, reload) {
|
|
|
589
714
|
.map((t) => t.trim())
|
|
590
715
|
.filter((t) => t !== "");
|
|
591
716
|
const groupList = checks.filter((c) => c.cb.checked).map((c) => c.name);
|
|
592
|
-
// The server's registry writes are per-field PUTs; save all
|
|
717
|
+
// The server's registry writes are per-field PUTs; save all four.
|
|
593
718
|
tryWrite(
|
|
594
719
|
"Save",
|
|
595
720
|
async () => {
|
|
596
721
|
await api.setTags(row.id, tagList);
|
|
597
722
|
await api.setNotes(row.id, notes.value);
|
|
598
723
|
await api.setGroups(row.id, groupList);
|
|
724
|
+
if (hiddenCb.checked !== (row.hidden === true)) {
|
|
725
|
+
await api.setHidden(row.id, hiddenCb.checked);
|
|
726
|
+
}
|
|
599
727
|
},
|
|
600
728
|
reload,
|
|
601
729
|
);
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models tab (0.6.0 design §8.3) — the answer to "which model served, with
|
|
3
|
+
* which settings, at what cost, and is the cheap lane winning?"
|
|
4
|
+
*
|
|
5
|
+
* Four panels, in the order an operator asks the questions:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Registry** — the `models:` profiles this spec declares, and the
|
|
8
|
+
* `model_pool` candidates that reference them (one card per pool: a
|
|
9
|
+
* crew role or a workflow step declares one as readily as `agent:`).
|
|
10
|
+
* 2. **Per-profile spend** — the same cost fold the Costs tab reads, split
|
|
11
|
+
* by `role` and by `profile` instead of by model. This is where a
|
|
12
|
+
* judge, an escalation or a sub-agent's spend becomes visible: those
|
|
13
|
+
* calls carry the attribution, and until 0.6.0 nothing rendered it.
|
|
14
|
+
* 3. **Leaderboard** — the learned arms ranked inside each routing band,
|
|
15
|
+
* with the arm a `learned` policy would exploit starred.
|
|
16
|
+
* 4. **Route timeline** — one run's durable routing lines in order, so the
|
|
17
|
+
* shape of a hybrid turn (draft → judge → escalate) can be read back.
|
|
18
|
+
*
|
|
19
|
+
* READ-ONLY. Nothing on this screen writes: the roster, the rules, the
|
|
20
|
+
* strategy membership and the conservative floor are human-owned and travel
|
|
21
|
+
* through `crewhaus propose`; the arms are the runtime's to write.
|
|
22
|
+
*
|
|
23
|
+
* The pure shaping functions are exported for unit tests; the renderers are
|
|
24
|
+
* thin DOM builders over them, in the M2 tradition.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { api } from "../api.js";
|
|
28
|
+
import { clear, collapsible, el, emptyState, jsonPre, skeleton } from "../dom.js";
|
|
29
|
+
import { fmtCount, fmtUsd, usdFromMicros } from "../util.js";
|
|
30
|
+
|
|
31
|
+
/** A spend row's share of the total, in [0, 1]. Pure. */
|
|
32
|
+
export function spendShare(usdMicros, totalUsdMicros) {
|
|
33
|
+
const total = typeof totalUsdMicros === "number" ? totalUsdMicros : 0;
|
|
34
|
+
const part = typeof usdMicros === "number" ? usdMicros : 0;
|
|
35
|
+
return total > 0 ? part / total : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Payload → the per-profile spend rows the table renders, biggest first.
|
|
40
|
+
* The server already ranks them; this normalizes the shape and attaches the
|
|
41
|
+
* share so the bar and the number cannot disagree. Pure.
|
|
42
|
+
*/
|
|
43
|
+
export function profileSpendRows(payload) {
|
|
44
|
+
const spend =
|
|
45
|
+
payload && typeof payload.spend === "object" && payload.spend !== null ? payload.spend : {};
|
|
46
|
+
const total = typeof spend.totalUsdMicros === "number" ? spend.totalUsdMicros : 0;
|
|
47
|
+
const rows = Array.isArray(spend.byProfile) ? spend.byProfile : [];
|
|
48
|
+
return rows.map((row) => ({
|
|
49
|
+
profile: String(row?.profile ?? "(none)"),
|
|
50
|
+
calls: typeof row?.calls === "number" ? row.calls : 0,
|
|
51
|
+
usdMicros: typeof row?.usdMicros === "number" ? row.usdMicros : 0,
|
|
52
|
+
share: spendShare(row?.usdMicros, total),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Payload → the declared pools, one row per host that declares one. Pure.
|
|
58
|
+
*
|
|
59
|
+
* `model_pool` is not an `agent:` field — a crew role, a workflow step, a
|
|
60
|
+
* graph node or a sub-agent declares one too — so the tab renders a card per
|
|
61
|
+
* pool. A manager one version behind serves only `pool`; that single view is
|
|
62
|
+
* used as the one row rather than claiming nothing is declared.
|
|
63
|
+
*/
|
|
64
|
+
export function declaredPools(payload) {
|
|
65
|
+
const rows = Array.isArray(payload?.pools) ? payload.pools : [];
|
|
66
|
+
const shaped = rows
|
|
67
|
+
.filter((row) => row && typeof row.pool === "object" && row.pool !== null)
|
|
68
|
+
.map((row) => ({ hostPath: String(row.hostPath ?? "model_pool"), pool: row.pool }));
|
|
69
|
+
if (shaped.length > 0) return shaped;
|
|
70
|
+
const single = payload?.pool;
|
|
71
|
+
return single && typeof single === "object" && single.declared === true
|
|
72
|
+
? [{ hostPath: "model_pool", pool: single }]
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The same shaping for the per-role split. Pure. */
|
|
77
|
+
export function roleSpendRows(payload) {
|
|
78
|
+
const spend =
|
|
79
|
+
payload && typeof payload.spend === "object" && payload.spend !== null ? payload.spend : {};
|
|
80
|
+
const total = typeof spend.totalUsdMicros === "number" ? spend.totalUsdMicros : 0;
|
|
81
|
+
const rows = Array.isArray(spend.byRole) ? spend.byRole : [];
|
|
82
|
+
return rows.map((row) => ({
|
|
83
|
+
role: String(row?.role ?? "primary"),
|
|
84
|
+
calls: typeof row?.calls === "number" ? row.calls : 0,
|
|
85
|
+
usdMicros: typeof row?.usdMicros === "number" ? row.usdMicros : 0,
|
|
86
|
+
share: spendShare(row?.usdMicros, total),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One timeline entry → the single line the gutter renders. Pure, and
|
|
92
|
+
* deliberately total: an unknown routing kind still renders its kind rather
|
|
93
|
+
* than vanishing, because a manager one version behind its harness must not
|
|
94
|
+
* silently drop events.
|
|
95
|
+
*/
|
|
96
|
+
export function timelineLine(entry) {
|
|
97
|
+
const kind = String(entry?.kind ?? "");
|
|
98
|
+
const p =
|
|
99
|
+
entry && typeof entry.payload === "object" && entry.payload !== null ? entry.payload : {};
|
|
100
|
+
const parts = [];
|
|
101
|
+
const push = (value) => {
|
|
102
|
+
if (value !== undefined && value !== null && String(value) !== "") parts.push(String(value));
|
|
103
|
+
};
|
|
104
|
+
switch (kind) {
|
|
105
|
+
case "model_route":
|
|
106
|
+
push(p.model);
|
|
107
|
+
if (p.profile) push(`profile=${p.profile}`);
|
|
108
|
+
if (p.band) push(`band=${p.band}`);
|
|
109
|
+
if (p.policy) push(`policy=${p.policy}${p.explored === true ? " (exploring)" : ""}`);
|
|
110
|
+
if (p.ruleId) push(`rule=${p.ruleId}`);
|
|
111
|
+
break;
|
|
112
|
+
case "model_tier_route":
|
|
113
|
+
push(p.model);
|
|
114
|
+
if (p.tier) push(`tier=${p.tier}${p.escalated === true ? " (escalated)" : ""}`);
|
|
115
|
+
break;
|
|
116
|
+
case "model_stage":
|
|
117
|
+
push(`${p.stage ?? "?"} · ${p.outcome ?? "?"}`);
|
|
118
|
+
if (p.strategy) push(`strategy=${p.strategy}`);
|
|
119
|
+
if (p.model) push(p.model);
|
|
120
|
+
if (p.profile) push(`profile=${p.profile}`);
|
|
121
|
+
if (p.cause) push(p.cause);
|
|
122
|
+
break;
|
|
123
|
+
case "model_directive":
|
|
124
|
+
push(`${p.requested ?? "?"} → ${p.resolved ?? "(refused)"}`);
|
|
125
|
+
push(p.accepted === true ? "accepted" : "refused");
|
|
126
|
+
if (p.reason) push(p.reason);
|
|
127
|
+
break;
|
|
128
|
+
case "model_failover":
|
|
129
|
+
push(`${p.from ?? "?"} → ${p.to ?? "?"}`);
|
|
130
|
+
if (p.reason) push(p.reason);
|
|
131
|
+
break;
|
|
132
|
+
case "judge_verdict":
|
|
133
|
+
push(p.verdict);
|
|
134
|
+
if (typeof p.score === "number") push(`score=${p.score}`);
|
|
135
|
+
if (p.judgeModel) push(`judge=${p.judgeModel}`);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
kind,
|
|
142
|
+
turn: typeof entry?.turnNumber === "number" ? entry.turnNumber : null,
|
|
143
|
+
detail: parts.join(" · "),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shareBar(share) {
|
|
148
|
+
const pct = Math.max(0, Math.min(1, share)) * 100;
|
|
149
|
+
return el("div", { class: "meter" }, [
|
|
150
|
+
el("div", { class: "meter-fill", style: `width:${pct.toFixed(1)}%` }),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function table(headers, rows) {
|
|
155
|
+
return el("div", { class: "table-scroll" }, [
|
|
156
|
+
el("table", { class: "fleet" }, [
|
|
157
|
+
el(
|
|
158
|
+
"thead",
|
|
159
|
+
null,
|
|
160
|
+
el(
|
|
161
|
+
"tr",
|
|
162
|
+
null,
|
|
163
|
+
headers.map((h) => el("th", { text: h })),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
el("tbody", null, rows),
|
|
167
|
+
]),
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function card(title, sub, body) {
|
|
172
|
+
return el("div", { class: "card" }, [
|
|
173
|
+
el("h3", { class: "card-title" }, [
|
|
174
|
+
el("span", { text: title }),
|
|
175
|
+
sub === null ? null : el("span", { class: "muted card-sub", text: sub }),
|
|
176
|
+
]),
|
|
177
|
+
...(Array.isArray(body) ? body : [body]),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The whole tab. `ctx` is `{ id, … }`, the same shape every tab receives. */
|
|
182
|
+
export async function renderModels(root, ctx) {
|
|
183
|
+
clear(root).appendChild(skeleton(8));
|
|
184
|
+
const data = await api.models({ id: ctx.id });
|
|
185
|
+
clear(root);
|
|
186
|
+
const body = data && typeof data.body === "object" && data.body !== null ? data.body : data;
|
|
187
|
+
if (!body || typeof body !== "object") {
|
|
188
|
+
root.appendChild(emptyState("No model data", "crewhaus models list"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const registry = Array.isArray(body.registry) ? body.registry : [];
|
|
193
|
+
const pools = declaredPools(body);
|
|
194
|
+
const arms = Array.isArray(body.arms) ? body.arms : [];
|
|
195
|
+
const leaderboard = Array.isArray(body.leaderboard) ? body.leaderboard : [];
|
|
196
|
+
const sessions = Array.isArray(body.sessions) ? body.sessions : [];
|
|
197
|
+
const profiles = profileSpendRows(body);
|
|
198
|
+
const roles = roleSpendRows(body);
|
|
199
|
+
|
|
200
|
+
if (typeof body.note === "string" && body.note !== "") {
|
|
201
|
+
root.appendChild(el("p", { class: "muted", text: body.note }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- 1. the registry + the pool ----------------------------------------
|
|
205
|
+
root.appendChild(
|
|
206
|
+
card(
|
|
207
|
+
"Registry",
|
|
208
|
+
registry.length === 0 ? "no models: block declared" : `${registry.length} profile(s)`,
|
|
209
|
+
registry.length === 0
|
|
210
|
+
? el("p", {
|
|
211
|
+
class: "muted",
|
|
212
|
+
text: "declare models: to give a profile its own settings, tools and permissions once, then reference it as $name at any model slot.",
|
|
213
|
+
})
|
|
214
|
+
: table(
|
|
215
|
+
["Profile", "Model", "Settings"],
|
|
216
|
+
registry.map((row) =>
|
|
217
|
+
el("tr", null, [
|
|
218
|
+
el("td", { class: "mono", text: String(row?.name ?? "?") }),
|
|
219
|
+
el("td", { class: "mono", text: String(row?.model ?? "—") }),
|
|
220
|
+
el("td", {
|
|
221
|
+
class: "muted",
|
|
222
|
+
text: (Array.isArray(row?.settings) ? row.settings : [])
|
|
223
|
+
.filter((s) => s?.key !== "model")
|
|
224
|
+
.map((s) => `${s.key}=${s.value}`)
|
|
225
|
+
.join(" · "),
|
|
226
|
+
}),
|
|
227
|
+
]),
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (pools.length === 0) {
|
|
234
|
+
root.appendChild(
|
|
235
|
+
card(
|
|
236
|
+
"Routing pool",
|
|
237
|
+
"not declared",
|
|
238
|
+
el("p", {
|
|
239
|
+
class: "muted",
|
|
240
|
+
text: "no model_pool anywhere in the spec — every turn is served by the one declared model.",
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
for (const { hostPath, pool: p } of pools) {
|
|
246
|
+
root.appendChild(
|
|
247
|
+
card(
|
|
248
|
+
`Routing pool · ${hostPath}`,
|
|
249
|
+
`policy ${p.policy ?? "static"}${p.scope ? ` · scope ${p.scope}` : ""}`,
|
|
250
|
+
table(
|
|
251
|
+
["Candidate", "Profile", "Tags", "Enabled"],
|
|
252
|
+
(Array.isArray(p.candidates) ? p.candidates : []).map((c) =>
|
|
253
|
+
el("tr", null, [
|
|
254
|
+
el("td", { class: "mono", text: String(c?.model ?? "—") }),
|
|
255
|
+
el("td", { class: "mono", text: String(c?.profile ?? "—") }),
|
|
256
|
+
el("td", {
|
|
257
|
+
class: "muted",
|
|
258
|
+
text: (Array.isArray(c?.tags) ? c.tags : []).join(", "),
|
|
259
|
+
}),
|
|
260
|
+
el("td", { text: c?.enabled === false ? "no" : "yes" }),
|
|
261
|
+
]),
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- 2. per-role and per-profile spend ---------------------------------
|
|
270
|
+
const spendBody = [];
|
|
271
|
+
if (roles.length > 0) {
|
|
272
|
+
spendBody.push(
|
|
273
|
+
table(
|
|
274
|
+
["Role", "Calls", "Cost", "Share"],
|
|
275
|
+
roles.map((r) =>
|
|
276
|
+
el("tr", null, [
|
|
277
|
+
el("td", { text: r.role }),
|
|
278
|
+
el("td", { class: "num", text: fmtCount(r.calls) }),
|
|
279
|
+
el("td", { class: "num", text: fmtUsd(usdFromMicros(r.usdMicros)) }),
|
|
280
|
+
el("td", null, shareBar(r.share)),
|
|
281
|
+
]),
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (profiles.length > 0) {
|
|
287
|
+
spendBody.push(
|
|
288
|
+
table(
|
|
289
|
+
["Profile", "Calls", "Cost", "Share"],
|
|
290
|
+
profiles.map((p) =>
|
|
291
|
+
el("tr", null, [
|
|
292
|
+
el("td", { class: "mono", text: p.profile }),
|
|
293
|
+
el("td", { class: "num", text: fmtCount(p.calls) }),
|
|
294
|
+
el("td", { class: "num", text: fmtUsd(usdFromMicros(p.usdMicros)) }),
|
|
295
|
+
el("td", null, shareBar(p.share)),
|
|
296
|
+
]),
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (spendBody.length === 0) {
|
|
302
|
+
spendBody.push(
|
|
303
|
+
el("p", { class: "muted", text: "no priced model calls recorded for this harness yet." }),
|
|
304
|
+
);
|
|
305
|
+
} else if (typeof body.spend?.rollups === "number" && body.spend.rollups > 0) {
|
|
306
|
+
spendBody.push(
|
|
307
|
+
el("p", {
|
|
308
|
+
class: "muted",
|
|
309
|
+
text: `${body.spend.rollups} nested-run roll-up line(s) were skipped — a sub-agent's spend is counted from the child's own session log, which is folded here too.`,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
root.appendChild(
|
|
314
|
+
card("Spend by role and profile", fmtUsd(usdFromMicros(body.spend?.totalUsdMicros)), spendBody),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ---- 3. the leaderboard -------------------------------------------------
|
|
318
|
+
root.appendChild(
|
|
319
|
+
card(
|
|
320
|
+
"Leaderboard",
|
|
321
|
+
arms.length === 0 ? "no arms recorded" : `${arms.length} arm(s)`,
|
|
322
|
+
leaderboard.length === 0
|
|
323
|
+
? el("p", {
|
|
324
|
+
class: "muted",
|
|
325
|
+
text: "a pooled run folds one observation per turn into its band's arm; nothing has been observed yet.",
|
|
326
|
+
})
|
|
327
|
+
: table(
|
|
328
|
+
["Band", "Model", "n", "Reward", "Quality", "Latency", "Cost/call"],
|
|
329
|
+
leaderboard.map((row) =>
|
|
330
|
+
el("tr", { class: row?.best === true ? "row-best" : null }, [
|
|
331
|
+
el("td", {
|
|
332
|
+
class: "mono",
|
|
333
|
+
text: `${row?.band ?? "?"}${row?.shadow === true ? " (shadow)" : ""}`,
|
|
334
|
+
}),
|
|
335
|
+
el("td", {
|
|
336
|
+
class: "mono",
|
|
337
|
+
text: `${row?.model ?? "?"}${row?.best === true ? " ★" : ""}`,
|
|
338
|
+
}),
|
|
339
|
+
el("td", { class: "num", text: fmtCount(row?.n) }),
|
|
340
|
+
el("td", { class: "num", text: Number(row?.meanReward ?? 0).toFixed(3) }),
|
|
341
|
+
el("td", {
|
|
342
|
+
class: "num",
|
|
343
|
+
text: row?.qualityCount > 0 ? Number(row.meanQuality).toFixed(3) : "—",
|
|
344
|
+
}),
|
|
345
|
+
el("td", {
|
|
346
|
+
class: "num",
|
|
347
|
+
text: `${Math.round(Number(row?.meanLatencyMs ?? 0))}ms`,
|
|
348
|
+
}),
|
|
349
|
+
el("td", {
|
|
350
|
+
class: "num",
|
|
351
|
+
text: row?.meanCostUsd > 0 ? `$${Number(row.meanCostUsd).toFixed(5)}` : "—",
|
|
352
|
+
}),
|
|
353
|
+
]),
|
|
354
|
+
),
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// ---- 4. the route timeline ---------------------------------------------
|
|
360
|
+
const timelineRoot = el("div");
|
|
361
|
+
root.appendChild(
|
|
362
|
+
card(
|
|
363
|
+
"Route timeline",
|
|
364
|
+
sessions.length === 0
|
|
365
|
+
? "no session recorded a routing decision"
|
|
366
|
+
: `${sessions.length} session(s)`,
|
|
367
|
+
sessions.length === 0
|
|
368
|
+
? el("p", {
|
|
369
|
+
class: "muted",
|
|
370
|
+
text: "only runs whose spec declares a pool, tiers or a hybrid strategy persist routing lines.",
|
|
371
|
+
})
|
|
372
|
+
: [
|
|
373
|
+
el(
|
|
374
|
+
"div",
|
|
375
|
+
{ class: "chips" },
|
|
376
|
+
sessions.slice(0, 12).map((s) => {
|
|
377
|
+
const btn = el("button", {
|
|
378
|
+
class: "chip",
|
|
379
|
+
text: `${s.id} (${s.routingLines})`,
|
|
380
|
+
});
|
|
381
|
+
btn.addEventListener("click", () => {
|
|
382
|
+
void loadTimeline(timelineRoot, ctx.id, String(s.id));
|
|
383
|
+
});
|
|
384
|
+
return btn;
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
timelineRoot,
|
|
388
|
+
],
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
const first = sessions[0];
|
|
392
|
+
if (first !== undefined) await loadTimeline(timelineRoot, ctx.id, String(first.id));
|
|
393
|
+
|
|
394
|
+
if (body.freeze) {
|
|
395
|
+
root.appendChild(
|
|
396
|
+
collapsible(
|
|
397
|
+
[el("span", { text: "Routing is FROZEN — the learned policy is pinned" })],
|
|
398
|
+
[jsonPre(body.freeze)],
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Load and render one session's timeline into `host`. */
|
|
405
|
+
async function loadTimeline(host, id, sessionId) {
|
|
406
|
+
clear(host).appendChild(skeleton(3));
|
|
407
|
+
const data = await api.modelRoutes({ id, sess: sessionId });
|
|
408
|
+
const body = data && typeof data.body === "object" && data.body !== null ? data.body : data;
|
|
409
|
+
clear(host);
|
|
410
|
+
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
|
411
|
+
if (entries.length === 0) {
|
|
412
|
+
host.appendChild(
|
|
413
|
+
el("p", { class: "muted", text: String(body?.note ?? "no routing lines in this session.") }),
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
host.appendChild(
|
|
418
|
+
table(
|
|
419
|
+
["Turn", "Kind", "Detail"],
|
|
420
|
+
entries.map((entry) => {
|
|
421
|
+
const line = timelineLine(entry);
|
|
422
|
+
return el("tr", null, [
|
|
423
|
+
el("td", { class: "num", text: line.turn === null ? "—" : String(line.turn) }),
|
|
424
|
+
el("td", { class: "mono", text: line.kind }),
|
|
425
|
+
el("td", { class: "muted", text: line.detail }),
|
|
426
|
+
]);
|
|
427
|
+
}),
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|