@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,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HM-189 — the ⌘K overlay.
|
|
3
|
+
*
|
|
4
|
+
* A text field, a ranked result list, and — kept visually and behaviourally
|
|
5
|
+
* separate — the ACTIONS the server proposed for the query. Navigation
|
|
6
|
+
* results are links: pressing Enter on one goes somewhere. Actions are not:
|
|
7
|
+
* an action needs a second, explicit confirm before the console calls the
|
|
8
|
+
* ordinary route, and the row shows the exact CLI command it will run first.
|
|
9
|
+
*
|
|
10
|
+
* That split is the point. An omnibox that can start a daemon on Enter is a
|
|
11
|
+
* loaded gun on the keyboard; one that can only ever propose, and shows its
|
|
12
|
+
* CLI twin while proposing, is a faster way to do the thing you were going
|
|
13
|
+
* to do anyway.
|
|
14
|
+
*
|
|
15
|
+
* The index itself lives server-side and is built lazily (nothing is
|
|
16
|
+
* indexed until the first query), so opening this overlay on a hundred-
|
|
17
|
+
* harness machine costs one request, not a scan.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { api } from "./api.js";
|
|
21
|
+
import { clear, el, emptyState, toast } from "./dom.js";
|
|
22
|
+
import { ROUTES } from "./routes.js";
|
|
23
|
+
|
|
24
|
+
/** Debounce for keystroke → request. Short enough to feel live, long enough
|
|
25
|
+
* that a fast typist issues one request per word, not per letter. */
|
|
26
|
+
const DEBOUNCE_MS = 120;
|
|
27
|
+
|
|
28
|
+
/** Kind → the short label shown in the row's leading chip. */
|
|
29
|
+
const KIND_LABEL = {
|
|
30
|
+
harness: "harness",
|
|
31
|
+
group: "group",
|
|
32
|
+
tag: "tag",
|
|
33
|
+
session: "session",
|
|
34
|
+
wiki: "wiki",
|
|
35
|
+
fact: "facts",
|
|
36
|
+
dataset: "dataset",
|
|
37
|
+
grader: "grader",
|
|
38
|
+
"eval-run": "eval run",
|
|
39
|
+
incident: "incident",
|
|
40
|
+
approval: "approval",
|
|
41
|
+
action: "action",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which api wrapper an action's `route` names. Actions only ever reference
|
|
46
|
+
* the process verbs, and this map is the allowlist that keeps it that way:
|
|
47
|
+
* a server proposing anything else finds no executor here and the row
|
|
48
|
+
* renders disabled with the reason.
|
|
49
|
+
*/
|
|
50
|
+
const ACTION_EXECUTORS = {
|
|
51
|
+
procStart: (id) => api.procStart(id),
|
|
52
|
+
procStop: (id) => api.procStop(id),
|
|
53
|
+
procRestart: (id) => api.procRestart(id),
|
|
54
|
+
procDrain: (id) => api.procDrain(id),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mount the overlay. Returns `{ close }`; the caller (app.js) owns when it
|
|
59
|
+
* opens, because ⌘K ownership is decided by the `keys.js` reducer.
|
|
60
|
+
*/
|
|
61
|
+
export function openOmnibox(host, opts = {}) {
|
|
62
|
+
const onClose = opts.onClose ?? (() => {});
|
|
63
|
+
const backdrop = el("div", { class: "omni-backdrop", role: "presentation" });
|
|
64
|
+
const input = el("input", {
|
|
65
|
+
class: "omni-input",
|
|
66
|
+
type: "search",
|
|
67
|
+
placeholder: "Search harnesses, sessions, wiki, runs… or type “start <harness>”",
|
|
68
|
+
"aria-label": "search the fleet",
|
|
69
|
+
autocomplete: "off",
|
|
70
|
+
spellcheck: "false",
|
|
71
|
+
});
|
|
72
|
+
const listNode = el("div", { class: "omni-list", role: "listbox" });
|
|
73
|
+
const hint = el("div", { class: "omni-hint muted" }, [
|
|
74
|
+
el("span", { text: "Enter opens · actions ask first · Esc closes" }),
|
|
75
|
+
]);
|
|
76
|
+
const panel = el(
|
|
77
|
+
"div",
|
|
78
|
+
{ class: "omni-panel", role: "dialog", "aria-modal": "true", "aria-label": "omnibox" },
|
|
79
|
+
[input, listNode, hint],
|
|
80
|
+
);
|
|
81
|
+
backdrop.appendChild(panel);
|
|
82
|
+
host.appendChild(backdrop);
|
|
83
|
+
|
|
84
|
+
let rows = [];
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
let seq = 0;
|
|
87
|
+
let timer = null;
|
|
88
|
+
let pendingConfirm = null;
|
|
89
|
+
|
|
90
|
+
const close = () => {
|
|
91
|
+
if (timer !== null) clearTimeout(timer);
|
|
92
|
+
backdrop.remove();
|
|
93
|
+
onClose();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
backdrop.addEventListener("click", (event) => {
|
|
97
|
+
if (event.target === backdrop) close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const draw = () => {
|
|
101
|
+
clear(listNode);
|
|
102
|
+
if (rows.length === 0) {
|
|
103
|
+
listNode.appendChild(
|
|
104
|
+
input.value.trim() === ""
|
|
105
|
+
? el("div", { class: "empty" }, [
|
|
106
|
+
el("div", { class: "empty-msg", text: "Type to search the fleet" }),
|
|
107
|
+
el("div", {
|
|
108
|
+
class: "empty-verb muted",
|
|
109
|
+
text: "the index is built on demand — nothing is scanned until you ask",
|
|
110
|
+
}),
|
|
111
|
+
])
|
|
112
|
+
: emptyState(`Nothing matches “${input.value.trim()}”`, "crewhaus harness list"),
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
rows.forEach((row, i) => listNode.appendChild(rowNode(row, i)));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const rowNode = (row, index) => {
|
|
120
|
+
const selected = index === cursor;
|
|
121
|
+
const chip = el("span", { class: "omni-kind", text: KIND_LABEL[row.kind] ?? row.kind });
|
|
122
|
+
if (row.kind === "action") {
|
|
123
|
+
const confirming = pendingConfirm === row.id;
|
|
124
|
+
const node = el(
|
|
125
|
+
"div",
|
|
126
|
+
{
|
|
127
|
+
class: `omni-row omni-action${selected ? " selected" : ""}`,
|
|
128
|
+
role: "option",
|
|
129
|
+
"aria-selected": selected ? "true" : "false",
|
|
130
|
+
},
|
|
131
|
+
[
|
|
132
|
+
chip,
|
|
133
|
+
el("span", { class: "omni-title", text: row.title }),
|
|
134
|
+
el("code", { class: "omni-twin mono", text: row.cliTwin }),
|
|
135
|
+
confirming
|
|
136
|
+
? el("span", { class: "omni-confirm" }, [
|
|
137
|
+
el("button", {
|
|
138
|
+
class: "btn btn-danger",
|
|
139
|
+
type: "button",
|
|
140
|
+
text: "Run it",
|
|
141
|
+
onClick: () => runAction(row),
|
|
142
|
+
}),
|
|
143
|
+
el("button", {
|
|
144
|
+
class: "btn btn-ghost",
|
|
145
|
+
type: "button",
|
|
146
|
+
text: "Cancel",
|
|
147
|
+
onClick: () => {
|
|
148
|
+
pendingConfirm = null;
|
|
149
|
+
draw();
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
])
|
|
153
|
+
: el("button", {
|
|
154
|
+
class: "btn",
|
|
155
|
+
type: "button",
|
|
156
|
+
text: "Confirm…",
|
|
157
|
+
onClick: () => {
|
|
158
|
+
pendingConfirm = row.id;
|
|
159
|
+
draw();
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
return node;
|
|
165
|
+
}
|
|
166
|
+
return el(
|
|
167
|
+
"a",
|
|
168
|
+
{
|
|
169
|
+
class: `omni-row${selected ? " selected" : ""}`,
|
|
170
|
+
role: "option",
|
|
171
|
+
"aria-selected": selected ? "true" : "false",
|
|
172
|
+
href: row.href,
|
|
173
|
+
onClick: () => close(),
|
|
174
|
+
},
|
|
175
|
+
[
|
|
176
|
+
chip,
|
|
177
|
+
el("span", { class: "omni-title", text: row.title }),
|
|
178
|
+
el("span", { class: "omni-sub muted", text: row.subtitle }),
|
|
179
|
+
],
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const runAction = async (row) => {
|
|
184
|
+
const executor = ACTION_EXECUTORS[row.route];
|
|
185
|
+
if (executor === undefined) {
|
|
186
|
+
toast(`This build cannot run “${row.route}” from the omnibox`, "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
pendingConfirm = null;
|
|
190
|
+
try {
|
|
191
|
+
const res = await executor(row.params?.id);
|
|
192
|
+
// The process routes answer a typed refusal as a BODY; surface it.
|
|
193
|
+
if (res && res.ok === false) {
|
|
194
|
+
toast(String(res.body?.message ?? res.body?.reason ?? "refused"), "error");
|
|
195
|
+
} else {
|
|
196
|
+
toast(`${row.title} — sent`, "info");
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
toast(err instanceof Error ? err.message : String(err), "error");
|
|
200
|
+
}
|
|
201
|
+
close();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const search = async () => {
|
|
205
|
+
const query = input.value.trim();
|
|
206
|
+
const mine = ++seq;
|
|
207
|
+
if (query === "") {
|
|
208
|
+
rows = [];
|
|
209
|
+
draw();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
let payload = null;
|
|
213
|
+
try {
|
|
214
|
+
payload = await api.search(query);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (mine !== seq) return;
|
|
217
|
+
rows = [];
|
|
218
|
+
clear(listNode).appendChild(
|
|
219
|
+
emptyState(
|
|
220
|
+
`Search failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
221
|
+
"crewhaus harness list",
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (mine !== seq) return; // a newer keystroke already answered
|
|
227
|
+
rows = omniRows(payload);
|
|
228
|
+
cursor = 0;
|
|
229
|
+
pendingConfirm = null;
|
|
230
|
+
draw();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
input.addEventListener("input", () => {
|
|
234
|
+
if (timer !== null) clearTimeout(timer);
|
|
235
|
+
timer = setTimeout(search, DEBOUNCE_MS);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
input.addEventListener("keydown", (event) => {
|
|
239
|
+
if (event.key === "ArrowDown" || (event.key === "n" && event.ctrlKey)) {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
cursor = rows.length === 0 ? 0 : Math.min(cursor + 1, rows.length - 1);
|
|
242
|
+
draw();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event.key === "ArrowUp" || (event.key === "p" && event.ctrlKey)) {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
cursor = Math.max(cursor - 1, 0);
|
|
248
|
+
draw();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (event.key === "Enter") {
|
|
252
|
+
const row = rows[cursor];
|
|
253
|
+
if (row === undefined) return;
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
// Enter NEVER runs an action: it asks. Navigation rows go.
|
|
256
|
+
if (row.kind === "action") {
|
|
257
|
+
pendingConfirm = row.id;
|
|
258
|
+
draw();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
window.location.hash = row.href.startsWith("#") ? row.href.slice(1) : row.href;
|
|
262
|
+
close();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
input.focus();
|
|
267
|
+
draw();
|
|
268
|
+
return { close };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The server payload → the overlay's row list: navigation entries first,
|
|
273
|
+
* then actions. Pure, and exported so the ordering and the shape can be
|
|
274
|
+
* tested without a DOM.
|
|
275
|
+
*/
|
|
276
|
+
export function omniRows(payload) {
|
|
277
|
+
const body = payload && typeof payload === "object" ? payload : {};
|
|
278
|
+
const entries = Array.isArray(body.entries) ? body.entries : [];
|
|
279
|
+
const actions = Array.isArray(body.actions) ? body.actions : [];
|
|
280
|
+
return [
|
|
281
|
+
...entries.map((e) => ({
|
|
282
|
+
kind: typeof e.kind === "string" ? e.kind : "harness",
|
|
283
|
+
id: String(e.id ?? ""),
|
|
284
|
+
title: String(e.title ?? ""),
|
|
285
|
+
subtitle: String(e.subtitle ?? ""),
|
|
286
|
+
href: typeof e.href === "string" ? e.href : "#/",
|
|
287
|
+
})),
|
|
288
|
+
...actions.map((a) => ({
|
|
289
|
+
kind: "action",
|
|
290
|
+
id: String(a.id ?? ""),
|
|
291
|
+
title: String(a.label ?? ""),
|
|
292
|
+
subtitle: "",
|
|
293
|
+
href: "#/",
|
|
294
|
+
route: String(a.route ?? ""),
|
|
295
|
+
params: a.params && typeof a.params === "object" ? a.params : {},
|
|
296
|
+
cliTwin: String(a.cliTwin ?? ""),
|
|
297
|
+
// Never trusted from the payload: an action is confirmed here, always.
|
|
298
|
+
confirm: true,
|
|
299
|
+
})),
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** True when this build can execute the named action route. Exported so the
|
|
304
|
+
* row can render disabled-with-reason rather than failing on click. */
|
|
305
|
+
export function canRunAction(route) {
|
|
306
|
+
return Object.hasOwn(ACTION_EXECUTORS, String(route)) && Object.hasOwn(ROUTES, String(route));
|
|
307
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash router. `parseRoute` is pure (unit-tested under bun); `startRouter`
|
|
3
|
+
* is the only function that touches `window`, so importing this module
|
|
4
|
+
* outside a browser is safe.
|
|
5
|
+
*
|
|
6
|
+
* Route map (every screen deep-linkable):
|
|
7
|
+
* #/ → Library
|
|
8
|
+
* #/runs → Runs & daemons (fleet supervision)
|
|
9
|
+
* #/approvals → the approvals inbox
|
|
10
|
+
* #/review → the review queue
|
|
11
|
+
* #/activity → the activity digest
|
|
12
|
+
* #/h/<id> → harness detail, Overview tab
|
|
13
|
+
* #/h/<id>/spec → Spec
|
|
14
|
+
* #/h/<id>/runs → this harness's run ledger
|
|
15
|
+
* #/h/<id>/runs/<runId> → one run's console (live SSE feed)
|
|
16
|
+
* #/h/<id>/schedulers → the four-lane timeline
|
|
17
|
+
* #/h/<id>/sessions → Sessions list
|
|
18
|
+
* #/h/<id>/sessions/<sess> → one transcript
|
|
19
|
+
* #/h/<id>/evals → eval run history
|
|
20
|
+
* #/h/<id>/evals/<runId> → one run
|
|
21
|
+
* #/h/<id>/evals/<runId>/<sampleId> → one sample
|
|
22
|
+
* #/h/<id>/memory → memory fabric
|
|
23
|
+
* #/h/<id>/memory/wiki/<slug> → wiki article reader
|
|
24
|
+
* #/h/<id>/costs → costs
|
|
25
|
+
* #/h/<id>/deploy → deployment records
|
|
26
|
+
*
|
|
27
|
+
* M3 adds the detail tabs and two more fleet screens:
|
|
28
|
+
* #/health → the fleet health board (M4)
|
|
29
|
+
* #/settings → notification rules, read-only, plugins (M4)
|
|
30
|
+
* #/h/<id>/panes → sandboxed plugin panes (M4)
|
|
31
|
+
* #/credentials → the fleet credential matrix
|
|
32
|
+
* #/thredz → the global Thredz explorer
|
|
33
|
+
* #/feedback → the fleet feedback rollup
|
|
34
|
+
* #/h/<id>/{data,feedback,creds,channels,security,thredz,inspect,dev}
|
|
35
|
+
* Each M3 tab accepts trailing segments, captured verbatim as `rest` — the
|
|
36
|
+
* sub-screens inside them (a dataset, a store, a Thredz article) are the
|
|
37
|
+
* area implementer's to name, and the router should not have to be edited
|
|
38
|
+
* again for each one.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const HARNESS_TABS = [
|
|
42
|
+
"overview",
|
|
43
|
+
"spec",
|
|
44
|
+
"runs",
|
|
45
|
+
"schedulers",
|
|
46
|
+
"sessions",
|
|
47
|
+
"evals",
|
|
48
|
+
"memory",
|
|
49
|
+
"data",
|
|
50
|
+
"feedback",
|
|
51
|
+
"costs",
|
|
52
|
+
"creds",
|
|
53
|
+
"channels",
|
|
54
|
+
"security",
|
|
55
|
+
"thredz",
|
|
56
|
+
"deploy",
|
|
57
|
+
"inspect",
|
|
58
|
+
"dev",
|
|
59
|
+
// M4 (HM-179): the sandboxed plugin panes this harness shows. Always on
|
|
60
|
+
// the strip — a harness with no applicable pane renders the honest empty
|
|
61
|
+
// state naming the install verb, which is more useful than a tab that
|
|
62
|
+
// appears only once a plugin exists.
|
|
63
|
+
"panes",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** The M3 tabs, whose trailing segments are captured generically as `rest`.
|
|
67
|
+
* Shape gating (which tabs a given target shows) is applied where the tab
|
|
68
|
+
* strip is drawn, through {@link tabAvailability} below — never here: a deep
|
|
69
|
+
* link to an off-matrix tab must still RESOLVE, because the honest empty
|
|
70
|
+
* state that tab renders is a real answer and a shared URL must not 404. */
|
|
71
|
+
export const M3_TABS = [
|
|
72
|
+
"data",
|
|
73
|
+
"feedback",
|
|
74
|
+
"creds",
|
|
75
|
+
"channels",
|
|
76
|
+
"security",
|
|
77
|
+
"thredz",
|
|
78
|
+
"inspect",
|
|
79
|
+
"dev",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** The global (fleet-wide) screens, in nav order. `#/` is the Library. */
|
|
83
|
+
export const GLOBAL_VIEWS = [
|
|
84
|
+
"runs",
|
|
85
|
+
"approvals",
|
|
86
|
+
"review",
|
|
87
|
+
"activity",
|
|
88
|
+
"credentials",
|
|
89
|
+
"feedback",
|
|
90
|
+
"thredz",
|
|
91
|
+
// M4: the fleet health board (HM-11) and Settings (HM-198's console half:
|
|
92
|
+
// notification rules, read-only mode, the plugin inventory).
|
|
93
|
+
"health",
|
|
94
|
+
"settings",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The compile targets, in the order the plan's shape × panel matrix lists
|
|
99
|
+
* them. This is the strict union `@crewhaus/spec` validates against; a
|
|
100
|
+
* target that is NOT in this list is unknown to this build, and an unknown
|
|
101
|
+
* shape is gated at nothing — hiding a panel from a shape the console has
|
|
102
|
+
* never heard of would be a guess wearing the clothes of a rule.
|
|
103
|
+
*/
|
|
104
|
+
export const SHAPE_TARGETS = [
|
|
105
|
+
"cli",
|
|
106
|
+
"workflow",
|
|
107
|
+
"channel",
|
|
108
|
+
"graph",
|
|
109
|
+
"managed",
|
|
110
|
+
"pipeline",
|
|
111
|
+
"crew",
|
|
112
|
+
"research",
|
|
113
|
+
"batch",
|
|
114
|
+
"voice",
|
|
115
|
+
"browser",
|
|
116
|
+
"eval",
|
|
117
|
+
"onchain",
|
|
118
|
+
"onchain-game",
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The shape × panel matrix, as data.
|
|
123
|
+
*
|
|
124
|
+
* Every target gets the universal set — Overview, Spec, Runs, Sessions,
|
|
125
|
+
* Evals, Datasets, Costs, Credentials, Security, Deployments, Inspect and
|
|
126
|
+
* Dev & MCP — because each of those reads something every harness has (a
|
|
127
|
+
* directory, a spec, a session store, a ledger). The five tabs below are
|
|
128
|
+
* per-shape ADDITIONS, and the rule for each is the same one the spec
|
|
129
|
+
* schema already enforces: **a tab is on the matrix for a target when that
|
|
130
|
+
* target's schema admits at least one of the blocks the tab configures.**
|
|
131
|
+
*
|
|
132
|
+
* That rule is deliberately narrower than "the store might hold something":
|
|
133
|
+
* a tab whose subject the target cannot declare has nothing to configure
|
|
134
|
+
* and nothing to wire, so linking to it buys the operator a screen whose
|
|
135
|
+
* only content is the sentence "not applicable". It is also deliberately
|
|
136
|
+
* NOT a hard gate — `parseRoute` still resolves the deep link and the
|
|
137
|
+
* screen still answers, so nothing an operator saved is unreachable.
|
|
138
|
+
*/
|
|
139
|
+
export const SHAPE_GATED_TABS = {
|
|
140
|
+
channels: {
|
|
141
|
+
// `channels:`, `routing:` and `gateway:` are channel-only in the union.
|
|
142
|
+
shapes: ["channel"],
|
|
143
|
+
why: "a channels: block (with its routing and gateway) is only valid in a channel spec",
|
|
144
|
+
},
|
|
145
|
+
schedulers: {
|
|
146
|
+
// heartbeat: channel · schedule: channel/managed/batch · memory.dream:
|
|
147
|
+
// every shape with a memory block · janitor: the daemon shapes.
|
|
148
|
+
shapes: ["cli", "channel", "managed", "crew", "research", "batch", "voice"],
|
|
149
|
+
why: "no lane can arm here — this target's schema admits no heartbeat:, schedule: or memory.dream: block, and its bundle runs no janitor",
|
|
150
|
+
},
|
|
151
|
+
memory: {
|
|
152
|
+
// memory:/knowledge:/learning:/watchme:, or continuity: on its own.
|
|
153
|
+
shapes: [
|
|
154
|
+
"cli",
|
|
155
|
+
"workflow",
|
|
156
|
+
"channel",
|
|
157
|
+
"managed",
|
|
158
|
+
"crew",
|
|
159
|
+
"research",
|
|
160
|
+
"batch",
|
|
161
|
+
"voice",
|
|
162
|
+
"browser",
|
|
163
|
+
],
|
|
164
|
+
why: "this target's schema admits neither a memory: nor a continuity: block, so there is no fabric to read",
|
|
165
|
+
},
|
|
166
|
+
feedback: {
|
|
167
|
+
shapes: ["cli", "channel", "managed"],
|
|
168
|
+
why: "this target's schema admits no feedback: block, so the ratings loop — reactions, distill sinks, advice — is not wired here",
|
|
169
|
+
},
|
|
170
|
+
thredz: {
|
|
171
|
+
shapes: ["cli", "channel", "managed", "crew", "research"],
|
|
172
|
+
why: "this target's schema admits no thredz: block, so there is no workspace for the manager to proxy",
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Which tabs this target shows, in strip order.
|
|
178
|
+
*
|
|
179
|
+
* Returns one row per {@link HARNESS_TABS} entry: `{ tab, on, reason }`.
|
|
180
|
+
* `on:false` rows carry the sentence the strip renders instead of a link —
|
|
181
|
+
* a disabled tab that says why is information; a live link into an empty
|
|
182
|
+
* screen is a maze.
|
|
183
|
+
*/
|
|
184
|
+
export function tabAvailability(target) {
|
|
185
|
+
const shape = typeof target === "string" ? target : "";
|
|
186
|
+
const known = SHAPE_TARGETS.includes(shape);
|
|
187
|
+
return HARNESS_TABS.map((tab) => {
|
|
188
|
+
const gate = Object.hasOwn(SHAPE_GATED_TABS, tab) ? SHAPE_GATED_TABS[tab] : null;
|
|
189
|
+
if (gate === null || !known || gate.shapes.includes(shape)) {
|
|
190
|
+
return { tab, on: true, reason: null };
|
|
191
|
+
}
|
|
192
|
+
return { tab, on: false, reason: `${gate.why} — this harness's target is ${shape}` };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeDecode(part) {
|
|
197
|
+
try {
|
|
198
|
+
return decodeURIComponent(part);
|
|
199
|
+
} catch {
|
|
200
|
+
return part;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parse a location.hash string into a route object. Never throws. */
|
|
205
|
+
export function parseRoute(hash) {
|
|
206
|
+
const raw = typeof hash === "string" ? hash : "";
|
|
207
|
+
const h = raw.startsWith("#") ? raw.slice(1) : raw;
|
|
208
|
+
if (h === "" || h === "/") return { view: "library" };
|
|
209
|
+
const parts = h
|
|
210
|
+
.split("/")
|
|
211
|
+
.filter((p) => p !== "")
|
|
212
|
+
.map(safeDecode);
|
|
213
|
+
// Fleet-wide screens live at the root, harness screens under /h/<id>.
|
|
214
|
+
if (parts[0] !== "h") {
|
|
215
|
+
if (parts.length === 1 && GLOBAL_VIEWS.includes(parts[0])) return { view: parts[0] };
|
|
216
|
+
return { view: "notfound", hash: raw };
|
|
217
|
+
}
|
|
218
|
+
if (parts.length < 2) return { view: "notfound", hash: raw };
|
|
219
|
+
const id = parts[1];
|
|
220
|
+
const tab = parts[2] ?? "overview";
|
|
221
|
+
const rest = parts.slice(3);
|
|
222
|
+
switch (tab) {
|
|
223
|
+
case "overview":
|
|
224
|
+
case "spec":
|
|
225
|
+
case "costs":
|
|
226
|
+
case "schedulers":
|
|
227
|
+
case "deploy":
|
|
228
|
+
case "panes":
|
|
229
|
+
return { view: "harness", id, tab };
|
|
230
|
+
case "sessions":
|
|
231
|
+
return rest[0] !== undefined
|
|
232
|
+
? { view: "harness", id, tab, sessionId: rest[0] }
|
|
233
|
+
: { view: "harness", id, tab };
|
|
234
|
+
case "runs":
|
|
235
|
+
return rest[0] !== undefined
|
|
236
|
+
? { view: "harness", id, tab, runId: rest[0] }
|
|
237
|
+
: { view: "harness", id, tab };
|
|
238
|
+
case "evals": {
|
|
239
|
+
const route = { view: "harness", id, tab };
|
|
240
|
+
if (rest[0] !== undefined) route.runId = rest[0];
|
|
241
|
+
if (rest[1] !== undefined) route.sampleId = rest[1];
|
|
242
|
+
return route;
|
|
243
|
+
}
|
|
244
|
+
case "memory":
|
|
245
|
+
return rest[0] === "wiki" && rest[1] !== undefined
|
|
246
|
+
? { view: "harness", id, tab, wikiSlug: rest[1] }
|
|
247
|
+
: { view: "harness", id, tab };
|
|
248
|
+
default:
|
|
249
|
+
// The M3 tabs take their trailing segments generically, so adding a
|
|
250
|
+
// sub-screen inside one is a view change, not a router change.
|
|
251
|
+
if (M3_TABS.includes(tab)) {
|
|
252
|
+
return rest.length > 0 ? { view: "harness", id, tab, rest } : { view: "harness", id, tab };
|
|
253
|
+
}
|
|
254
|
+
return { view: "notfound", hash: raw };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Build a deep link for a harness tab (+ optional trailing segments). */
|
|
259
|
+
export function hrefHarness(id, tab = "overview", ...rest) {
|
|
260
|
+
const segs = [`#/h/${encodeURIComponent(id)}`];
|
|
261
|
+
if (tab !== "overview" || rest.length > 0) segs.push(tab);
|
|
262
|
+
for (const part of rest) segs.push(encodeURIComponent(String(part)));
|
|
263
|
+
return segs.join("/");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The Library link. */
|
|
267
|
+
export function hrefLibrary() {
|
|
268
|
+
return "#/";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A fleet-wide screen's link (`#/runs`, `#/approvals`, …). */
|
|
272
|
+
export function hrefGlobal(view) {
|
|
273
|
+
return `#/${view}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Wire hashchange → handler and fire once for the current hash. */
|
|
277
|
+
export function startRouter(onChange) {
|
|
278
|
+
window.addEventListener("hashchange", () => onChange(parseRoute(window.location.hash)));
|
|
279
|
+
onChange(parseRoute(window.location.hash));
|
|
280
|
+
}
|