@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.
- package/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec — the M3 half of the Spec tab: the field editor, trust-tier badges,
|
|
3
|
+
* version history with pins and diffs, and the four builders (new-spec
|
|
4
|
+
* wizard, grader builder, dataset builder, MCP connectors).
|
|
5
|
+
*
|
|
6
|
+
* The field editor is honest about its two halves. A SCALAR leaf edits
|
|
7
|
+
* inline. Everything else — a block, a list, a field inside a list item, or
|
|
8
|
+
* a block this spec does not declare at all — edits through the JSON pane,
|
|
9
|
+
* which sends ONE edit (`path` + `value`) down the same `specDiff` →
|
|
10
|
+
* `specEdit` road every other write here takes, so the server's trust tiers
|
|
11
|
+
* and typed confirmation still decide what may land. There is no YAML
|
|
12
|
+
* editor in this console and this screen never says there is: the console is
|
|
13
|
+
* zero-build and dependency-free, so it has no YAML parser to be honest
|
|
14
|
+
* with.
|
|
15
|
+
*
|
|
16
|
+
* The M1 read (`views/spec.js`: YAML, issues, env-ref presence, badges)
|
|
17
|
+
* stays where it is and is rendered above this.
|
|
18
|
+
*
|
|
19
|
+
* The one thing this screen must never let a user do accidentally: write a
|
|
20
|
+
* HUMAN-OWNED path (permissions, the model roster, watchme, plugins, expose,
|
|
21
|
+
* learning.sources, thredz, sandbox, transaction_policy) as if it were a
|
|
22
|
+
* quality knob. Those fields render with the security-surface interstitial —
|
|
23
|
+
* a credential-redacted diff plus a typed confirmation — or hand off to
|
|
24
|
+
* `crewhaus propose`. The server enforces the same split, so the UI is the
|
|
25
|
+
* explanation, never the gate.
|
|
26
|
+
*
|
|
27
|
+
* Two more things this screen is careful about:
|
|
28
|
+
*
|
|
29
|
+
* - it renders the EFFECTIVE configuration, not the YAML. A spec that
|
|
30
|
+
* never mentions a default-ON block still runs it, so every row says
|
|
31
|
+
* whether it was declared or defaulted;
|
|
32
|
+
* - it says which schema validated the spec. A fleet spans crewhaus
|
|
33
|
+
* versions, and "validated against the manager's copy" is a materially
|
|
34
|
+
* different claim from "validated against this harness's own".
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { api } from "../api.js";
|
|
38
|
+
import {
|
|
39
|
+
clear,
|
|
40
|
+
collapsible,
|
|
41
|
+
copyBtn,
|
|
42
|
+
dot,
|
|
43
|
+
el,
|
|
44
|
+
emptyState,
|
|
45
|
+
jsonPre,
|
|
46
|
+
skeleton,
|
|
47
|
+
toast,
|
|
48
|
+
} from "../dom.js";
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// small shared pieces
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** A titled card. `badge` is a node (usually a traffic light + its text). */
|
|
55
|
+
function card(title, badge, children) {
|
|
56
|
+
return el("div", { class: "card" }, [
|
|
57
|
+
el("h3", { class: "card-title" }, [el("span", { text: title }), badge]),
|
|
58
|
+
...(Array.isArray(children) ? children : [children]),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A payload's `note` line, when it has one. */
|
|
63
|
+
function noteLine(body) {
|
|
64
|
+
const note = typeof body?.note === "string" && body.note !== "" ? body.note : null;
|
|
65
|
+
return note === null ? null : el("p", { class: "muted", text: note });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A table with a scroll container (wide content never scrolls the page). */
|
|
69
|
+
function table(headers, rows) {
|
|
70
|
+
return el("div", { class: "table-scroll" }, [
|
|
71
|
+
el("table", { class: "fleet" }, [
|
|
72
|
+
el(
|
|
73
|
+
"thead",
|
|
74
|
+
null,
|
|
75
|
+
el(
|
|
76
|
+
"tr",
|
|
77
|
+
null,
|
|
78
|
+
headers.map((h) => el("th", { text: h })),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
el("tbody", null, rows),
|
|
82
|
+
]),
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Monospace block for a rendered diff or a YAML body. */
|
|
87
|
+
function pre(text) {
|
|
88
|
+
return el("pre", { class: "rawjson", text: text === "" ? "(no change)" : text });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The tier badge — colour is never alone, the tier word rides with it. */
|
|
92
|
+
function tierBadge(row) {
|
|
93
|
+
if (row.tier === "auto-tunable") return dot("ok", "auto-tunable");
|
|
94
|
+
return dot(row.securitySurface === true ? "bad" : "warn", "human-owned");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Report the outcome of a write.
|
|
99
|
+
*
|
|
100
|
+
* Three distinct answers live behind one call, and collapsing them would
|
|
101
|
+
* throw away the interesting one: a transport failure, a typed refusal the
|
|
102
|
+
* server answered 200 with (`ok:false` — "that version is not in the
|
|
103
|
+
* registry"), and a 409 gate ("type the spec name"). Each gets its own
|
|
104
|
+
* sentence.
|
|
105
|
+
*/
|
|
106
|
+
function reportWrite(res, okMessage) {
|
|
107
|
+
if (res === null || res === undefined) {
|
|
108
|
+
toast("No answer from the manager");
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const message =
|
|
113
|
+
typeof res.body?.error === "string" ? res.body.error : `HTTP ${res.status ?? "?"}`;
|
|
114
|
+
toast(message);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (res.body?.ok === false) {
|
|
118
|
+
toast(`${res.body.code ?? "refused"}: ${res.body.reason ?? "refused"}`, "info");
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
toast(okMessage, "info");
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The typed-confirmation dialog: the operator types the spec's name and the
|
|
127
|
+
* SERVER verifies it. The button stays disabled until the text matches, so
|
|
128
|
+
* the dialog cannot be dismissed by muscle memory.
|
|
129
|
+
*/
|
|
130
|
+
function confirmModal({ title, lead, detail, confirmName, action, onConfirm }) {
|
|
131
|
+
const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
|
|
132
|
+
const close = () => backdrop.remove();
|
|
133
|
+
const input = el("input", {
|
|
134
|
+
class: "input",
|
|
135
|
+
type: "text",
|
|
136
|
+
placeholder: confirmName,
|
|
137
|
+
"aria-label": `type ${confirmName} to confirm`,
|
|
138
|
+
});
|
|
139
|
+
const go = el("button", {
|
|
140
|
+
class: "btn btn-danger",
|
|
141
|
+
type: "button",
|
|
142
|
+
text: action,
|
|
143
|
+
disabled: true,
|
|
144
|
+
});
|
|
145
|
+
input.addEventListener("input", () => {
|
|
146
|
+
go.disabled = input.value.trim() !== confirmName;
|
|
147
|
+
});
|
|
148
|
+
go.addEventListener("click", () => {
|
|
149
|
+
close();
|
|
150
|
+
onConfirm();
|
|
151
|
+
});
|
|
152
|
+
backdrop.appendChild(
|
|
153
|
+
el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
|
|
154
|
+
el("h3", { class: "card-title" }, [el("span", { text: title }), dot("warn", "human-owned")]),
|
|
155
|
+
el("p", { class: "muted", text: lead }),
|
|
156
|
+
el("div", { class: "modal-body" }, detail),
|
|
157
|
+
el("label", { class: "field" }, [
|
|
158
|
+
el("span", { text: `Type ${confirmName} to confirm` }),
|
|
159
|
+
input,
|
|
160
|
+
]),
|
|
161
|
+
el("div", { class: "modal-actions" }, [
|
|
162
|
+
go,
|
|
163
|
+
el("button", { class: "btn btn-ghost", type: "button", text: "Cancel", onClick: close }),
|
|
164
|
+
]),
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
backdrop.addEventListener("click", (e) => {
|
|
168
|
+
if (e.target === backdrop) close();
|
|
169
|
+
});
|
|
170
|
+
document.body.appendChild(backdrop);
|
|
171
|
+
return backdrop;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A plain (untyped) dialog for previews — a version's YAML, a diff. */
|
|
175
|
+
function viewModal(title, children) {
|
|
176
|
+
const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
|
|
177
|
+
const close = () => backdrop.remove();
|
|
178
|
+
backdrop.appendChild(
|
|
179
|
+
el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
|
|
180
|
+
el("h3", { class: "card-title" }, [el("span", { text: title })]),
|
|
181
|
+
el("div", { class: "modal-body" }, children),
|
|
182
|
+
el("div", { class: "modal-actions" }, [
|
|
183
|
+
el("button", { class: "btn btn-ghost", type: "button", text: "Close", onClick: close }),
|
|
184
|
+
]),
|
|
185
|
+
]),
|
|
186
|
+
);
|
|
187
|
+
backdrop.addEventListener("click", (e) => {
|
|
188
|
+
if (e.target === backdrop) close();
|
|
189
|
+
});
|
|
190
|
+
document.body.appendChild(backdrop);
|
|
191
|
+
return backdrop;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A 501 stub, rendered as the fact it is rather than as a broken panel. */
|
|
195
|
+
function stubCard(title, res) {
|
|
196
|
+
return card(title, dot("off", "not built yet (M3)"), [
|
|
197
|
+
el("p", {
|
|
198
|
+
class: "muted",
|
|
199
|
+
text:
|
|
200
|
+
typeof res?.body?.error === "string"
|
|
201
|
+
? res.body.error
|
|
202
|
+
: "the server has no handler for this yet",
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const isStub = (res) => res !== null && res !== undefined && res.status === 501;
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// effective configuration + diagnostics
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function schemaBadge(body) {
|
|
214
|
+
if (body.schemaSource === "harness") {
|
|
215
|
+
return dot("ok", `validated against this harness's schema${suffix(body.schemaVersion)}`);
|
|
216
|
+
}
|
|
217
|
+
return dot("warn", "validated against the manager's schema");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const suffix = (version) => (typeof version === "string" && version !== "" ? ` (${version})` : "");
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The effective-config table: what this harness is ACTUALLY configured to
|
|
224
|
+
* do. A `default` row is a value nobody wrote down — which is exactly the
|
|
225
|
+
* question raw YAML cannot answer.
|
|
226
|
+
*/
|
|
227
|
+
function effectiveCard(body) {
|
|
228
|
+
const rows = Array.isArray(body.effective) ? body.effective : [];
|
|
229
|
+
const declared = Number(body.declaredCount ?? 0);
|
|
230
|
+
const defaulted = Number(body.defaultedCount ?? 0);
|
|
231
|
+
const inner = [];
|
|
232
|
+
inner.push(
|
|
233
|
+
el("p", { class: "muted" }, [
|
|
234
|
+
el("span", { text: `${declared} declared · ${defaulted} from the schema's defaults` }),
|
|
235
|
+
]),
|
|
236
|
+
);
|
|
237
|
+
if (typeof body.effectiveNote === "string" && body.effectiveNote !== "") {
|
|
238
|
+
inner.push(
|
|
239
|
+
el("p", { class: "reason" }, [dot("warn", "defaults unavailable"), body.effectiveNote]),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (rows.length === 0) {
|
|
243
|
+
inner.push(emptyState("Nothing to show yet — the spec has to validate first", "crewhaus init"));
|
|
244
|
+
} else {
|
|
245
|
+
const body_ = rows.map((row) =>
|
|
246
|
+
el("tr", null, [
|
|
247
|
+
el("td", { class: "mono", text: String(row.path ?? "") }),
|
|
248
|
+
el("td", { class: "mono", text: renderValue(row.value) }),
|
|
249
|
+
el("td", null, [
|
|
250
|
+
row.source === "declared"
|
|
251
|
+
? dot("ok", "declared")
|
|
252
|
+
: dot("unknown", "default (not in the file)"),
|
|
253
|
+
]),
|
|
254
|
+
]),
|
|
255
|
+
);
|
|
256
|
+
inner.push(table(["Path", "Value", "Source"], body_));
|
|
257
|
+
}
|
|
258
|
+
const issues = Array.isArray(body.issues) ? body.issues : [];
|
|
259
|
+
if (issues.length > 0) {
|
|
260
|
+
inner.push(
|
|
261
|
+
el("h4", { class: "sub-title", text: "Diagnostics" }),
|
|
262
|
+
el(
|
|
263
|
+
"ul",
|
|
264
|
+
{ class: "issue-list" },
|
|
265
|
+
issues.map((issue) =>
|
|
266
|
+
el("li", null, [
|
|
267
|
+
dot("bad", String(issue.message ?? "issue")),
|
|
268
|
+
el("code", { text: Array.isArray(issue.path) ? issue.path.join(".") : "(root)" }),
|
|
269
|
+
]),
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
inner.push(
|
|
275
|
+
el("p", { class: "muted" }, [
|
|
276
|
+
el("span", { text: "Compile warnings (accepted-but-unwired keys) come from " }),
|
|
277
|
+
el("code", { text: String(body.warningsVerb ?? "crewhaus compile") }),
|
|
278
|
+
el("span", { text: " — this view does not run the compiler." }),
|
|
279
|
+
]),
|
|
280
|
+
);
|
|
281
|
+
return card("Effective configuration", schemaBadge(body), inner);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderValue(value) {
|
|
285
|
+
if (value === null) return "null";
|
|
286
|
+
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 79)}…` : value;
|
|
287
|
+
try {
|
|
288
|
+
const rendered = JSON.stringify(value);
|
|
289
|
+
return rendered.length > 80 ? `${rendered.slice(0, 79)}…` : rendered;
|
|
290
|
+
} catch {
|
|
291
|
+
return String(value);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// the field editor (trust tiers)
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One row per declared path, badged by tier, with the edit control the tier
|
|
301
|
+
* allows:
|
|
302
|
+
*
|
|
303
|
+
* auto-tunable → an inline input that saves through `specPatch` (the same
|
|
304
|
+
* restriction the optimizer runs under, enforced server-side);
|
|
305
|
+
* human-owned → "Review & apply", which fetches the redacted diff FIRST
|
|
306
|
+
* and only then offers the typed confirmation, plus "Propose" for the
|
|
307
|
+
* review-PR path;
|
|
308
|
+
* a block or a list → the JSON pane, which takes the same diff → tier →
|
|
309
|
+
* confirm road with the whole value as one edit.
|
|
310
|
+
*/
|
|
311
|
+
function trustCard(ctx, trust, valueByPath, reload) {
|
|
312
|
+
const rows = Array.isArray(trust.paths) ? trust.paths : [];
|
|
313
|
+
// Rows are what the spec DECLARES; the pane is how anything else is
|
|
314
|
+
// reached, so it belongs on the empty screen too — a spec with no rows is
|
|
315
|
+
// exactly the one an operator needs to add a block to.
|
|
316
|
+
const addBlock = el("button", { class: "btn", type: "button", text: "Edit any path…" });
|
|
317
|
+
addBlock.addEventListener("click", () => {
|
|
318
|
+
valueDialog(ctx, { path: "", value: undefined, pathEditable: true, reload });
|
|
319
|
+
});
|
|
320
|
+
const addForm = (lead) =>
|
|
321
|
+
el("div", { class: "add-form" }, [el("p", { class: "muted grow", text: lead }), addBlock]);
|
|
322
|
+
if (rows.length === 0) {
|
|
323
|
+
return card("Fields & trust tiers", dot("off", "no fields"), [
|
|
324
|
+
emptyState(
|
|
325
|
+
typeof trust.note === "string" ? trust.note : "This spec declares no editable fields",
|
|
326
|
+
typeof trust.verb === "string" ? trust.verb : "crewhaus init",
|
|
327
|
+
),
|
|
328
|
+
addForm(
|
|
329
|
+
"A path this spec has not declared is still reachable — the server applies it with the same diff, tier and typed confirmation.",
|
|
330
|
+
),
|
|
331
|
+
]);
|
|
332
|
+
}
|
|
333
|
+
const confirmName = String(trust.confirmName ?? "");
|
|
334
|
+
const body = rows.map((row) => {
|
|
335
|
+
const path = String(row.path ?? "");
|
|
336
|
+
const current = valueByPath.get(path);
|
|
337
|
+
const editable = current !== undefined && !isContainer(current);
|
|
338
|
+
return el("tr", null, [
|
|
339
|
+
el("td", { class: "mono", text: path }),
|
|
340
|
+
el("td", null, [tierBadge(row)]),
|
|
341
|
+
el("td", { class: "muted", text: String(row.reason ?? "") }),
|
|
342
|
+
el("td", { class: "cell-actions" }, [
|
|
343
|
+
editable
|
|
344
|
+
? row.tier === "auto-tunable"
|
|
345
|
+
? autoTunableEditor(ctx, path, current, reload)
|
|
346
|
+
: humanOwnedActions(ctx, path, current, confirmName, reload)
|
|
347
|
+
: jsonPaneButton(ctx, path, current, reload),
|
|
348
|
+
]),
|
|
349
|
+
]);
|
|
350
|
+
});
|
|
351
|
+
return card(
|
|
352
|
+
"Fields & trust tiers",
|
|
353
|
+
dot(
|
|
354
|
+
"ok",
|
|
355
|
+
`${trust.autoTunableCount ?? 0} auto-tunable · ${trust.humanOwnedCount ?? 0} human-owned`,
|
|
356
|
+
),
|
|
357
|
+
[
|
|
358
|
+
el("p", {
|
|
359
|
+
class: "muted",
|
|
360
|
+
text: "Auto-tunable fields are the ones the optimizer may write, so the console may write them too. Everything else needs a review of the diff and a typed confirmation — the server enforces the same split.",
|
|
361
|
+
}),
|
|
362
|
+
table(["Path", "Tier", "Why", ""], body),
|
|
363
|
+
addForm(
|
|
364
|
+
"Rows list what this spec DECLARES. A block it has never declared, or a field inside a list item, is reachable by path — same diff, same tier, same typed confirmation.",
|
|
365
|
+
),
|
|
366
|
+
],
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const isContainer = (value) => typeof value === "object" && value !== null;
|
|
371
|
+
|
|
372
|
+
/** Inline input + Save for a path the optimizer is allowed to write. */
|
|
373
|
+
function autoTunableEditor(ctx, path, current, reload) {
|
|
374
|
+
const input = el("input", {
|
|
375
|
+
class: "input",
|
|
376
|
+
type: "text",
|
|
377
|
+
value: typeof current === "string" ? current : JSON.stringify(current),
|
|
378
|
+
"aria-label": `value for ${path}`,
|
|
379
|
+
});
|
|
380
|
+
const save = el("button", { class: "btn btn-primary", type: "button", text: "Save" });
|
|
381
|
+
save.addEventListener("click", async () => {
|
|
382
|
+
save.disabled = true;
|
|
383
|
+
const res = await api.specPatch(
|
|
384
|
+
{ id: ctx.id },
|
|
385
|
+
{ edit: { path, value: coerce(input.value, current) } },
|
|
386
|
+
);
|
|
387
|
+
save.disabled = false;
|
|
388
|
+
if (reportWrite(res, `Saved ${path}`)) reload();
|
|
389
|
+
});
|
|
390
|
+
return el("div", { class: "row-editor" }, [input, save]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Keep a number a number and a boolean a boolean — a form field is text,
|
|
394
|
+
* and the spec schema is not. */
|
|
395
|
+
function coerce(text, current) {
|
|
396
|
+
if (typeof current === "number") {
|
|
397
|
+
const n = Number(text);
|
|
398
|
+
return Number.isNaN(n) ? text : n;
|
|
399
|
+
}
|
|
400
|
+
if (typeof current === "boolean") return text === "true";
|
|
401
|
+
return text;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// the JSON pane — blocks, lists, and paths this spec has not declared
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A dotted path as the segment array the server's edit parser takes.
|
|
410
|
+
*
|
|
411
|
+
* A segment that is a non-negative integer becomes a NUMBER, because that is
|
|
412
|
+
* the difference between `candidates.0.id` addressing the first list item
|
|
413
|
+
* and addressing a mapping key called "0". Pure, so the rule is testable.
|
|
414
|
+
*/
|
|
415
|
+
export function editPath(dotted) {
|
|
416
|
+
return String(dotted ?? "")
|
|
417
|
+
.split(".")
|
|
418
|
+
.map((segment) => segment.trim())
|
|
419
|
+
.filter((segment) => segment !== "")
|
|
420
|
+
.map((segment) => (/^(?:0|[1-9][0-9]*)$/.test(segment) ? Number(segment) : segment));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Parse the pane's text into the value to write. Empty text is DELETE (the
|
|
425
|
+
* server treats an edit with no `value` as one), which is how a block is
|
|
426
|
+
* removed; anything else must be JSON — a subset of the YAML the spec is
|
|
427
|
+
* written in, and the only structured format this dependency-free console
|
|
428
|
+
* can parse without guessing.
|
|
429
|
+
*/
|
|
430
|
+
export function parseJsonValue(text) {
|
|
431
|
+
const raw = String(text ?? "").trim();
|
|
432
|
+
if (raw === "") return { ok: true, remove: true };
|
|
433
|
+
try {
|
|
434
|
+
return { ok: true, remove: false, value: JSON.parse(raw) };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The one-edit batch a path + pane produce — or a refusal carrying the
|
|
441
|
+
* sentence to render, because "nothing happened" is not an answer. */
|
|
442
|
+
export function editsFor(path, text) {
|
|
443
|
+
const segments = editPath(path);
|
|
444
|
+
if (segments.length === 0) return { ok: false, message: "a path is required" };
|
|
445
|
+
const parsed = parseJsonValue(text);
|
|
446
|
+
if (!parsed.ok) return { ok: false, message: `that is not valid JSON: ${parsed.message}` };
|
|
447
|
+
// No `value` key at all ⇒ the server deletes the path. `value: undefined`
|
|
448
|
+
// would not survive JSON.stringify, so the key is omitted deliberately.
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
edits: [parsed.remove ? { path: segments } : { path: segments, value: parsed.value }],
|
|
452
|
+
removing: parsed.remove === true,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The row control for a path the inline editor cannot type: a container, or
|
|
457
|
+
* a declared path with no effective value to seed an input from. */
|
|
458
|
+
function jsonPaneButton(ctx, path, current, reload) {
|
|
459
|
+
const open = el("button", { class: "btn btn-ghost", type: "button", text: "Edit as JSON…" });
|
|
460
|
+
open.addEventListener("click", () => {
|
|
461
|
+
valueDialog(ctx, { path, value: current, pathEditable: false, reload });
|
|
462
|
+
});
|
|
463
|
+
return open;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The pane. It writes NOTHING on its own: Preview asks `specDiff` what would
|
|
468
|
+
* change and which tier the batch lands in, and only the answer decides what
|
|
469
|
+
* the operator is offered — a plain Apply for an auto-tunable path (the
|
|
470
|
+
* optimizer's own surface), or the typed confirmation for a human-owned one.
|
|
471
|
+
* Both roads end at the same `specEdit` PUT, so the server remains the gate
|
|
472
|
+
* and this screen stays the explanation.
|
|
473
|
+
*/
|
|
474
|
+
function valueDialog(ctx, { path, value, pathEditable, reload }) {
|
|
475
|
+
let backdrop = null;
|
|
476
|
+
const pathInput = el("input", {
|
|
477
|
+
class: "input grow",
|
|
478
|
+
type: "text",
|
|
479
|
+
value: path,
|
|
480
|
+
placeholder: "agent.model_pool.candidates.0.id",
|
|
481
|
+
"aria-label": "spec path — dotted, and a number is a list index",
|
|
482
|
+
});
|
|
483
|
+
const editor = el("textarea", {
|
|
484
|
+
class: "input notes",
|
|
485
|
+
rows: 12,
|
|
486
|
+
spellcheck: "false",
|
|
487
|
+
"aria-label": "value as JSON",
|
|
488
|
+
});
|
|
489
|
+
editor.value = value === undefined ? "" : JSON.stringify(value, null, 2);
|
|
490
|
+
const out = el("div", { class: "modal-body" });
|
|
491
|
+
|
|
492
|
+
const preview = el("button", { class: "btn btn-primary", type: "button", text: "Preview diff" });
|
|
493
|
+
preview.addEventListener("click", async () => {
|
|
494
|
+
const target = pathInput.value.trim();
|
|
495
|
+
const batch = editsFor(target, editor.value);
|
|
496
|
+
clear(out);
|
|
497
|
+
if (!batch.ok) {
|
|
498
|
+
out.appendChild(el("p", { class: "reason", text: batch.message }));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const res = await api.specDiff({ id: ctx.id }, { edits: batch.edits });
|
|
502
|
+
clear(out);
|
|
503
|
+
if (!res.ok) {
|
|
504
|
+
out.appendChild(el("p", { class: "reason", text: res.body?.error ?? `HTTP ${res.status}` }));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const body = res.body ?? {};
|
|
508
|
+
if (body.ok === false) {
|
|
509
|
+
out.appendChild(
|
|
510
|
+
el("p", {
|
|
511
|
+
class: "reason",
|
|
512
|
+
text: `${body.code ?? "refused"}: ${body.reason ?? "this edit does not apply"}`,
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (batch.removing) {
|
|
518
|
+
out.appendChild(
|
|
519
|
+
el("p", { class: "muted", text: `an empty pane DELETES ${target} from the spec` }),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
out.appendChild(pre(String(body.diff ?? "")));
|
|
523
|
+
const apply = async (extra) => {
|
|
524
|
+
const wrote = await api.specEdit({ id: ctx.id }, { edits: batch.edits, ...extra });
|
|
525
|
+
if (reportWrite(wrote, `Applied ${target}`)) {
|
|
526
|
+
if (backdrop !== null) backdrop.remove();
|
|
527
|
+
reload();
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
if (body.autoApplicable === true) {
|
|
531
|
+
const go = el("button", { class: "btn btn-primary", type: "button", text: "Apply" });
|
|
532
|
+
go.addEventListener("click", () => {
|
|
533
|
+
void apply({});
|
|
534
|
+
});
|
|
535
|
+
out.appendChild(
|
|
536
|
+
el("p", {
|
|
537
|
+
class: "muted",
|
|
538
|
+
text: "every path in this edit is auto-tunable — the surface the optimizer already writes.",
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
out.appendChild(el("div", { class: "modal-actions" }, [go]));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const confirmName = String(body.confirmName ?? "");
|
|
545
|
+
const typed = el("input", {
|
|
546
|
+
class: "input",
|
|
547
|
+
type: "text",
|
|
548
|
+
placeholder: confirmName,
|
|
549
|
+
"aria-label": `type ${confirmName} to confirm`,
|
|
550
|
+
});
|
|
551
|
+
const go = el("button", {
|
|
552
|
+
class: "btn btn-danger",
|
|
553
|
+
type: "button",
|
|
554
|
+
text: "Apply to the live spec",
|
|
555
|
+
disabled: true,
|
|
556
|
+
});
|
|
557
|
+
typed.addEventListener("input", () => {
|
|
558
|
+
go.disabled = typed.value.trim() !== confirmName;
|
|
559
|
+
});
|
|
560
|
+
go.addEventListener("click", () => {
|
|
561
|
+
void apply({ confirmName });
|
|
562
|
+
});
|
|
563
|
+
// The same batch, sent for review instead — the road the scalar rows
|
|
564
|
+
// offer, and the reason this pane can name it without lying.
|
|
565
|
+
const propose = el("button", { class: "btn btn-ghost", type: "button", text: "Propose" });
|
|
566
|
+
propose.addEventListener("click", async () => {
|
|
567
|
+
const staged = await api.specPropose(
|
|
568
|
+
{ id: ctx.id },
|
|
569
|
+
{ edits: batch.edits, title: `hangar: ${target}` },
|
|
570
|
+
);
|
|
571
|
+
if (
|
|
572
|
+
reportWrite(
|
|
573
|
+
staged,
|
|
574
|
+
"Proposal staged — the review bundle is a dry run until you open the PR",
|
|
575
|
+
)
|
|
576
|
+
) {
|
|
577
|
+
if (backdrop !== null) backdrop.remove();
|
|
578
|
+
reload();
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
out.appendChild(
|
|
582
|
+
el("p", {
|
|
583
|
+
class: "muted",
|
|
584
|
+
text: "This batch touches a human-owned path, so the server asks for the spec's name in writing — or send the same edit for review instead.",
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
out.appendChild(
|
|
588
|
+
el("label", { class: "field" }, [
|
|
589
|
+
el("span", { text: `Type ${confirmName} to confirm` }),
|
|
590
|
+
typed,
|
|
591
|
+
]),
|
|
592
|
+
);
|
|
593
|
+
out.appendChild(el("div", { class: "modal-actions" }, [go, propose]));
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
backdrop = viewModal(pathEditable ? "Edit a spec path" : `Edit ${path}`, [
|
|
597
|
+
pathEditable
|
|
598
|
+
? el("label", { class: "field grow" }, [
|
|
599
|
+
el("span", { text: "Path (dotted; a number is a list index)" }),
|
|
600
|
+
pathInput,
|
|
601
|
+
])
|
|
602
|
+
: el("p", { class: "muted mono", text: path }),
|
|
603
|
+
el("p", {
|
|
604
|
+
class: "muted",
|
|
605
|
+
text: "The value is JSON. An empty pane deletes the path. Nothing is written until the diff below has been read.",
|
|
606
|
+
}),
|
|
607
|
+
editor,
|
|
608
|
+
el("div", { class: "modal-actions" }, [preview]),
|
|
609
|
+
out,
|
|
610
|
+
]);
|
|
611
|
+
return backdrop;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** "Review & apply" (diff → typed confirm) and "Propose" for a human-owned
|
|
615
|
+
* path. Neither writes anything before the operator has seen the diff. */
|
|
616
|
+
function humanOwnedActions(ctx, path, current, confirmName, reload) {
|
|
617
|
+
const input = el("input", {
|
|
618
|
+
class: "input",
|
|
619
|
+
type: "text",
|
|
620
|
+
value: typeof current === "string" ? current : JSON.stringify(current),
|
|
621
|
+
"aria-label": `new value for ${path}`,
|
|
622
|
+
});
|
|
623
|
+
const review = el("button", { class: "btn", type: "button", text: "Review & apply" });
|
|
624
|
+
const propose = el("button", { class: "btn btn-ghost", type: "button", text: "Propose" });
|
|
625
|
+
const editFor = () => ({ path, value: coerce(input.value, current) });
|
|
626
|
+
|
|
627
|
+
review.addEventListener("click", async () => {
|
|
628
|
+
const edits = [editFor()];
|
|
629
|
+
const preview = await api.specDiff({ id: ctx.id }, { edits });
|
|
630
|
+
if (!preview.ok) {
|
|
631
|
+
toast(preview.body?.error ?? `HTTP ${preview.status}`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const body = preview.body ?? {};
|
|
635
|
+
if (body.ok === false) {
|
|
636
|
+
toast(`${body.code ?? "refused"}: ${body.reason ?? "this edit does not apply"}`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
confirmModal({
|
|
640
|
+
title: `Change ${path}`,
|
|
641
|
+
lead: "This is a human-owned field. Read the diff — values are credential-redacted server-side — then type the spec name to apply it here, or close this and open a review instead.",
|
|
642
|
+
detail: [pre(String(body.diff ?? ""))],
|
|
643
|
+
confirmName,
|
|
644
|
+
action: "Apply to the live spec",
|
|
645
|
+
onConfirm: async () => {
|
|
646
|
+
const res = await api.specEdit({ id: ctx.id }, { edits, confirmName });
|
|
647
|
+
if (reportWrite(res, `Applied ${path}`)) reload();
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
propose.addEventListener("click", async () => {
|
|
653
|
+
const res = await api.specPropose(
|
|
654
|
+
{ id: ctx.id },
|
|
655
|
+
{ edits: [editFor()], title: `hangar: ${path}` },
|
|
656
|
+
);
|
|
657
|
+
if (
|
|
658
|
+
reportWrite(res, "Proposal staged — the review bundle is a dry run until you open the PR")
|
|
659
|
+
) {
|
|
660
|
+
reload();
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
return el("div", { class: "row-editor" }, [input, review, propose]);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// version history
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
function versionsCard(ctx, body, reload) {
|
|
671
|
+
const versions = Array.isArray(body.versions) ? body.versions : [];
|
|
672
|
+
const pins = body.pins && typeof body.pins === "object" ? body.pins : {};
|
|
673
|
+
const confirmName = String(body.confirmName ?? "");
|
|
674
|
+
const inner = [];
|
|
675
|
+
const pinEntries = Object.entries(pins);
|
|
676
|
+
inner.push(
|
|
677
|
+
el("p", { class: "muted" }, [
|
|
678
|
+
el("span", { text: "Registry: " }),
|
|
679
|
+
el("code", { text: String(body.dir ?? "") }),
|
|
680
|
+
]),
|
|
681
|
+
);
|
|
682
|
+
if (pinEntries.length > 0) {
|
|
683
|
+
inner.push(
|
|
684
|
+
el(
|
|
685
|
+
"div",
|
|
686
|
+
{ class: "chip-group" },
|
|
687
|
+
pinEntries.map(([env, version]) =>
|
|
688
|
+
el("span", { class: "chip", text: `${env} → ${version}` }),
|
|
689
|
+
),
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
if (versions.length === 0) {
|
|
694
|
+
inner.push(
|
|
695
|
+
emptyState(
|
|
696
|
+
typeof body.note === "string" && body.note !== ""
|
|
697
|
+
? body.note
|
|
698
|
+
: "No spec versions registered yet",
|
|
699
|
+
typeof body.verb === "string" ? body.verb : "crewhaus spec put",
|
|
700
|
+
),
|
|
701
|
+
);
|
|
702
|
+
} else {
|
|
703
|
+
inner.push(
|
|
704
|
+
table(
|
|
705
|
+
["Version", "Pinned to", "Provenance", "", ""],
|
|
706
|
+
versions.map((version) => versionRow(ctx, version, confirmName, reload)),
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
if (typeof body.changelog === "string" && body.changelog !== "") {
|
|
711
|
+
inner.push(
|
|
712
|
+
collapsible(
|
|
713
|
+
[el("span", { class: "muted", text: "CHANGELOG (credential-redacted)" })],
|
|
714
|
+
[pre(body.changelog)],
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return card(
|
|
719
|
+
"Version history",
|
|
720
|
+
body.present === true
|
|
721
|
+
? dot("ok", `${versions.length} versions`)
|
|
722
|
+
: dot("off", "no registry yet"),
|
|
723
|
+
inner,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function versionRow(ctx, version, confirmName, reload) {
|
|
728
|
+
const id = String(version.version ?? "");
|
|
729
|
+
const provenance = version.provenance;
|
|
730
|
+
const view = el("button", { class: "btn btn-ghost", type: "button", text: "View" });
|
|
731
|
+
view.addEventListener("click", async () => {
|
|
732
|
+
const res = await api.specVersion({ id: ctx.id, version: id });
|
|
733
|
+
if (!res.ok || res.body?.present === false) {
|
|
734
|
+
toast(res.body?.note ?? "that version's file is gone");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
viewModal(`${id} (values masked server-side)`, [
|
|
738
|
+
pre(String(res.body?.yaml ?? "")),
|
|
739
|
+
copyBtn(String(res.body?.yaml ?? ""), "copy yaml"),
|
|
740
|
+
]);
|
|
741
|
+
});
|
|
742
|
+
const diff = el("button", { class: "btn btn-ghost", type: "button", text: "Diff vs live" });
|
|
743
|
+
diff.addEventListener("click", async () => {
|
|
744
|
+
const res = await api.specVersionDiff({ id: ctx.id, version: id });
|
|
745
|
+
if (!res.ok) {
|
|
746
|
+
toast(res.body?.error ?? `HTTP ${res.status}`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
viewModal(`${id} vs the live spec`, [
|
|
750
|
+
res.body?.note ? el("p", { class: "muted", text: String(res.body.note) }) : null,
|
|
751
|
+
pre(String(res.body?.diff ?? "")),
|
|
752
|
+
]);
|
|
753
|
+
});
|
|
754
|
+
const pin = el("button", { class: "btn", type: "button", text: "Pin to…" });
|
|
755
|
+
pin.addEventListener("click", () => pinDialog(ctx, id, confirmName, reload));
|
|
756
|
+
return el("tr", null, [
|
|
757
|
+
el("td", { class: "mono" }, [
|
|
758
|
+
el("span", { text: id }),
|
|
759
|
+
version.isCurrent === true ? el("span", { class: "chip", text: "current" }) : null,
|
|
760
|
+
version.present === false
|
|
761
|
+
? el("span", { class: "chip chip-warn", text: "file missing" })
|
|
762
|
+
: null,
|
|
763
|
+
]),
|
|
764
|
+
el("td", null, [
|
|
765
|
+
Array.isArray(version.pinnedEnvs) && version.pinnedEnvs.length > 0
|
|
766
|
+
? el(
|
|
767
|
+
"div",
|
|
768
|
+
{ class: "chip-group" },
|
|
769
|
+
version.pinnedEnvs.map((env) => el("span", { class: "chip", text: env })),
|
|
770
|
+
)
|
|
771
|
+
: el("span", { class: "muted", text: "—" }),
|
|
772
|
+
]),
|
|
773
|
+
el("td", { class: "muted", text: provenanceText(provenance) }),
|
|
774
|
+
el("td", { class: "cell-actions" }, [view, diff]),
|
|
775
|
+
el("td", { class: "cell-actions" }, [pin]),
|
|
776
|
+
]);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function provenanceText(provenance) {
|
|
780
|
+
if (provenance === null || provenance === undefined || typeof provenance !== "object") {
|
|
781
|
+
return "hand-authored";
|
|
782
|
+
}
|
|
783
|
+
const parts = [];
|
|
784
|
+
if (provenance.runId) parts.push(`optimizer ${provenance.runId}`);
|
|
785
|
+
if (provenance.mutator) parts.push(String(provenance.mutator));
|
|
786
|
+
if (provenance.scoreBefore !== undefined && provenance.scoreAfter !== undefined) {
|
|
787
|
+
parts.push(`score ${provenance.scoreBefore} → ${provenance.scoreAfter}`);
|
|
788
|
+
}
|
|
789
|
+
return parts.length > 0 ? parts.join(" · ") : "optimizer";
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Pin or roll back an environment to a version. Both shell the audited
|
|
793
|
+
* deploy verb server-side; both are typed-confirm here. */
|
|
794
|
+
function pinDialog(ctx, version, confirmName, reload) {
|
|
795
|
+
const envInput = el("input", {
|
|
796
|
+
class: "input",
|
|
797
|
+
type: "text",
|
|
798
|
+
value: "prod",
|
|
799
|
+
"aria-label": "environment",
|
|
800
|
+
});
|
|
801
|
+
const rollback = el("input", { type: "checkbox", "aria-label": "treat this as a rollback" });
|
|
802
|
+
confirmModal({
|
|
803
|
+
title: `Pin ${version}`,
|
|
804
|
+
lead: "Pins move through the deploy verbs, so the audit record and a protected environment's approval quorum are preserved. The manager never writes the manifest itself.",
|
|
805
|
+
detail: [
|
|
806
|
+
el("label", { class: "field" }, [el("span", { text: "Environment" }), envInput]),
|
|
807
|
+
el("label", { class: "field" }, [
|
|
808
|
+
rollback,
|
|
809
|
+
el("span", { text: " this is a rollback (the environment is moving backwards)" }),
|
|
810
|
+
]),
|
|
811
|
+
],
|
|
812
|
+
confirmName,
|
|
813
|
+
action: "Move the pin",
|
|
814
|
+
onConfirm: async () => {
|
|
815
|
+
const params = { id: ctx.id };
|
|
816
|
+
const payload = { env: envInput.value.trim(), version, confirmName };
|
|
817
|
+
const res = rollback.checked
|
|
818
|
+
? await api.specRollback(params, payload)
|
|
819
|
+
: await api.specPin(params, payload);
|
|
820
|
+
if (reportWrite(res, `Queued the pin move for ${payload.env}`)) reload();
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// MCP connectors
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
function connectorsCard(ctx, body, catalog, reload) {
|
|
830
|
+
const servers = Array.isArray(body.servers) ? body.servers : [];
|
|
831
|
+
const findings = Array.isArray(body.findings) ? body.findings : [];
|
|
832
|
+
const confirmName = String(body.confirmName ?? "");
|
|
833
|
+
const inner = [];
|
|
834
|
+
if (findings.length > 0) {
|
|
835
|
+
inner.push(
|
|
836
|
+
el(
|
|
837
|
+
"ul",
|
|
838
|
+
{ class: "issue-list" },
|
|
839
|
+
findings.map((finding) =>
|
|
840
|
+
el("li", null, [
|
|
841
|
+
dot(finding.level === "bad" ? "bad" : "warn", String(finding.code ?? "finding")),
|
|
842
|
+
el("code", { text: `${finding.server ?? ""}.${finding.envName ?? ""}` }),
|
|
843
|
+
el("span", { text: ` ${finding.message ?? ""}` }),
|
|
844
|
+
]),
|
|
845
|
+
),
|
|
846
|
+
),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (servers.length === 0) {
|
|
850
|
+
inner.push(
|
|
851
|
+
emptyState(
|
|
852
|
+
typeof body.note === "string" && body.note !== ""
|
|
853
|
+
? body.note
|
|
854
|
+
: "This spec declares no MCP servers",
|
|
855
|
+
typeof body.verb === "string" ? body.verb : "crewhaus mcp doctor",
|
|
856
|
+
),
|
|
857
|
+
);
|
|
858
|
+
} else {
|
|
859
|
+
inner.push(
|
|
860
|
+
table(
|
|
861
|
+
["Server", "Transport", "Command / URL", "Env refs", ""],
|
|
862
|
+
servers.map((server) => connectorRow(ctx, server, confirmName, reload)),
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
inner.push(connectorForm(ctx, catalog, confirmName, reload));
|
|
867
|
+
return card(
|
|
868
|
+
"MCP connectors",
|
|
869
|
+
findings.some((f) => f.level === "bad")
|
|
870
|
+
? dot("bad", `${findings.filter((f) => f.level === "bad").length} blocking findings`)
|
|
871
|
+
: dot("ok", `${servers.length} servers`),
|
|
872
|
+
inner,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function connectorRow(ctx, server, confirmName, reload) {
|
|
877
|
+
const name = String(server.name ?? "");
|
|
878
|
+
const refs = Array.isArray(server.envRefs) ? server.envRefs : [];
|
|
879
|
+
const remove = el("button", { class: "btn btn-ghost", type: "button", text: "Remove…" });
|
|
880
|
+
remove.addEventListener("click", async () => {
|
|
881
|
+
// The plan first — the server answers what WOULD change, then the same
|
|
882
|
+
// call with the confirmation applies it.
|
|
883
|
+
const plan = await api.mcpConnectorRemove({ id: ctx.id, name });
|
|
884
|
+
if (!plan.ok) {
|
|
885
|
+
toast(plan.body?.error ?? `HTTP ${plan.status}`);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (plan.body?.ok === false) {
|
|
889
|
+
toast(`${plan.body.code}: ${plan.body.reason}`, "info");
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
confirmModal({
|
|
893
|
+
title: `Remove ${name}`,
|
|
894
|
+
lead: "An MCP server is third-party code plus its credentials, so removing one is a human-owned spec edit.",
|
|
895
|
+
detail: [pre(String(plan.body?.diff ?? ""))],
|
|
896
|
+
confirmName,
|
|
897
|
+
action: "Remove it",
|
|
898
|
+
onConfirm: async () => {
|
|
899
|
+
const res = await api.mcpConnectorRemove(
|
|
900
|
+
{ id: ctx.id, name },
|
|
901
|
+
undefined,
|
|
902
|
+
`?confirmName=${encodeURIComponent(confirmName)}`,
|
|
903
|
+
);
|
|
904
|
+
if (reportWrite(res, `Removed ${name}`)) reload();
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
return el("tr", null, [
|
|
909
|
+
el("td", { class: "mono", text: name }),
|
|
910
|
+
el("td", null, [el("span", { class: "chip", text: String(server.transport ?? "stdio") })]),
|
|
911
|
+
el("td", { class: "mono", text: String(server.command ?? server.url ?? "—") }),
|
|
912
|
+
el("td", null, [
|
|
913
|
+
refs.length === 0
|
|
914
|
+
? el("span", { class: "muted", text: "—" })
|
|
915
|
+
: el(
|
|
916
|
+
"ul",
|
|
917
|
+
{ class: "check-list" },
|
|
918
|
+
refs.map((ref) =>
|
|
919
|
+
el("li", null, [
|
|
920
|
+
dot(ref.set === true ? "ok" : "bad", ref.set === true ? "set" : "unset"),
|
|
921
|
+
el("code", { text: String(ref.name ?? "") }),
|
|
922
|
+
]),
|
|
923
|
+
),
|
|
924
|
+
),
|
|
925
|
+
]),
|
|
926
|
+
el("td", { class: "cell-actions" }, [remove]),
|
|
927
|
+
]);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Add a connector: pick a catalog skeleton or type one, preview the diff,
|
|
931
|
+
* then confirm. Values for secrets are never accepted here — the form takes
|
|
932
|
+
* a command and its arguments, and the server refuses the rest. */
|
|
933
|
+
function connectorForm(ctx, catalog, confirmName, reload) {
|
|
934
|
+
const connectors = Array.isArray(catalog?.connectors) ? catalog.connectors : [];
|
|
935
|
+
const name = el("input", { class: "input", type: "text", "aria-label": "server name" });
|
|
936
|
+
const command = el("input", {
|
|
937
|
+
class: "input",
|
|
938
|
+
type: "text",
|
|
939
|
+
placeholder: "bunx",
|
|
940
|
+
"aria-label": "command",
|
|
941
|
+
});
|
|
942
|
+
const args = el("input", {
|
|
943
|
+
class: "input grow",
|
|
944
|
+
type: "text",
|
|
945
|
+
placeholder: "-y some-server",
|
|
946
|
+
"aria-label": "arguments",
|
|
947
|
+
});
|
|
948
|
+
const preset = el("select", { class: "input", "aria-label": "catalog entry" });
|
|
949
|
+
preset.appendChild(el("option", { value: "", text: "custom…" }));
|
|
950
|
+
for (const entry of connectors) {
|
|
951
|
+
preset.appendChild(el("option", { value: String(entry.id), text: String(entry.title) }));
|
|
952
|
+
}
|
|
953
|
+
preset.addEventListener("change", () => {
|
|
954
|
+
const entry = connectors.find((c) => String(c.id) === preset.value);
|
|
955
|
+
if (entry === undefined) return;
|
|
956
|
+
command.value = typeof entry.command === "string" ? entry.command : "";
|
|
957
|
+
args.value = Array.isArray(entry.args) ? entry.args.join(" ") : "";
|
|
958
|
+
});
|
|
959
|
+
const add = el("button", { class: "btn btn-primary", type: "button", text: "Preview…" });
|
|
960
|
+
add.addEventListener("click", async () => {
|
|
961
|
+
const payload = {
|
|
962
|
+
name: name.value.trim(),
|
|
963
|
+
command: command.value.trim(),
|
|
964
|
+
args: args.value.trim() === "" ? [] : args.value.trim().split(/\s+/),
|
|
965
|
+
};
|
|
966
|
+
const plan = await api.mcpConnectorWrite({ id: ctx.id }, payload);
|
|
967
|
+
if (!plan.ok) {
|
|
968
|
+
toast(plan.body?.error ?? `HTTP ${plan.status}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (plan.body?.ok === false) {
|
|
972
|
+
toast(`${plan.body.code}: ${plan.body.reason}`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
confirmModal({
|
|
976
|
+
title: `Add ${payload.name}`,
|
|
977
|
+
lead: "Review the diff. Credentials never belong in a connector's env map — put them in this harness's .env and the server will refuse anything that looks like one.",
|
|
978
|
+
detail: [pre(String(plan.body?.diff ?? ""))],
|
|
979
|
+
confirmName,
|
|
980
|
+
action: "Write it to the spec",
|
|
981
|
+
onConfirm: async () => {
|
|
982
|
+
const res = await api.mcpConnectorWrite(
|
|
983
|
+
{ id: ctx.id },
|
|
984
|
+
{ ...payload, dryRun: false, confirmName },
|
|
985
|
+
);
|
|
986
|
+
if (reportWrite(res, `Added ${payload.name}`)) reload();
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
return el("div", { class: "add-form" }, [
|
|
991
|
+
el("label", { class: "field" }, [el("span", { text: "From catalog" }), preset]),
|
|
992
|
+
el("label", { class: "field" }, [el("span", { text: "Name" }), name]),
|
|
993
|
+
el("label", { class: "field" }, [el("span", { text: "Command" }), command]),
|
|
994
|
+
el("label", { class: "field grow" }, [el("span", { text: "Arguments" }), args]),
|
|
995
|
+
add,
|
|
996
|
+
]);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
// graders + dataset builder
|
|
1001
|
+
// ---------------------------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
function gradersCard(ctx, body, confirmName, reload) {
|
|
1004
|
+
const graders = Array.isArray(body.graders) ? body.graders : [];
|
|
1005
|
+
const inner = [];
|
|
1006
|
+
inner.push(
|
|
1007
|
+
el("p", { class: "muted" }, [
|
|
1008
|
+
el("span", { text: "File: " }),
|
|
1009
|
+
el("code", { text: String(body.path ?? "eval/graders.yaml") }),
|
|
1010
|
+
body.hash ? el("span", { class: "chip", text: String(body.hash).slice(0, 14) }) : null,
|
|
1011
|
+
]),
|
|
1012
|
+
);
|
|
1013
|
+
if (graders.length === 0) {
|
|
1014
|
+
inner.push(
|
|
1015
|
+
emptyState(
|
|
1016
|
+
typeof body.note === "string" && body.note !== "" ? body.note : "No graders configured yet",
|
|
1017
|
+
typeof body.verb === "string" ? body.verb : "crewhaus graders suggest",
|
|
1018
|
+
),
|
|
1019
|
+
);
|
|
1020
|
+
} else {
|
|
1021
|
+
inner.push(
|
|
1022
|
+
table(
|
|
1023
|
+
["Grader", "Kind"],
|
|
1024
|
+
graders.map((grader) =>
|
|
1025
|
+
el("tr", null, [
|
|
1026
|
+
el("td", { class: "mono", text: String(grader.name ?? "—") }),
|
|
1027
|
+
el("td", null, [el("span", { class: "chip", text: String(grader.type ?? "—") })]),
|
|
1028
|
+
]),
|
|
1029
|
+
),
|
|
1030
|
+
),
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
const editor = el("textarea", {
|
|
1034
|
+
class: "input notes",
|
|
1035
|
+
rows: 8,
|
|
1036
|
+
"aria-label": "graders.yaml",
|
|
1037
|
+
text: typeof body.yaml === "string" ? body.yaml : "",
|
|
1038
|
+
});
|
|
1039
|
+
const save = el("button", { class: "btn", type: "button", text: "Save graders.yaml…" });
|
|
1040
|
+
save.addEventListener("click", () => {
|
|
1041
|
+
confirmModal({
|
|
1042
|
+
title: "Replace eval/graders.yaml",
|
|
1043
|
+
lead: "Runs scored after this write carry a new graders hash; runs already recorded keep the graders they were scored by.",
|
|
1044
|
+
detail: [el("p", { class: "muted", text: `${editor.value.length} characters` })],
|
|
1045
|
+
confirmName,
|
|
1046
|
+
action: "Write the file",
|
|
1047
|
+
onConfirm: async () => {
|
|
1048
|
+
const res = await api.graderWrite({ id: ctx.id }, { yaml: editor.value, confirmName });
|
|
1049
|
+
if (reportWrite(res, "Wrote eval/graders.yaml")) reload();
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
inner.push(
|
|
1054
|
+
collapsible([el("span", { class: "muted", text: "edit graders.yaml" })], [editor, save]),
|
|
1055
|
+
el("p", {
|
|
1056
|
+
class: "muted",
|
|
1057
|
+
text: "The structured grader builder ships as a published package this manager does not bundle; the YAML pane above is the write path it has.",
|
|
1058
|
+
}),
|
|
1059
|
+
el("p", { class: "muted" }, [
|
|
1060
|
+
el("span", { text: "Render the rubric card with " }),
|
|
1061
|
+
el("code", { text: String(body.cardVerb ?? "crewhaus graders card") }),
|
|
1062
|
+
el("span", { text: "." }),
|
|
1063
|
+
]),
|
|
1064
|
+
);
|
|
1065
|
+
return card(
|
|
1066
|
+
"Graders",
|
|
1067
|
+
body.present === true ? dot("ok", `${graders.length} graders`) : dot("off", "no graders.yaml"),
|
|
1068
|
+
inner,
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function datasetCard(body) {
|
|
1073
|
+
return card("Dataset builder", dot("off", "not in this build"), [
|
|
1074
|
+
emptyState(
|
|
1075
|
+
typeof body.note === "string" && body.note !== ""
|
|
1076
|
+
? body.note
|
|
1077
|
+
: "The dataset builder is not part of this build",
|
|
1078
|
+
typeof body.verb === "string" ? body.verb : "crewhaus dataset mine",
|
|
1079
|
+
),
|
|
1080
|
+
el("p", {
|
|
1081
|
+
class: "muted",
|
|
1082
|
+
text: "Datasets themselves live on the Data tab — this panel only ever drove the builder's state machine.",
|
|
1083
|
+
}),
|
|
1084
|
+
]);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ---------------------------------------------------------------------------
|
|
1088
|
+
// the new-harness wizard
|
|
1089
|
+
// ---------------------------------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
function wizardCard(body) {
|
|
1092
|
+
const templates = Array.isArray(body.templates) ? body.templates : [];
|
|
1093
|
+
if (templates.length === 0) {
|
|
1094
|
+
return card("New harness", dot("off", "no templates"), [
|
|
1095
|
+
emptyState("No templates in this build", "crewhaus init"),
|
|
1096
|
+
]);
|
|
1097
|
+
}
|
|
1098
|
+
return card("New harness", dot("ok", `${templates.length} templates`), [
|
|
1099
|
+
noteLine(body),
|
|
1100
|
+
table(
|
|
1101
|
+
["Template", "Shape", "What it scaffolds", ""],
|
|
1102
|
+
templates.map((template) => wizardRow(template)),
|
|
1103
|
+
),
|
|
1104
|
+
]);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function wizardRow(template) {
|
|
1108
|
+
const create = el("button", { class: "btn", type: "button", text: "Create…" });
|
|
1109
|
+
create.addEventListener("click", () => wizardDialog(template));
|
|
1110
|
+
return el("tr", null, [
|
|
1111
|
+
el("td", null, [
|
|
1112
|
+
el("div", { text: String(template.title ?? template.id) }),
|
|
1113
|
+
el("div", { class: "muted", text: String(template.summary ?? "") }),
|
|
1114
|
+
]),
|
|
1115
|
+
el("td", null, [el("span", { class: "shape-badge", text: String(template.target ?? "") })]),
|
|
1116
|
+
el("td", { class: "mono muted", text: (template.scaffolds ?? []).join(", ") }),
|
|
1117
|
+
el("td", { class: "cell-actions" }, [create]),
|
|
1118
|
+
]);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* The wizard is dry-run-first: the plan renders the exact spec that would be
|
|
1123
|
+
* written and touches nothing, and only the typed confirmation creates the
|
|
1124
|
+
* directory. Registration is a second, explicit step — the manager does not
|
|
1125
|
+
* adopt a directory the operator has not asked it to.
|
|
1126
|
+
*/
|
|
1127
|
+
function wizardDialog(template) {
|
|
1128
|
+
const dir = el("input", {
|
|
1129
|
+
class: "input grow",
|
|
1130
|
+
type: "text",
|
|
1131
|
+
placeholder: "/absolute/path/to/new-harness",
|
|
1132
|
+
"aria-label": "new harness directory",
|
|
1133
|
+
});
|
|
1134
|
+
const nameInput = el("input", { class: "input", type: "text", "aria-label": "spec name" });
|
|
1135
|
+
const plan = el("button", { class: "btn btn-primary", type: "button", text: "Preview" });
|
|
1136
|
+
const out = el("div", { class: "modal-body" });
|
|
1137
|
+
plan.addEventListener("click", async () => {
|
|
1138
|
+
const answers = nameInput.value.trim() === "" ? {} : { name: nameInput.value.trim() };
|
|
1139
|
+
const res = await api.wizardCreate(
|
|
1140
|
+
{},
|
|
1141
|
+
{ dir: dir.value.trim(), template: template.id, answers },
|
|
1142
|
+
);
|
|
1143
|
+
clear(out);
|
|
1144
|
+
if (!res.ok) {
|
|
1145
|
+
out.appendChild(el("p", { class: "reason", text: res.body?.error ?? `HTTP ${res.status}` }));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const body = res.body ?? {};
|
|
1149
|
+
if (body.ok === false) {
|
|
1150
|
+
out.appendChild(el("p", { class: "reason", text: `${body.code}: ${body.reason}` }));
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
out.appendChild(el("p", { class: "muted", text: String(body.note ?? "") }));
|
|
1154
|
+
out.appendChild(pre(String(body.yaml ?? "")));
|
|
1155
|
+
const confirmName = String(body.confirmName ?? "");
|
|
1156
|
+
const write = el("button", { class: "btn btn-danger", type: "button", text: "Create it" });
|
|
1157
|
+
const typed = el("input", {
|
|
1158
|
+
class: "input",
|
|
1159
|
+
type: "text",
|
|
1160
|
+
placeholder: confirmName,
|
|
1161
|
+
"aria-label": `type ${confirmName} to confirm`,
|
|
1162
|
+
});
|
|
1163
|
+
write.disabled = true;
|
|
1164
|
+
typed.addEventListener("input", () => {
|
|
1165
|
+
write.disabled = typed.value.trim() !== confirmName;
|
|
1166
|
+
});
|
|
1167
|
+
write.addEventListener("click", async () => {
|
|
1168
|
+
const made = await api.wizardCreate(
|
|
1169
|
+
{},
|
|
1170
|
+
{
|
|
1171
|
+
dir: dir.value.trim(),
|
|
1172
|
+
template: template.id,
|
|
1173
|
+
answers: nameInput.value.trim() === "" ? {} : { name: nameInput.value.trim() },
|
|
1174
|
+
dryRun: false,
|
|
1175
|
+
confirmName,
|
|
1176
|
+
},
|
|
1177
|
+
);
|
|
1178
|
+
if (!reportWrite(made, "Created the harness")) return;
|
|
1179
|
+
// Registering is the console's own route — the wizard's answer names it
|
|
1180
|
+
// rather than pretending the manager did it.
|
|
1181
|
+
try {
|
|
1182
|
+
await api.addHarness(String(made.body?.dir ?? dir.value.trim()));
|
|
1183
|
+
toast("Registered — it is in the Library now", "info");
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
toast(`Created, but not registered: ${err instanceof Error ? err.message : String(err)}`);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
out.appendChild(
|
|
1189
|
+
el("label", { class: "field" }, [
|
|
1190
|
+
el("span", { text: `Type ${confirmName} to create it` }),
|
|
1191
|
+
typed,
|
|
1192
|
+
]),
|
|
1193
|
+
);
|
|
1194
|
+
out.appendChild(el("div", { class: "modal-actions" }, [write]));
|
|
1195
|
+
});
|
|
1196
|
+
viewModal(`New ${template.title ?? template.id}`, [
|
|
1197
|
+
el("label", { class: "field grow" }, [el("span", { text: "Directory (absolute)" }), dir]),
|
|
1198
|
+
el("label", { class: "field" }, [el("span", { text: "Spec name" }), nameInput]),
|
|
1199
|
+
el("div", { class: "modal-actions" }, [plan]),
|
|
1200
|
+
out,
|
|
1201
|
+
]);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// ---------------------------------------------------------------------------
|
|
1205
|
+
// entry point
|
|
1206
|
+
// ---------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
export async function renderSpecEdit(root, ctx) {
|
|
1209
|
+
clear(root).appendChild(skeleton(6));
|
|
1210
|
+
const reload = () => renderSpecEdit(root, ctx);
|
|
1211
|
+
const [schema, trust, versions, connectors, catalog, graders, dataset, templates] =
|
|
1212
|
+
await Promise.all([
|
|
1213
|
+
api.specSchema({ id: ctx.id }),
|
|
1214
|
+
api.specTrust({ id: ctx.id }),
|
|
1215
|
+
api.specVersions({ id: ctx.id }),
|
|
1216
|
+
api.mcpConnectors({ id: ctx.id }),
|
|
1217
|
+
api.mcpCatalog({}),
|
|
1218
|
+
api.graderCatalog({ id: ctx.id }),
|
|
1219
|
+
api.datasetBuilder({ id: ctx.id }),
|
|
1220
|
+
api.wizardTemplates({}),
|
|
1221
|
+
]);
|
|
1222
|
+
clear(root);
|
|
1223
|
+
|
|
1224
|
+
// path → effective value, so the field editor can seed its inputs from
|
|
1225
|
+
// what the harness is actually running rather than from the raw text.
|
|
1226
|
+
const valueByPath = new Map();
|
|
1227
|
+
if (schema.ok && Array.isArray(schema.body?.effective)) {
|
|
1228
|
+
for (const row of schema.body.effective) valueByPath.set(String(row.path), row.value);
|
|
1229
|
+
}
|
|
1230
|
+
const confirmName = String(
|
|
1231
|
+
trust.body?.confirmName ?? connectors.body?.confirmName ?? versions.body?.confirmName ?? "",
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
root.appendChild(
|
|
1235
|
+
isStub(schema)
|
|
1236
|
+
? stubCard("Effective configuration", schema)
|
|
1237
|
+
: schema.ok
|
|
1238
|
+
? effectiveCard(schema.body ?? {})
|
|
1239
|
+
: errorCard("Effective configuration", schema),
|
|
1240
|
+
);
|
|
1241
|
+
root.appendChild(
|
|
1242
|
+
isStub(trust)
|
|
1243
|
+
? stubCard("Fields & trust tiers", trust)
|
|
1244
|
+
: trust.ok
|
|
1245
|
+
? trustCard(ctx, trust.body ?? {}, valueByPath, reload)
|
|
1246
|
+
: errorCard("Fields & trust tiers", trust),
|
|
1247
|
+
);
|
|
1248
|
+
root.appendChild(
|
|
1249
|
+
isStub(versions)
|
|
1250
|
+
? stubCard("Version history", versions)
|
|
1251
|
+
: versions.ok
|
|
1252
|
+
? versionsCard(ctx, versions.body ?? {}, reload)
|
|
1253
|
+
: errorCard("Version history", versions),
|
|
1254
|
+
);
|
|
1255
|
+
root.appendChild(
|
|
1256
|
+
isStub(connectors)
|
|
1257
|
+
? stubCard("MCP connectors", connectors)
|
|
1258
|
+
: connectors.ok
|
|
1259
|
+
? connectorsCard(ctx, connectors.body ?? {}, catalog.ok ? catalog.body : null, reload)
|
|
1260
|
+
: errorCard("MCP connectors", connectors),
|
|
1261
|
+
);
|
|
1262
|
+
root.appendChild(
|
|
1263
|
+
isStub(graders)
|
|
1264
|
+
? stubCard("Graders", graders)
|
|
1265
|
+
: graders.ok
|
|
1266
|
+
? gradersCard(ctx, graders.body ?? {}, confirmName, reload)
|
|
1267
|
+
: errorCard("Graders", graders),
|
|
1268
|
+
);
|
|
1269
|
+
root.appendChild(
|
|
1270
|
+
isStub(dataset) ? stubCard("Dataset builder", dataset) : datasetCard(dataset.body ?? {}),
|
|
1271
|
+
);
|
|
1272
|
+
root.appendChild(
|
|
1273
|
+
isStub(templates)
|
|
1274
|
+
? stubCard("New harness", templates)
|
|
1275
|
+
: templates.ok
|
|
1276
|
+
? wizardCard(templates.body ?? {})
|
|
1277
|
+
: errorCard("New harness", templates),
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** A route that failed for a reason that is not "not built yet". */
|
|
1282
|
+
function errorCard(title, res) {
|
|
1283
|
+
return card(title, dot("bad", `HTTP ${res.status ?? "?"}`), [
|
|
1284
|
+
el("p", { class: "muted", text: res.body?.error ?? "the manager could not answer" }),
|
|
1285
|
+
res.body
|
|
1286
|
+
? collapsible([el("span", { class: "muted", text: "raw" })], [jsonPre(res.body)])
|
|
1287
|
+
: null,
|
|
1288
|
+
]);
|
|
1289
|
+
}
|