@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
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M5 — the Advisor: every alert, suggestion and optimization signal about a
|
|
3
|
+
* harness on ONE screen, each item explained (hover for the why), each with
|
|
4
|
+
* its quick action (a queued CLI verb or a deep link), and each decision
|
|
5
|
+
* recorded — acting takes an optional comment, dismissing REQUIRES a reason,
|
|
6
|
+
* and a dismissal can always be reopened.
|
|
7
|
+
*
|
|
8
|
+
* Two surfaces:
|
|
9
|
+
* - the per-harness tab (`#/h/<id>/advisor`): the feed, the improvement
|
|
10
|
+
* trend, the reports panel, and the issue inbox that turns a complaint
|
|
11
|
+
* into an update ready to run;
|
|
12
|
+
* - the fleet board (`#/advisor`): every harness's rollup, worst first.
|
|
13
|
+
*
|
|
14
|
+
* The pure decisions (row model, severity dot, action face, trend bars) are
|
|
15
|
+
* exported for unit tests; the render functions are thin DOM builders over
|
|
16
|
+
* them, in the M2 tradition.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { api } from "../api.js";
|
|
20
|
+
import {
|
|
21
|
+
clear,
|
|
22
|
+
collapsible,
|
|
23
|
+
dot,
|
|
24
|
+
el,
|
|
25
|
+
emptyState,
|
|
26
|
+
jsonPre,
|
|
27
|
+
skeleton,
|
|
28
|
+
tipIcon,
|
|
29
|
+
toast,
|
|
30
|
+
withTip,
|
|
31
|
+
} from "../dom.js";
|
|
32
|
+
import { hrefGlobal, hrefHarness } from "../router.js";
|
|
33
|
+
|
|
34
|
+
/** severity → the traffic-light dot state (always paired with text). */
|
|
35
|
+
export function severityDot(severity) {
|
|
36
|
+
if (severity === "critical") return "bad";
|
|
37
|
+
if (severity === "warn") return "warn";
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One item → what its action face renders. Pure.
|
|
43
|
+
* { kind: "job", label, cliTwin } an executable button
|
|
44
|
+
* { kind: "link", label, href, cliTwin } a deep link
|
|
45
|
+
* { kind: "none", label } guidance only — still says so
|
|
46
|
+
*/
|
|
47
|
+
export function itemActionModel(item, id) {
|
|
48
|
+
const action =
|
|
49
|
+
item && typeof item.action === "object" && item.action !== null ? item.action : null;
|
|
50
|
+
if (action === null) return { kind: "none", label: "no quick action — see the guidance" };
|
|
51
|
+
if (action.kind === "job" && typeof action.jobKind === "string") {
|
|
52
|
+
return {
|
|
53
|
+
kind: "job",
|
|
54
|
+
label: String(action.label ?? "Run"),
|
|
55
|
+
cliTwin: typeof action.cliTwin === "string" ? action.cliTwin : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const screen = String(item.screen ?? "overview");
|
|
59
|
+
// Fleet screens (the approvals inbox) are global; everything else is a tab
|
|
60
|
+
// on this harness.
|
|
61
|
+
const href = screen === "approvals" ? hrefGlobal("approvals") : hrefHarness(id, screen);
|
|
62
|
+
return {
|
|
63
|
+
kind: "link",
|
|
64
|
+
label: String(action.label ?? `Open ${screen}`),
|
|
65
|
+
href,
|
|
66
|
+
cliTwin: typeof action.cliTwin === "string" ? action.cliTwin : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fleet payload → board rows, worst first (ties by name, stable). Pure. */
|
|
71
|
+
export function rankAdvisorRows(payload) {
|
|
72
|
+
const body = payload && typeof payload === "object" ? payload : {};
|
|
73
|
+
const rows = Array.isArray(body.harnesses) ? body.harnesses : [];
|
|
74
|
+
return rows
|
|
75
|
+
.map((row) => ({
|
|
76
|
+
id: String(row?.id ?? ""),
|
|
77
|
+
specName: String(row?.specName ?? row?.id ?? ""),
|
|
78
|
+
open: typeof row?.open === "number" ? row.open : 0,
|
|
79
|
+
critical: typeof row?.critical === "number" ? row.critical : 0,
|
|
80
|
+
warn: typeof row?.warn === "number" ? row.warn : 0,
|
|
81
|
+
suggestion: typeof row?.suggestion === "number" ? row.suggestion : 0,
|
|
82
|
+
optimal: row?.optimal === true,
|
|
83
|
+
topItem: row?.topItem && typeof row.topItem === "object" ? row.topItem : null,
|
|
84
|
+
}))
|
|
85
|
+
.sort(
|
|
86
|
+
(a, b) => b.critical - a.critical || b.open - a.open || a.specName.localeCompare(b.specName),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Eval-series points → bar heights in [0.05, 1]. Pure (unit-tested). */
|
|
91
|
+
export function trendBars(series) {
|
|
92
|
+
const points = Array.isArray(series) ? series : [];
|
|
93
|
+
return points
|
|
94
|
+
.map((p) => (typeof p?.passRate === "number" ? p.passRate : null))
|
|
95
|
+
.filter((r) => r !== null)
|
|
96
|
+
.map((rate) => Math.max(0.05, Math.min(1, rate)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function tryWrite(label, fn, onOk) {
|
|
100
|
+
let result;
|
|
101
|
+
try {
|
|
102
|
+
result = await fn();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
toast(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (result && result.ok === false) {
|
|
108
|
+
toast(`${label} refused: ${String(result.body?.error ?? `HTTP ${result.status}`)}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
onOk(result);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// The per-harness tab
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export async function renderAdvisor(root, ctx) {
|
|
119
|
+
clear(root).appendChild(skeleton(6));
|
|
120
|
+
const [feedRes, trendRes, reportsRes, issuesRes] = await Promise.allSettled([
|
|
121
|
+
api.advisor({ id: ctx.id }),
|
|
122
|
+
api.advisorTrend({ id: ctx.id }),
|
|
123
|
+
api.advisorReports({ id: ctx.id }),
|
|
124
|
+
api.advisorIssues({ id: ctx.id }),
|
|
125
|
+
]);
|
|
126
|
+
clear(root);
|
|
127
|
+
const reload = () => renderAdvisor(root, ctx);
|
|
128
|
+
const feed = feedRes.status === "fulfilled" && feedRes.value.ok ? feedRes.value.body : null;
|
|
129
|
+
root.appendChild(feedCard(feed, ctx, reload));
|
|
130
|
+
const trend = trendRes.status === "fulfilled" && trendRes.value.ok ? trendRes.value.body : null;
|
|
131
|
+
root.appendChild(trendCard(trend));
|
|
132
|
+
const reports =
|
|
133
|
+
reportsRes.status === "fulfilled" && reportsRes.value.ok ? reportsRes.value.body : null;
|
|
134
|
+
root.appendChild(reportsCard(reports, ctx, reload));
|
|
135
|
+
const issues =
|
|
136
|
+
issuesRes.status === "fulfilled" && issuesRes.value.ok ? issuesRes.value.body : null;
|
|
137
|
+
root.appendChild(issuesCard(issues, ctx, reload));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function feedCard(feed, ctx, reload) {
|
|
141
|
+
const card = el("section", { class: "card" });
|
|
142
|
+
card.appendChild(
|
|
143
|
+
el("h3", { class: "card-title" }, [
|
|
144
|
+
el("span", { text: "Needs attention" }),
|
|
145
|
+
tipIcon(
|
|
146
|
+
"Every alert and suggestion the manager can derive about this harness, in one feed: preflight, spec lint, eval health vs the pinned baseline, spend vs budget, incidents, approvals, overdue dreams, mined advice. Hover any item for why it matters.",
|
|
147
|
+
),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
if (feed === null) {
|
|
151
|
+
card.appendChild(emptyState("The advisor feed could not be read", "crewhaus doctor"));
|
|
152
|
+
return card;
|
|
153
|
+
}
|
|
154
|
+
const items = Array.isArray(feed.items) ? feed.items : [];
|
|
155
|
+
const dismissed = Array.isArray(feed.dismissed) ? feed.dismissed : [];
|
|
156
|
+
if (feed.optimal === true) {
|
|
157
|
+
card.appendChild(
|
|
158
|
+
el("div", { class: "adv-optimal" }, [
|
|
159
|
+
dot("ok", "running optimally"),
|
|
160
|
+
el("p", {
|
|
161
|
+
class: "adv-note",
|
|
162
|
+
text: String(feed.note ?? "every signal this manager reads is clean"),
|
|
163
|
+
}),
|
|
164
|
+
]),
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
card.appendChild(
|
|
168
|
+
el("p", { class: "adv-note" }, [el("span", { text: String(feed.guidance ?? "") })]),
|
|
169
|
+
);
|
|
170
|
+
for (const item of items) card.appendChild(itemRow(item, ctx, reload));
|
|
171
|
+
}
|
|
172
|
+
if (dismissed.length > 0) {
|
|
173
|
+
card.appendChild(
|
|
174
|
+
collapsible(
|
|
175
|
+
[dot("off", `${dismissed.length} dismissed — each with its recorded reason`)],
|
|
176
|
+
dismissed.map((item) => dismissedRow(item, ctx, reload)),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return card;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function itemRow(item, ctx, reload) {
|
|
184
|
+
const severity = String(item.severity ?? "suggestion");
|
|
185
|
+
const model = itemActionModel(item, ctx.id);
|
|
186
|
+
const row = el("div", { class: `adv-item adv-${severity}` });
|
|
187
|
+
row.appendChild(
|
|
188
|
+
withTip(el("div", null, [dot(severityDot(severity), severity)]), String(item.explain ?? "")),
|
|
189
|
+
);
|
|
190
|
+
const main = el("div", { class: "adv-main" }, [
|
|
191
|
+
withTip(
|
|
192
|
+
el("div", { class: "adv-title", text: String(item.title ?? "") }),
|
|
193
|
+
String(item.explain ?? ""),
|
|
194
|
+
),
|
|
195
|
+
el("div", { class: "adv-detail muted", text: String(item.detail ?? "") }),
|
|
196
|
+
el("div", { class: "adv-guidance" }, [
|
|
197
|
+
el("span", { text: `→ ${String(item.guidance ?? "")}` }),
|
|
198
|
+
]),
|
|
199
|
+
]);
|
|
200
|
+
if (item.lastAction && typeof item.lastAction === "object") {
|
|
201
|
+
const la = item.lastAction;
|
|
202
|
+
main.appendChild(
|
|
203
|
+
el("div", {
|
|
204
|
+
class: "adv-decision",
|
|
205
|
+
text: `acted ${String(la.at ?? "")}${la.comment ? ` — “${String(la.comment)}”` : ""}${la.jobId ? ` (job ${String(la.jobId)})` : ""}`,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
row.appendChild(main);
|
|
210
|
+
|
|
211
|
+
const actions = el("div", { class: "adv-actions" });
|
|
212
|
+
if (model.kind === "job") {
|
|
213
|
+
const runBtn = el("button", { class: "btn btn-primary", type: "button", text: model.label });
|
|
214
|
+
runBtn.addEventListener("click", () => openActForm(row, item, ctx, reload, model));
|
|
215
|
+
actions.appendChild(
|
|
216
|
+
withTip(
|
|
217
|
+
runBtn,
|
|
218
|
+
"Opens a confirm where you can attach a comment; the comment is recorded in the advisor ledger next to the queued job.",
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
} else if (model.kind === "link") {
|
|
222
|
+
actions.appendChild(el("a", { class: "btn", href: model.href, text: model.label }));
|
|
223
|
+
} else {
|
|
224
|
+
actions.appendChild(el("span", { class: "muted", text: model.label }));
|
|
225
|
+
}
|
|
226
|
+
if (model.cliTwin)
|
|
227
|
+
actions.appendChild(
|
|
228
|
+
el("span", { class: "adv-cli", title: model.cliTwin, text: model.cliTwin }),
|
|
229
|
+
);
|
|
230
|
+
const dismissBtn = el("button", { class: "btn btn-ghost", type: "button", text: "Dismiss…" });
|
|
231
|
+
dismissBtn.addEventListener("click", () => openDismissForm(row, item, ctx, reload));
|
|
232
|
+
actions.appendChild(
|
|
233
|
+
withTip(
|
|
234
|
+
dismissBtn,
|
|
235
|
+
"Dismissing requires a reason — it is recorded in the ledger so “why was this ignored” always has an answer. A dismissal can be reopened.",
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
row.appendChild(actions);
|
|
239
|
+
return row;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The act confirm: the exact CLI twin, plus the optional comment that is
|
|
243
|
+
* injected into the recorded decision. Only one form open per row. */
|
|
244
|
+
function openActForm(row, item, ctx, reload, model) {
|
|
245
|
+
closeForms(row);
|
|
246
|
+
const comment = el("input", {
|
|
247
|
+
class: "input",
|
|
248
|
+
type: "text",
|
|
249
|
+
placeholder: "optional comment, recorded with the action",
|
|
250
|
+
"aria-label": "comment recorded with this action",
|
|
251
|
+
});
|
|
252
|
+
const form = el("form", { class: "adv-form" }, [
|
|
253
|
+
model.cliTwin ? el("code", { class: "mono", text: model.cliTwin }) : null,
|
|
254
|
+
comment,
|
|
255
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Queue it" }),
|
|
256
|
+
el("button", {
|
|
257
|
+
class: "btn btn-ghost",
|
|
258
|
+
type: "button",
|
|
259
|
+
text: "Cancel",
|
|
260
|
+
onClick: () => form.remove(),
|
|
261
|
+
}),
|
|
262
|
+
]);
|
|
263
|
+
form.addEventListener("submit", (e) => {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
const body = comment.value.trim() === "" ? {} : { comment: comment.value.trim() };
|
|
266
|
+
tryWrite(
|
|
267
|
+
"Act",
|
|
268
|
+
() => api.advisorAct({ id: ctx.id, itemId: String(item.id) }, body),
|
|
269
|
+
(result) => {
|
|
270
|
+
toast(
|
|
271
|
+
result?.body?.acted === true
|
|
272
|
+
? "Queued — watch it in the job queue"
|
|
273
|
+
: "That item has cleared; reloading",
|
|
274
|
+
"info",
|
|
275
|
+
);
|
|
276
|
+
reload();
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
row.appendChild(form);
|
|
281
|
+
comment.focus();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The dismiss form: the reason is REQUIRED — the server refuses without it. */
|
|
285
|
+
function openDismissForm(row, item, ctx, reload) {
|
|
286
|
+
closeForms(row);
|
|
287
|
+
const reason = el("input", {
|
|
288
|
+
class: "input",
|
|
289
|
+
type: "text",
|
|
290
|
+
placeholder: "why this is fine to leave (required, recorded)",
|
|
291
|
+
"aria-label": "dismissal reason",
|
|
292
|
+
required: "",
|
|
293
|
+
});
|
|
294
|
+
const form = el("form", { class: "adv-form" }, [
|
|
295
|
+
reason,
|
|
296
|
+
el("button", { class: "btn", type: "submit", text: "Dismiss with reason" }),
|
|
297
|
+
el("button", {
|
|
298
|
+
class: "btn btn-ghost",
|
|
299
|
+
type: "button",
|
|
300
|
+
text: "Cancel",
|
|
301
|
+
onClick: () => form.remove(),
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
form.addEventListener("submit", (e) => {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
if (reason.value.trim() === "") return;
|
|
307
|
+
tryWrite(
|
|
308
|
+
"Dismiss",
|
|
309
|
+
() =>
|
|
310
|
+
api.advisorDismiss(
|
|
311
|
+
{ id: ctx.id, itemId: String(item.id) },
|
|
312
|
+
{ reason: reason.value.trim() },
|
|
313
|
+
),
|
|
314
|
+
() => reload(),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
row.appendChild(form);
|
|
318
|
+
reason.focus();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function closeForms(row) {
|
|
322
|
+
for (const old of row.querySelectorAll(".adv-form")) old.remove();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function dismissedRow(item, ctx, reload) {
|
|
326
|
+
const dismissal = item.dismissal && typeof item.dismissal === "object" ? item.dismissal : {};
|
|
327
|
+
const reopenBtn = el("button", { class: "btn btn-ghost", type: "button", text: "Reopen" });
|
|
328
|
+
reopenBtn.addEventListener("click", () => {
|
|
329
|
+
tryWrite(
|
|
330
|
+
"Reopen",
|
|
331
|
+
() => api.advisorReopen({ id: ctx.id, itemId: String(item.id) }, {}),
|
|
332
|
+
() => reload(),
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
return el("div", { class: "adv-item adv-suggestion" }, [
|
|
336
|
+
el("div", null, [dot("off", "dismissed")]),
|
|
337
|
+
el("div", { class: "adv-main" }, [
|
|
338
|
+
el("div", { class: "adv-title", text: String(item.title ?? "") }),
|
|
339
|
+
el("div", {
|
|
340
|
+
class: "adv-decision",
|
|
341
|
+
text: `by ${String(dismissal.by ?? "?")} ${String(dismissal.at ?? "")} — “${String(dismissal.reason ?? "")}”`,
|
|
342
|
+
}),
|
|
343
|
+
]),
|
|
344
|
+
el("div", { class: "adv-actions" }, [reopenBtn]),
|
|
345
|
+
]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Trend, reports, issues
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
function trendCard(trend) {
|
|
353
|
+
const card = el("section", { class: "card" });
|
|
354
|
+
card.appendChild(
|
|
355
|
+
el("h3", { class: "card-title" }, [
|
|
356
|
+
el("span", { text: "Is it improving?" }),
|
|
357
|
+
tipIcon(
|
|
358
|
+
"Folded from durable sources only — the eval run index, the session cost ledger, and the advisor's own decision records. Nothing here is a stored snapshot.",
|
|
359
|
+
),
|
|
360
|
+
]),
|
|
361
|
+
);
|
|
362
|
+
if (trend === null) {
|
|
363
|
+
card.appendChild(emptyState("The trend could not be read", "crewhaus eval crewhaus.yaml"));
|
|
364
|
+
return card;
|
|
365
|
+
}
|
|
366
|
+
card.appendChild(el("p", { class: "adv-note", text: String(trend.summary ?? "") }));
|
|
367
|
+
const bars = trendBars(trend.evalSeries);
|
|
368
|
+
if (bars.length > 0) {
|
|
369
|
+
card.appendChild(
|
|
370
|
+
withTip(
|
|
371
|
+
el(
|
|
372
|
+
"div",
|
|
373
|
+
{
|
|
374
|
+
class: "trend-bars",
|
|
375
|
+
role: "img",
|
|
376
|
+
"aria-label": "eval pass rate per run, oldest first",
|
|
377
|
+
},
|
|
378
|
+
bars.map((h) =>
|
|
379
|
+
el("div", { class: "trend-bar", style: { height: `${Math.round(h * 100)}%` } }),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
"Eval pass rate per recorded run, oldest first — the improvement curve the optimizer climbs.",
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const decisions = trend.decisions && typeof trend.decisions === "object" ? trend.decisions : {};
|
|
387
|
+
card.appendChild(
|
|
388
|
+
el("p", {
|
|
389
|
+
class: "muted small",
|
|
390
|
+
text: `decisions recorded: ${Number(decisions.acted ?? 0)} acted · ${Number(decisions.dismissed ?? 0)} dismissed`,
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
return card;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const REPORT_TIPS = {
|
|
397
|
+
"model-usage":
|
|
398
|
+
"Per-model spend, calls and tokens, with the model to right-size first named in the finding.",
|
|
399
|
+
costs: "The daily spend series and how much of the declared budget ceiling is used.",
|
|
400
|
+
usefulness:
|
|
401
|
+
"Sessions on record, eval runs, the latest pass rate, and waiting advice — is this harness earning its keep?",
|
|
402
|
+
optimization:
|
|
403
|
+
"The eval series vs recorded optimize runs — whether the active-optimization loop is actually being closed.",
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
function reportsCard(reports, ctx, reload) {
|
|
407
|
+
const card = el("section", { class: "card" });
|
|
408
|
+
card.appendChild(
|
|
409
|
+
el("h3", { class: "card-title" }, [
|
|
410
|
+
el("span", { text: "Reports" }),
|
|
411
|
+
tipIcon(
|
|
412
|
+
"Generate a report about anything measured here; each one is saved on the harness and can be re-read and compared later.",
|
|
413
|
+
),
|
|
414
|
+
]),
|
|
415
|
+
);
|
|
416
|
+
const kinds = Array.isArray(reports?.kinds) ? reports.kinds : Object.keys(REPORT_TIPS);
|
|
417
|
+
const bar = el("div", { class: "toolbar" });
|
|
418
|
+
for (const kind of kinds) {
|
|
419
|
+
const btn = el("button", { class: "btn", type: "button", text: `Generate ${kind}` });
|
|
420
|
+
btn.addEventListener("click", () => {
|
|
421
|
+
btn.disabled = true;
|
|
422
|
+
tryWrite(
|
|
423
|
+
"Generate report",
|
|
424
|
+
() => api.advisorReportRun({ id: ctx.id }, { kind }),
|
|
425
|
+
() => {
|
|
426
|
+
toast(`Generated ${kind}`, "info");
|
|
427
|
+
reload();
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
bar.appendChild(withTip(btn, REPORT_TIPS[kind] ?? `Generate the ${kind} report.`));
|
|
432
|
+
}
|
|
433
|
+
card.appendChild(bar);
|
|
434
|
+
const rows = Array.isArray(reports?.reports) ? reports.reports : [];
|
|
435
|
+
if (rows.length === 0) {
|
|
436
|
+
card.appendChild(emptyState(String(reports?.note ?? "No reports generated yet"), null));
|
|
437
|
+
return card;
|
|
438
|
+
}
|
|
439
|
+
const list = el("div", null);
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
const openBtn = el("button", { class: "btn btn-ghost", type: "button", text: "View" });
|
|
442
|
+
const holder = el("div");
|
|
443
|
+
openBtn.addEventListener("click", async () => {
|
|
444
|
+
openBtn.disabled = true;
|
|
445
|
+
const answer = await api.advisorReport({ id: ctx.id, reportId: String(row.reportId) });
|
|
446
|
+
openBtn.disabled = false;
|
|
447
|
+
clear(holder);
|
|
448
|
+
if (answer.ok && answer.body?.report) holder.appendChild(jsonPre(answer.body.report));
|
|
449
|
+
else toast("Report could not be read");
|
|
450
|
+
});
|
|
451
|
+
list.appendChild(
|
|
452
|
+
el("div", { class: "find-row" }, [
|
|
453
|
+
el("span", { class: "chip", text: String(row.kind ?? "?") }),
|
|
454
|
+
el("span", { class: "mono", text: String(row.reportId ?? "") }),
|
|
455
|
+
el("span", { class: "muted small", text: String(row.generatedAt ?? "") }),
|
|
456
|
+
openBtn,
|
|
457
|
+
]),
|
|
458
|
+
);
|
|
459
|
+
list.appendChild(holder);
|
|
460
|
+
}
|
|
461
|
+
card.appendChild(list);
|
|
462
|
+
return card;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const ISSUE_KIND_TIPS = {
|
|
466
|
+
optimize:
|
|
467
|
+
"Queues the eval→patch optimize loop: it searches the spec's tunable paths against your evals and lands a reviewable spec patch — an update ready to run, never an automatic write.",
|
|
468
|
+
eval: "Queues an eval run, so the issue gets a measured before/after.",
|
|
469
|
+
doctor: "Queues the doctor — for issues that smell like credentials, wiring or environment.",
|
|
470
|
+
compile: "Queues a recompile — for issues that smell like a stale bundle.",
|
|
471
|
+
advise: "Queues the advice miner over recorded sessions — it proposes concrete spec patches.",
|
|
472
|
+
note: "Records the issue for a human plan; nothing is queued.",
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
function issuesCard(issues, ctx, reload) {
|
|
476
|
+
const card = el("section", { class: "card" });
|
|
477
|
+
card.appendChild(
|
|
478
|
+
el("h3", { class: "card-title" }, [
|
|
479
|
+
el("span", { text: "Report an issue" }),
|
|
480
|
+
tipIcon(
|
|
481
|
+
"Describe a problem and it is tuned into an update ready to run: the kind picks what gets queued (optimize is the default — its artifact is a reviewable spec patch). The issue and its update land in a harness-local ledger.",
|
|
482
|
+
),
|
|
483
|
+
]),
|
|
484
|
+
);
|
|
485
|
+
const title = el("input", {
|
|
486
|
+
class: "input grow",
|
|
487
|
+
type: "text",
|
|
488
|
+
placeholder: "what's wrong? (one line)",
|
|
489
|
+
"aria-label": "issue title",
|
|
490
|
+
});
|
|
491
|
+
const detail = el("textarea", {
|
|
492
|
+
class: "input notes",
|
|
493
|
+
rows: "2",
|
|
494
|
+
placeholder: "details (optional)",
|
|
495
|
+
"aria-label": "issue detail",
|
|
496
|
+
});
|
|
497
|
+
const kinds = Array.isArray(issues?.kinds) ? issues.kinds : Object.keys(ISSUE_KIND_TIPS);
|
|
498
|
+
const kindSel = el("select", { class: "input", "aria-label": "update kind" });
|
|
499
|
+
for (const kind of kinds) {
|
|
500
|
+
kindSel.appendChild(el("option", { value: String(kind), text: String(kind) }));
|
|
501
|
+
}
|
|
502
|
+
// The kind's tooltip follows the selection: `withTip` seeds the class and
|
|
503
|
+
// the initial text; the change listener rewrites BOTH faces of the tip
|
|
504
|
+
// (the hover bubble reads data-tip, assistive tech reads title).
|
|
505
|
+
const retip = () => {
|
|
506
|
+
const tipText = ISSUE_KIND_TIPS[kindSel.value] ?? "";
|
|
507
|
+
kindSel.dataset.tip = tipText;
|
|
508
|
+
kindSel.setAttribute("title", tipText);
|
|
509
|
+
};
|
|
510
|
+
kindSel.addEventListener("change", retip);
|
|
511
|
+
withTip(kindSel, ISSUE_KIND_TIPS[String(kinds[0] ?? "optimize")] ?? "");
|
|
512
|
+
// Field order = tab order: title, details, then the kind and the submit.
|
|
513
|
+
const form = el("form", { class: "adv-form" }, [
|
|
514
|
+
title,
|
|
515
|
+
detail,
|
|
516
|
+
kindSel,
|
|
517
|
+
el("button", {
|
|
518
|
+
class: "btn btn-primary",
|
|
519
|
+
type: "submit",
|
|
520
|
+
text: "Submit → update ready to run",
|
|
521
|
+
}),
|
|
522
|
+
]);
|
|
523
|
+
form.addEventListener("submit", (e) => {
|
|
524
|
+
e.preventDefault();
|
|
525
|
+
if (title.value.trim() === "") return;
|
|
526
|
+
tryWrite(
|
|
527
|
+
"Submit issue",
|
|
528
|
+
() =>
|
|
529
|
+
api.advisorIssueSubmit(
|
|
530
|
+
{ id: ctx.id },
|
|
531
|
+
{
|
|
532
|
+
title: title.value.trim(),
|
|
533
|
+
...(detail.value.trim() !== "" ? { detail: detail.value.trim() } : {}),
|
|
534
|
+
kind: kindSel.value,
|
|
535
|
+
},
|
|
536
|
+
),
|
|
537
|
+
(result) => {
|
|
538
|
+
toast(String(result?.body?.issue?.update?.note ?? "Submitted"), "info");
|
|
539
|
+
reload();
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
card.appendChild(form);
|
|
544
|
+
const rows = Array.isArray(issues?.issues) ? issues.issues : [];
|
|
545
|
+
if (rows.length === 0) {
|
|
546
|
+
card.appendChild(emptyState(String(issues?.note ?? "No issues submitted yet"), null));
|
|
547
|
+
return card;
|
|
548
|
+
}
|
|
549
|
+
for (const row of rows) {
|
|
550
|
+
const update = row.update && typeof row.update === "object" ? row.update : {};
|
|
551
|
+
card.appendChild(
|
|
552
|
+
el("div", { class: "find-row" }, [
|
|
553
|
+
update.ready === true
|
|
554
|
+
? el("span", { class: "chip chip-group", text: "update queued" })
|
|
555
|
+
: el("span", { class: "chip", text: "recorded" }),
|
|
556
|
+
el("span", { text: String(row.title ?? "") }),
|
|
557
|
+
el("span", { class: "muted small", text: String(row.at ?? "") }),
|
|
558
|
+
update.cliTwin
|
|
559
|
+
? el("span", {
|
|
560
|
+
class: "adv-cli",
|
|
561
|
+
title: String(update.cliTwin),
|
|
562
|
+
text: String(update.cliTwin),
|
|
563
|
+
})
|
|
564
|
+
: null,
|
|
565
|
+
]),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
return card;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// The fleet board
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
export async function renderAdvisorBoard(root) {
|
|
576
|
+
clear(root).appendChild(skeleton(6));
|
|
577
|
+
let payload = null;
|
|
578
|
+
try {
|
|
579
|
+
const answer = await api.advisorFleet({});
|
|
580
|
+
payload = answer.ok ? answer.body : null;
|
|
581
|
+
} catch (err) {
|
|
582
|
+
clear(root).appendChild(
|
|
583
|
+
el("div", { class: "card error-card" }, [
|
|
584
|
+
el("h2", { text: "Fleet advisor unavailable" }),
|
|
585
|
+
el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
586
|
+
]),
|
|
587
|
+
);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
clear(root);
|
|
591
|
+
root.appendChild(
|
|
592
|
+
el("div", { class: "rollup" }, [
|
|
593
|
+
el("h2", { text: "Fleet advisor" }),
|
|
594
|
+
el("span", {
|
|
595
|
+
class: "muted",
|
|
596
|
+
text: "worst first — open a harness's Advisor tab for the feed and its actions",
|
|
597
|
+
}),
|
|
598
|
+
]),
|
|
599
|
+
);
|
|
600
|
+
if (payload === null) {
|
|
601
|
+
root.appendChild(emptyState("The fleet advisor could not be read", "crewhaus doctor"));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const rows = rankAdvisorRows(payload);
|
|
605
|
+
if (rows.length === 0) {
|
|
606
|
+
root.appendChild(emptyState("No live harness to advise", "crewhaus harness add <dir>"));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const card = el("section", { class: "card" });
|
|
610
|
+
for (const row of rows) {
|
|
611
|
+
card.appendChild(
|
|
612
|
+
el("div", { class: "adv-board-row" }, [
|
|
613
|
+
el("a", { class: "name-link", href: hrefHarness(row.id, "advisor"), text: row.specName }),
|
|
614
|
+
row.optimal
|
|
615
|
+
? dot("ok", "optimal")
|
|
616
|
+
: dot(row.critical > 0 ? "bad" : row.warn > 0 ? "warn" : "unknown", `${row.open} open`),
|
|
617
|
+
el("span", {
|
|
618
|
+
class: "muted small",
|
|
619
|
+
text: `${row.critical} critical · ${row.warn} warn · ${row.suggestion} suggested`,
|
|
620
|
+
}),
|
|
621
|
+
row.topItem
|
|
622
|
+
? withTip(
|
|
623
|
+
el("span", { class: "muted", text: String(row.topItem.title ?? "") }),
|
|
624
|
+
"The worst open item — the feed on the harness's Advisor tab has the full explanation and the quick action.",
|
|
625
|
+
)
|
|
626
|
+
: el("span", { class: "muted", text: "—" }),
|
|
627
|
+
]),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
root.appendChild(card);
|
|
631
|
+
}
|