@crewhaus/hangar-ui 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * The route contract as pure data — the ONE place client code names a
3
+ * server path. `api.js` builds every request from this map, and the hangar
4
+ * server's contract test (`packages/hangar-server/src/contract.test.ts`)
5
+ * imports the same map and drives every route here against a live fixture
6
+ * server: writes must 2xx with a visible effect, reads must 2xx with every
7
+ * field the views consume present. A route drifting from the server fails
8
+ * that test — the alarm that keeps this file honest.
9
+ *
10
+ * Each entry: `{ method, path }` where `path` is a template whose `:name`
11
+ * segments `buildPath` fills (values URI-encoded). Writes carry `body`, the
12
+ * name of their request-body shape (documentation for humans + the contract
13
+ * test; the shapes themselves are defined by the server handlers).
14
+ * `stream: "sse"` marks a route whose response is `text/event-stream`, not
15
+ * JSON — `api.js` must not `res.json()` it.
16
+ *
17
+ * M1 was read-only over harness state. M2 makes the console a DRIVER: the
18
+ * process routes start/stop/restart/drain a supervised harness, the control
19
+ * routes proxy `crewhaus.control.v1` (the token is read server-side and
20
+ * never crosses this boundary), and the approvals/review routes settle
21
+ * parked work through the same stores the CLI writes through.
22
+ *
23
+ * M3 is the DETAIL surface, and every M3 entry carries one extra field:
24
+ * `group`, naming the area that owns it (spec / memory / evals / data /
25
+ * feedback / creds / channels / security / thredz / inspect / runtime).
26
+ * That single field does a lot of work — it is what lets `api.js` generate
27
+ * the client wrappers instead of hand-writing 180 of them, what the views
28
+ * read to render their own surface honestly while the handlers are still
29
+ * stubs, and what the server's dispatch table is asserted equal to. A route
30
+ * WITHOUT a group is M1/M2 and has a hand-written wrapper.
31
+ *
32
+ * The M3 handlers currently answer `501 not implemented (M3)`. That is a
33
+ * deliberate state, not an oversight: the contract is frozen first so six
34
+ * implementers can fill the bodies in parallel without colliding, and the
35
+ * contract test accepts a 501 today and demands the view's fields the moment
36
+ * a route goes real.
37
+ */
38
+
39
+ export const ROUTES = {
40
+ // cross-cutting
41
+ version: { method: "GET", path: "/api/version" },
42
+
43
+ // fleet feed + registry CRUD (the ONLY writes in M1 — manager state)
44
+ harnesses: { method: "GET", path: "/api/harnesses" },
45
+ addHarness: { method: "POST", path: "/api/harnesses", body: "AddHarness" }, // {dir}
46
+ scan: { method: "POST", path: "/api/scan", body: "Empty" }, // {}
47
+ groups: { method: "GET", path: "/api/registry/groups" },
48
+ addGroup: { method: "POST", path: "/api/registry/groups", body: "GroupCreate" }, // {name, color?}
49
+ addScanRoot: { method: "POST", path: "/api/registry/scan-roots", body: "ScanRootCreate" }, // {dir}
50
+ removeHarness: { method: "DELETE", path: "/api/h/:id" },
51
+ relocate: { method: "POST", path: "/api/h/:id/relocate", body: "Relocate" }, // {newDir}
52
+ setGroups: { method: "PUT", path: "/api/h/:id/groups", body: "SetGroups" }, // {groups}
53
+ setTags: { method: "PUT", path: "/api/h/:id/tags", body: "SetTags" }, // {tags}
54
+ setPin: { method: "PUT", path: "/api/h/:id/pin", body: "SetPin" }, // {pinned}
55
+ setNotes: { method: "PUT", path: "/api/h/:id/notes", body: "SetNotes" }, // {notes}
56
+
57
+ // per-harness reads
58
+ harness: { method: "GET", path: "/api/h/:id" },
59
+ spec: { method: "GET", path: "/api/h/:id/spec" },
60
+ preflight: { method: "GET", path: "/api/h/:id/preflight" },
61
+ sessions: { method: "GET", path: "/api/h/:id/sessions" },
62
+ session: { method: "GET", path: "/api/h/:id/sessions/:sess" },
63
+ evals: { method: "GET", path: "/api/h/:id/evals" },
64
+ evalRun: { method: "GET", path: "/api/h/:id/evals/:runId" },
65
+ evalSample: { method: "GET", path: "/api/h/:id/evals/:runId/:sampleId" },
66
+ memory: { method: "GET", path: "/api/h/:id/memory/:area" },
67
+ wikiArticle: { method: "GET", path: "/api/h/:id/memory/wiki/:slug" },
68
+ costs: { method: "GET", path: "/api/h/:id/costs" },
69
+
70
+ // ---- M2: the process layer -------------------------------------------
71
+ // `start` runs the preflight gate FIRST and answers the typed refusal
72
+ // instead of spawning; `{force}` waves through every forceable blocking
73
+ // item and `{acknowledge:[id]}` waves through named ones — neither can
74
+ // clear an UNFORCEABLE finding (missing channel secrets), because the
75
+ // compiled daemon exits 2 on exactly that set.
76
+ proc: { method: "GET", path: "/api/h/:id/proc" },
77
+ procStart: { method: "POST", path: "/api/h/:id/proc/start", body: "ProcStart" }, // {force?, acknowledge?}
78
+ procStop: { method: "POST", path: "/api/h/:id/proc/stop", body: "Empty" }, // {}
79
+ procRestart: { method: "POST", path: "/api/h/:id/proc/restart", body: "ProcStart" }, // {force?, acknowledge?}
80
+ procDrain: { method: "POST", path: "/api/h/:id/proc/drain", body: "Empty" }, // {}
81
+
82
+ // run history + the live feed. `runEvents` is SSE: it opens with a
83
+ // `replay` frame of durable history and ALWAYS ends with `done`.
84
+ runs: { method: "GET", path: "/api/h/:id/runs" },
85
+ run: { method: "GET", path: "/api/h/:id/runs/:runId" },
86
+ runEvents: { method: "GET", path: "/api/h/:id/runs/:runId/events", stream: "sse" },
87
+
88
+ // ---- M2: crewhaus.control.v1 proxy -----------------------------------
89
+ // Always 200 with a typed envelope: `{ok:true,…}` or `{ok:false, code,
90
+ // reason, retryable, expected}`. `no_control_port` (pre-0.5.0 bundle) and
91
+ // `lane_not_armed` are FACTS, not errors — render disabled-with-reason.
92
+ // The two 409s differ: `tick_in_flight` retries, `draining` does not.
93
+ controlStatus: { method: "GET", path: "/api/h/:id/control/status" },
94
+ controlWake: { method: "POST", path: "/api/h/:id/control/wake", body: "ControlWake" }, // {lane, reason?}
95
+ controlDrain: { method: "POST", path: "/api/h/:id/control/drain", body: "Empty" }, // {}
96
+
97
+ // the four-lane timeline: spec cadence (offline) merged with control.v1
98
+ // phase (online only), plus dream state and janitor rows
99
+ schedulers: { method: "GET", path: "/api/h/:id/schedulers" },
100
+
101
+ // ---- M2: fleet inboxes ------------------------------------------------
102
+ approvals: { method: "GET", path: "/api/approvals" },
103
+ grantApproval: {
104
+ method: "POST",
105
+ path: "/api/h/:id/approvals/:apprId/grant",
106
+ body: "ApprovalDecision", // {by?}
107
+ },
108
+ denyApproval: {
109
+ method: "POST",
110
+ path: "/api/h/:id/approvals/:apprId/deny",
111
+ body: "ApprovalDecision", // {by?}
112
+ },
113
+ review: { method: "GET", path: "/api/review" },
114
+ adjudicateReview: {
115
+ method: "POST",
116
+ path: "/api/h/:id/review/:itemId",
117
+ body: "ReviewVerdict", // {verdict: up|down|pass|fail, note?}
118
+ },
119
+ activity: { method: "GET", path: "/api/activity" }, // ?since=<iso|epochMs|7d>
120
+
121
+ // ---- M2: jobs + the remaining action faces ----------------------------
122
+ jobs: { method: "GET", path: "/api/jobs" },
123
+ submitJob: { method: "POST", path: "/api/h/:id/jobs", body: "JobSubmit" }, // {kind, dataset?, graders?}
124
+ deployments: { method: "GET", path: "/api/h/:id/deployments" },
125
+ pinSession: { method: "POST", path: "/api/h/:id/sessions/:sess/pin", body: "PinSession" }, // {pinned}
126
+ pinBaseline: { method: "POST", path: "/api/h/:id/evals/baseline", body: "PinBaseline" }, // {runId}
127
+
128
+ // ---- M4: polish, fleet ops, notifications -----------------------------
129
+ // Real handlers, not stubs — so these carry NO `group` and are driven by
130
+ // the contract test like any M1/M2 route.
131
+ //
132
+ // `health` is the explained score (HM-11): never a bare number, always the
133
+ // deductions that produced it, each naming the tab that fixes it.
134
+ // `fleetHealth` runs a preflight per harness, so it is the "needs
135
+ // attention" board an operator opens — never the Library's first paint.
136
+ health: { method: "GET", path: "/api/h/:id/health" },
137
+ fleetHealth: { method: "GET", path: "/api/health" },
138
+
139
+ // First boot (HM-12). `demoInstall` copies a starter out of a LOCAL demos
140
+ // checkout; with none configured it answers 409 naming the repo, the env
141
+ // var and the CLI verb — it never fetches code from the network.
142
+ onboarding: { method: "GET", path: "/api/onboarding" },
143
+ demoInstall: { method: "POST", path: "/api/onboarding/demo", body: "DemoInstall" }, // {starter, dir}
144
+
145
+ // ⌘K (HM-189). `?q=` + optional `?limit=`. The response's `actions` are
146
+ // PROPOSALS carrying a route key and a CLI twin; the console executes them
147
+ // through the ordinary route after an explicit confirm, so an action is
148
+ // never a side effect of typing.
149
+ search: { method: "GET", path: "/api/search" },
150
+
151
+ // Notification rules (HM-183). The GET is the badge poll AND the
152
+ // evaluation pass (the manager runs no timer of its own).
153
+ notifications: { method: "GET", path: "/api/notifications" },
154
+ setNotifications: { method: "PUT", path: "/api/notifications", body: "NotificationRules" },
155
+ clearNotifications: { method: "POST", path: "/api/notifications/clear", body: "Empty" }, // {}
156
+
157
+ // Read-only mode (HM-187) — enforced SERVER-side ahead of every handler.
158
+ readOnly: { method: "GET", path: "/api/read-only" },
159
+ setReadOnly: { method: "PUT", path: "/api/read-only", body: "ReadOnly" }, // {enabled}
160
+
161
+ // Plugin SDK minimal wiring (HM-179): the inventory (which extension
162
+ // points are wired and which are declared-but-deferred), one pane document
163
+ // with the sandbox + CSP that must accompany it, and the panes a given
164
+ // harness shows after the fail-closed fs permission is evaluated.
165
+ plugins: { method: "GET", path: "/api/plugins" },
166
+ pluginPane: { method: "GET", path: "/api/plugins/:plugin/panes/:pane" },
167
+ panes: { method: "GET", path: "/api/h/:id/panes" },
168
+
169
+ // ======================================================================
170
+ // M3 — the detail surface. Every entry carries `group`.
171
+ // ======================================================================
172
+
173
+ // ---- group "spec": structured editing, trust tiers, versions, builders
174
+ // Writes go through @crewhaus/spec-patch `applySpecEdits`, never by
175
+ // templating YAML. Auto-tunable paths (OPTIMIZABLE_PATHS) apply directly;
176
+ // human-owned paths need the redacted diff + typed confirm, or `propose`.
177
+ specEdit: { method: "PUT", path: "/api/h/:id/spec", body: "SpecEdits", group: "spec" },
178
+ specPatch: { method: "POST", path: "/api/h/:id/spec/patch", body: "SpecPatch", group: "spec" },
179
+ specDiff: { method: "POST", path: "/api/h/:id/spec/diff", body: "SpecEdits", group: "spec" },
180
+ specSchema: { method: "GET", path: "/api/h/:id/spec/schema", group: "spec" },
181
+ specTrust: { method: "GET", path: "/api/h/:id/spec/trust", group: "spec" },
182
+ specVersions: { method: "GET", path: "/api/h/:id/spec/versions", group: "spec" },
183
+ specVersion: { method: "GET", path: "/api/h/:id/spec/versions/:version", group: "spec" },
184
+ specVersionDiff: { method: "GET", path: "/api/h/:id/spec/versions/:version/diff", group: "spec" },
185
+ specPin: { method: "POST", path: "/api/h/:id/spec/pin", body: "SpecPin", group: "spec" },
186
+ specRollback: {
187
+ method: "POST",
188
+ path: "/api/h/:id/spec/rollback",
189
+ body: "SpecRollback",
190
+ group: "spec",
191
+ },
192
+ specPropose: {
193
+ method: "POST",
194
+ path: "/api/h/:id/spec/propose",
195
+ body: "SpecPropose",
196
+ group: "spec",
197
+ },
198
+ wizardTemplates: { method: "GET", path: "/api/builders/templates", group: "spec" },
199
+ wizardCreate: { method: "POST", path: "/api/builders/spec", body: "WizardSpec", group: "spec" },
200
+ mcpCatalog: { method: "GET", path: "/api/builders/mcp-catalog", group: "spec" },
201
+ graderCatalog: { method: "GET", path: "/api/h/:id/builders/graders", group: "spec" },
202
+ graderWrite: {
203
+ method: "POST",
204
+ path: "/api/h/:id/builders/graders",
205
+ body: "GraderBuild",
206
+ group: "spec",
207
+ },
208
+ datasetBuilder: { method: "GET", path: "/api/h/:id/builders/dataset", group: "spec" },
209
+ datasetBuilderStep: {
210
+ method: "POST",
211
+ path: "/api/h/:id/builders/dataset",
212
+ body: "DatasetBuildStep",
213
+ group: "spec",
214
+ },
215
+ mcpConnectors: { method: "GET", path: "/api/h/:id/builders/mcp", group: "spec" },
216
+ mcpConnectorWrite: {
217
+ method: "POST",
218
+ path: "/api/h/:id/builders/mcp",
219
+ body: "McpConnector",
220
+ group: "spec",
221
+ },
222
+ mcpConnectorRemove: { method: "DELETE", path: "/api/h/:id/builders/mcp/:name", group: "spec" },
223
+
224
+ // ---- group "memory": facts, continuity, wiki, watchme, learning -------
225
+ // NO HARD DELETE ANYWHERE: forget = a supersede tombstone with a reason,
226
+ // continuity clear = trash/<ts>/ with restore, wiki = archived status.
227
+ memoryFacts: { method: "GET", path: "/api/h/:id/memory/facts/:spec", group: "memory" },
228
+ memoryForget: {
229
+ method: "POST",
230
+ path: "/api/h/:id/memory/facts/:spec/forget",
231
+ body: "MemoryForget",
232
+ group: "memory",
233
+ },
234
+ memorySweep: {
235
+ method: "POST",
236
+ path: "/api/h/:id/memory/facts/:spec/sweep",
237
+ body: "DryRun",
238
+ group: "memory",
239
+ },
240
+ memoryRecall: {
241
+ method: "POST",
242
+ path: "/api/h/:id/memory/recall",
243
+ body: "RecallQuery",
244
+ group: "memory",
245
+ },
246
+ memoryMigrate: {
247
+ method: "POST",
248
+ path: "/api/h/:id/memory/migrate",
249
+ body: "DryRun",
250
+ group: "memory",
251
+ },
252
+ continuity: { method: "GET", path: "/api/h/:id/memory/continuity", group: "memory" },
253
+ continuityTrash: { method: "GET", path: "/api/h/:id/memory/continuity/trash", group: "memory" },
254
+ continuityRestore: {
255
+ method: "POST",
256
+ path: "/api/h/:id/memory/continuity/restore",
257
+ body: "ContinuityRestore",
258
+ group: "memory",
259
+ },
260
+ learning: { method: "GET", path: "/api/h/:id/memory/learning", group: "memory" },
261
+ knowledge: { method: "GET", path: "/api/h/:id/memory/knowledge", group: "memory" },
262
+ knowledgeSync: {
263
+ method: "POST",
264
+ path: "/api/h/:id/memory/knowledge/sync",
265
+ body: "KnowledgeSync",
266
+ group: "memory",
267
+ },
268
+ dreamScaffold: { method: "GET", path: "/api/h/:id/memory/dream/scaffold", group: "memory" },
269
+ // Wiki writes carry `expectedVersion`; a stale version is a re-read-retry
270
+ // state, not an error — identical UX for the local and Thredz backends.
271
+ wikiWrite: {
272
+ method: "PUT",
273
+ path: "/api/h/:id/memory/wiki/:slug",
274
+ body: "WikiWrite",
275
+ group: "memory",
276
+ },
277
+ wikiVersions: { method: "GET", path: "/api/h/:id/memory/wiki/:slug/versions", group: "memory" },
278
+ wikiVersion: {
279
+ method: "GET",
280
+ path: "/api/h/:id/memory/wiki/:slug/versions/:version",
281
+ group: "memory",
282
+ },
283
+ wikiLinks: { method: "GET", path: "/api/h/:id/memory/wiki/:slug/links", group: "memory" },
284
+ wikiSignals: {
285
+ method: "POST",
286
+ path: "/api/h/:id/memory/wiki/:slug/signals",
287
+ body: "WikiSignals",
288
+ group: "memory",
289
+ },
290
+ wikiArchive: {
291
+ method: "POST",
292
+ path: "/api/h/:id/memory/wiki/:slug/archive",
293
+ body: "WikiArchive",
294
+ group: "memory",
295
+ },
296
+ wikiReflect: { method: "GET", path: "/api/h/:id/memory/reflect", group: "memory" },
297
+ watchmeAnalytics: { method: "GET", path: "/api/h/:id/memory/watchme/analytics", group: "memory" },
298
+ watchmeReports: { method: "GET", path: "/api/h/:id/memory/watchme/reports", group: "memory" },
299
+ watchmeReport: {
300
+ method: "GET",
301
+ path: "/api/h/:id/memory/watchme/reports/:stamp",
302
+ group: "memory",
303
+ },
304
+ watchmeIntents: { method: "GET", path: "/api/h/:id/memory/watchme/intents", group: "memory" },
305
+ watchmeToggle: {
306
+ method: "POST",
307
+ path: "/api/h/:id/memory/watchme/toggle",
308
+ body: "WatchmeToggle",
309
+ group: "memory",
310
+ },
311
+ watchmeSynthesized: {
312
+ method: "GET",
313
+ path: "/api/h/:id/memory/watchme/synthesized",
314
+ group: "memory",
315
+ },
316
+ watchmeApply: {
317
+ method: "POST",
318
+ path: "/api/h/:id/memory/watchme/synthesized/:stamp/apply",
319
+ body: "SynthesizeApply",
320
+ group: "memory",
321
+ },
322
+ watchmePublish: {
323
+ method: "POST",
324
+ path: "/api/h/:id/memory/watchme/publish",
325
+ body: "DryRun",
326
+ group: "memory",
327
+ },
328
+
329
+ // ---- group "evals": the quality lab -----------------------------------
330
+ evalLaunch: { method: "POST", path: "/api/h/:id/evals/run", body: "EvalLaunch", group: "evals" },
331
+ evalMatrix: { method: "GET", path: "/api/h/:id/evals/matrix", group: "evals" },
332
+ evalMatrixCell: { method: "GET", path: "/api/h/:id/evals/matrix/:cell", group: "evals" },
333
+ evalSuites: { method: "GET", path: "/api/h/:id/evals/suites", group: "evals" },
334
+ evalSuiteRun: {
335
+ method: "POST",
336
+ path: "/api/h/:id/evals/suites",
337
+ body: "SuiteRun",
338
+ group: "evals",
339
+ },
340
+ evalTrends: { method: "GET", path: "/api/h/:id/evals/trends", group: "evals" },
341
+ evalPlan: { method: "POST", path: "/api/h/:id/evals/plan", body: "EvalPlan", group: "evals" },
342
+ judgeCalibration: { method: "GET", path: "/api/h/:id/evals/judge", group: "evals" },
343
+ judgeCalibrate: {
344
+ method: "POST",
345
+ path: "/api/h/:id/evals/judge",
346
+ body: "JudgeCalibrate",
347
+ group: "evals",
348
+ },
349
+ graderCards: { method: "GET", path: "/api/h/:id/evals/graders", group: "evals" },
350
+ gradersSuggest: {
351
+ method: "POST",
352
+ path: "/api/h/:id/evals/graders/suggest",
353
+ body: "Empty",
354
+ group: "evals",
355
+ },
356
+ gradersTest: {
357
+ method: "POST",
358
+ path: "/api/h/:id/evals/graders/test",
359
+ body: "GradersTest",
360
+ group: "evals",
361
+ },
362
+ redteam: { method: "GET", path: "/api/h/:id/evals/redteam", group: "evals" },
363
+ redteamGenerate: {
364
+ method: "POST",
365
+ path: "/api/h/:id/evals/redteam",
366
+ body: "RedteamGenerate",
367
+ group: "evals",
368
+ },
369
+ evalCoverage: { method: "GET", path: "/api/h/:id/evals/coverage", group: "evals" },
370
+ sentinel: { method: "GET", path: "/api/h/:id/evals/sentinel", group: "evals" },
371
+ sentinelRun: {
372
+ method: "POST",
373
+ path: "/api/h/:id/evals/sentinel",
374
+ body: "SentinelRun",
375
+ group: "evals",
376
+ },
377
+ voiceEvals: { method: "GET", path: "/api/h/:id/evals/voice", group: "evals" },
378
+ optimizer: { method: "GET", path: "/api/h/:id/evals/optimize", group: "evals" },
379
+ optimizerRun: {
380
+ method: "POST",
381
+ path: "/api/h/:id/evals/optimize",
382
+ body: "OptimizeRun",
383
+ group: "evals",
384
+ },
385
+ optimizerArtifacts: {
386
+ method: "GET",
387
+ path: "/api/h/:id/evals/optimize/:optRunId",
388
+ group: "evals",
389
+ },
390
+ flywheel: { method: "GET", path: "/api/h/:id/evals/flywheel", group: "evals" },
391
+ flywheelRun: {
392
+ method: "POST",
393
+ path: "/api/h/:id/evals/flywheel",
394
+ body: "FlywheelRun",
395
+ group: "evals",
396
+ },
397
+ experiments: { method: "GET", path: "/api/h/:id/evals/experiments", group: "evals" },
398
+ experimentRecord: {
399
+ method: "POST",
400
+ path: "/api/h/:id/evals/experiments",
401
+ body: "ExperimentRecord",
402
+ group: "evals",
403
+ },
404
+ annotations: { method: "GET", path: "/api/h/:id/evals/annotations", group: "evals" },
405
+ annotateSample: {
406
+ method: "POST",
407
+ path: "/api/h/:id/evals/:runId/:sampleId/annotate",
408
+ body: "SampleAnnotation",
409
+ group: "evals",
410
+ },
411
+
412
+ // ---- group "data": the dataset registry, hygiene, growth ---------------
413
+ // The dataset NAME travels in the BODY for every write: an operator-chosen
414
+ // string must not sit where a literal route keyword lives.
415
+ datasets: { method: "GET", path: "/api/h/:id/data/datasets", group: "data" },
416
+ dataset: { method: "GET", path: "/api/h/:id/data/datasets/:name", group: "data" },
417
+ datasetStatus: { method: "GET", path: "/api/h/:id/data/status", group: "data" },
418
+ datasetQuarantine: { method: "GET", path: "/api/h/:id/data/quarantine", group: "data" },
419
+ datasetVerify: {
420
+ method: "POST",
421
+ path: "/api/h/:id/data/verify",
422
+ body: "DatasetName",
423
+ group: "data",
424
+ },
425
+ datasetAudit: { method: "POST", path: "/api/h/:id/data/audit", body: "Empty", group: "data" },
426
+ datasetLint: { method: "POST", path: "/api/h/:id/data/lint", body: "Empty", group: "data" },
427
+ datasetMine: { method: "POST", path: "/api/h/:id/data/mine", body: "DatasetMine", group: "data" },
428
+ datasetSynthesize: {
429
+ method: "POST",
430
+ path: "/api/h/:id/data/synthesize",
431
+ body: "DatasetSynthesize",
432
+ group: "data",
433
+ },
434
+ datasetRefreshGoldens: {
435
+ method: "POST",
436
+ path: "/api/h/:id/data/refresh-goldens",
437
+ body: "DatasetName",
438
+ group: "data",
439
+ },
440
+
441
+ // ---- group "feedback": the growth loops --------------------------------
442
+ feedback: { method: "GET", path: "/api/h/:id/feedback", group: "feedback" },
443
+ distillRun: {
444
+ method: "POST",
445
+ path: "/api/h/:id/feedback/distill",
446
+ body: "DistillRun",
447
+ group: "feedback",
448
+ },
449
+ fewshot: { method: "GET", path: "/api/h/:id/feedback/fewshot", group: "feedback" },
450
+ fewshotHarvest: {
451
+ method: "POST",
452
+ path: "/api/h/:id/feedback/fewshot",
453
+ body: "Empty",
454
+ group: "feedback",
455
+ },
456
+ faq: { method: "GET", path: "/api/h/:id/feedback/faq", group: "feedback" },
457
+ faqDistill: { method: "POST", path: "/api/h/:id/feedback/faq", body: "Empty", group: "feedback" },
458
+ lessons: { method: "GET", path: "/api/h/:id/feedback/lessons", group: "feedback" },
459
+ lessonsUpdate: {
460
+ method: "POST",
461
+ path: "/api/h/:id/feedback/lessons",
462
+ body: "Empty",
463
+ group: "feedback",
464
+ },
465
+ advice: { method: "GET", path: "/api/h/:id/feedback/advice", group: "feedback" },
466
+ adviceRun: {
467
+ method: "POST",
468
+ path: "/api/h/:id/feedback/advice",
469
+ body: "AdviseRun",
470
+ group: "feedback",
471
+ },
472
+ adviceApply: {
473
+ method: "POST",
474
+ path: "/api/h/:id/feedback/advice/:adviceId/apply",
475
+ body: "Confirm",
476
+ group: "feedback",
477
+ },
478
+ reactions: { method: "GET", path: "/api/h/:id/feedback/reactions", group: "feedback" },
479
+ feedbackFleet: { method: "GET", path: "/api/feedback", group: "feedback" },
480
+
481
+ // ---- group "creds": env, the fleet matrix, doctor, secrets, mcp lint ---
482
+ // Values go IN and never come back: every read here is presence booleans.
483
+ env: { method: "GET", path: "/api/h/:id/env", group: "creds" },
484
+ envSet: { method: "POST", path: "/api/h/:id/env", body: "EnvSet", group: "creds" },
485
+ envUnset: { method: "DELETE", path: "/api/h/:id/env/:key", group: "creds" },
486
+ credentialsMatrix: { method: "GET", path: "/api/credentials", group: "creds" },
487
+ credentialsSetAcross: {
488
+ method: "POST",
489
+ path: "/api/credentials/set",
490
+ body: "SetAcross",
491
+ group: "creds",
492
+ },
493
+ doctor: { method: "GET", path: "/api/h/:id/doctor", group: "creds" },
494
+ doctorRun: { method: "POST", path: "/api/h/:id/doctor", body: "DoctorRun", group: "creds" },
495
+ secrets: { method: "GET", path: "/api/h/:id/secrets", group: "creds" },
496
+ secretsDoctor: { method: "GET", path: "/api/h/:id/secrets/doctor", group: "creds" },
497
+ secretsRotate: {
498
+ method: "POST",
499
+ path: "/api/h/:id/secrets/:name/rotate",
500
+ body: "SecretRotate",
501
+ group: "creds",
502
+ },
503
+ mcpLint: { method: "GET", path: "/api/h/:id/mcp/lint", group: "creds" },
504
+
505
+ // ---- group "channels": provisioning, verify, the two test tiers --------
506
+ channels: { method: "GET", path: "/api/h/:id/channels", group: "channels" },
507
+ channelVerify: {
508
+ method: "POST",
509
+ path: "/api/h/:id/channels/verify",
510
+ body: "ChannelVerify",
511
+ group: "channels",
512
+ },
513
+ channelProvision: {
514
+ method: "GET",
515
+ path: "/api/h/:id/channels/:channel/provision",
516
+ group: "channels",
517
+ },
518
+ channelProvisionRun: {
519
+ method: "POST",
520
+ path: "/api/h/:id/channels/:channel/provision",
521
+ body: "Confirm",
522
+ group: "channels",
523
+ },
524
+ channelProbe: {
525
+ method: "POST",
526
+ path: "/api/h/:id/channels/:channel/probe",
527
+ body: "Empty",
528
+ group: "channels",
529
+ },
530
+ // Tier 2 — Slack/Telegram/WhatsApp only. Discord is asymmetric Ed25519
531
+ // (the harness holds only the public key), iMessage has no inbound webhook.
532
+ channelSynthetic: {
533
+ method: "POST",
534
+ path: "/api/h/:id/channels/:channel/synthetic",
535
+ body: "SyntheticInbound",
536
+ group: "channels",
537
+ },
538
+ gateway: { method: "GET", path: "/api/h/:id/gateway", group: "channels" },
539
+
540
+ // ---- group "security": audit, egress, pii, onchain, retention, slo -----
541
+ audit: { method: "GET", path: "/api/h/:id/audit", group: "security" },
542
+ auditVerify: {
543
+ method: "POST",
544
+ path: "/api/h/:id/audit/verify",
545
+ body: "Empty",
546
+ group: "security",
547
+ },
548
+ egress: { method: "GET", path: "/api/h/:id/security/egress", group: "security" },
549
+ egressReview: {
550
+ method: "POST",
551
+ path: "/api/h/:id/security/egress/:decisionId",
552
+ body: "EgressVerdict",
553
+ group: "security",
554
+ },
555
+ pii: { method: "GET", path: "/api/h/:id/security/pii", group: "security" },
556
+ piiTune: {
557
+ method: "POST",
558
+ path: "/api/h/:id/security/pii",
559
+ body: "PolicyTune",
560
+ group: "security",
561
+ },
562
+ justification: { method: "GET", path: "/api/h/:id/security/justification", group: "security" },
563
+ justificationCalibrate: {
564
+ method: "POST",
565
+ path: "/api/h/:id/security/justification/calibrate",
566
+ body: "Calibrate",
567
+ group: "security",
568
+ },
569
+ justificationPreflight: {
570
+ method: "POST",
571
+ path: "/api/h/:id/security/justification/preflight",
572
+ body: "JustificationPreflight",
573
+ group: "security",
574
+ },
575
+ securityCorpus: { method: "GET", path: "/api/h/:id/security/corpus", group: "security" },
576
+ securityCorpusCheck: {
577
+ method: "POST",
578
+ path: "/api/h/:id/security/corpus",
579
+ body: "Empty",
580
+ group: "security",
581
+ },
582
+ sandboxDoctor: { method: "GET", path: "/api/h/:id/security/sandbox", group: "security" },
583
+ onchain: { method: "GET", path: "/api/h/:id/security/onchain", group: "security" },
584
+ onchainTune: {
585
+ method: "POST",
586
+ path: "/api/h/:id/security/onchain/tune",
587
+ body: "PolicyTune",
588
+ group: "security",
589
+ },
590
+ onchainSentinel: {
591
+ method: "GET",
592
+ path: "/api/h/:id/security/onchain/sentinel",
593
+ group: "security",
594
+ },
595
+ compliance: { method: "GET", path: "/api/h/:id/security/compliance", group: "security" },
596
+ complianceEvidence: {
597
+ method: "POST",
598
+ path: "/api/h/:id/security/compliance",
599
+ body: "ComplianceEvidence",
600
+ group: "security",
601
+ },
602
+ retention: { method: "GET", path: "/api/h/:id/security/retention", group: "security" },
603
+ // Dry-run first, then typed-confirm — the top rung of the ladder.
604
+ retentionSweep: {
605
+ method: "POST",
606
+ path: "/api/h/:id/security/retention/sweep",
607
+ body: "RetentionRun",
608
+ group: "security",
609
+ },
610
+ retentionPurge: {
611
+ method: "POST",
612
+ path: "/api/h/:id/security/retention/purge",
613
+ body: "RetentionRun",
614
+ group: "security",
615
+ },
616
+ slo: { method: "GET", path: "/api/h/:id/slo", group: "security" },
617
+
618
+ // ---- group "thredz": the server-side proxied explorer ------------------
619
+ // The API key lives in the harness and is read server-side per request.
620
+ // It never reaches this file, this browser, or manager state.
621
+ thredzStatus: { method: "GET", path: "/api/h/:id/thredz", group: "thredz" },
622
+ thredzWiki: { method: "GET", path: "/api/h/:id/thredz/wiki", group: "thredz" },
623
+ thredzWikiArticle: { method: "GET", path: "/api/h/:id/thredz/wiki/:slug", group: "thredz" },
624
+ thredzWikiWrite: {
625
+ method: "PUT",
626
+ path: "/api/h/:id/thredz/wiki/:slug",
627
+ body: "ThredzWikiWrite",
628
+ group: "thredz",
629
+ },
630
+ thredzWikiVersions: {
631
+ method: "GET",
632
+ path: "/api/h/:id/thredz/wiki/:slug/versions",
633
+ group: "thredz",
634
+ },
635
+ thredzWikiRollback: {
636
+ method: "POST",
637
+ path: "/api/h/:id/thredz/wiki/:slug/rollback",
638
+ body: "ThredzRollback",
639
+ group: "thredz",
640
+ },
641
+ thredzRecords: { method: "GET", path: "/api/h/:id/thredz/records", group: "thredz" },
642
+ thredzRecordCreate: {
643
+ method: "POST",
644
+ path: "/api/h/:id/thredz/records",
645
+ body: "ThredzRecord",
646
+ group: "thredz",
647
+ },
648
+ thredzRecord: { method: "GET", path: "/api/h/:id/thredz/records/:recordId", group: "thredz" },
649
+ // Soft delete only — every Thredz delete has a restore.
650
+ thredzRecordDelete: {
651
+ method: "DELETE",
652
+ path: "/api/h/:id/thredz/records/:recordId",
653
+ group: "thredz",
654
+ },
655
+ thredzRecordRestore: {
656
+ method: "POST",
657
+ path: "/api/h/:id/thredz/records/:recordId/restore",
658
+ body: "Empty",
659
+ group: "thredz",
660
+ },
661
+ thredzSchemas: { method: "GET", path: "/api/h/:id/thredz/schemas", group: "thredz" },
662
+ thredzGoals: { method: "GET", path: "/api/h/:id/thredz/goals", group: "thredz" },
663
+ thredzTasks: { method: "GET", path: "/api/h/:id/thredz/tasks", group: "thredz" },
664
+ thredzTaskUpdate: {
665
+ method: "POST",
666
+ path: "/api/h/:id/thredz/tasks/:taskId",
667
+ body: "ThredzTaskUpdate",
668
+ group: "thredz",
669
+ },
670
+ thredzViews: { method: "GET", path: "/api/h/:id/thredz/views", group: "thredz" },
671
+ thredzViewExecute: {
672
+ method: "POST",
673
+ path: "/api/h/:id/thredz/views/:viewId/execute",
674
+ body: "ThredzViewExec",
675
+ group: "thredz",
676
+ },
677
+ thredzDashboards: { method: "GET", path: "/api/h/:id/thredz/dashboards", group: "thredz" },
678
+ thredzDashboard: {
679
+ method: "GET",
680
+ path: "/api/h/:id/thredz/dashboards/:dashboardId",
681
+ group: "thredz",
682
+ },
683
+ thredzCardCreate: {
684
+ method: "POST",
685
+ path: "/api/h/:id/thredz/dashboards/:dashboardId/cards",
686
+ body: "ThredzCard",
687
+ group: "thredz",
688
+ },
689
+ thredzListeners: { method: "GET", path: "/api/h/:id/thredz/listeners", group: "thredz" },
690
+ thredzListenerCreate: {
691
+ method: "POST",
692
+ path: "/api/h/:id/thredz/listeners",
693
+ body: "ThredzListener",
694
+ group: "thredz",
695
+ },
696
+ thredzWebhooks: { method: "GET", path: "/api/h/:id/thredz/webhooks", group: "thredz" },
697
+ thredzConnectors: { method: "GET", path: "/api/h/:id/thredz/connectors", group: "thredz" },
698
+ thredzActivity: { method: "GET", path: "/api/h/:id/thredz/activity", group: "thredz" },
699
+ thredzTraverse: {
700
+ method: "POST",
701
+ path: "/api/h/:id/thredz/traverse",
702
+ body: "ThredzTraverse",
703
+ group: "thredz",
704
+ },
705
+ thredzKeys: { method: "GET", path: "/api/h/:id/thredz/keys", group: "thredz" },
706
+ thredzKeyCreate: {
707
+ method: "POST",
708
+ path: "/api/h/:id/thredz/keys",
709
+ body: "ThredzKeyCreate",
710
+ group: "thredz",
711
+ },
712
+ thredzKeyRotate: {
713
+ method: "POST",
714
+ path: "/api/h/:id/thredz/keys/:keyId/rotate",
715
+ body: "Confirm",
716
+ group: "thredz",
717
+ },
718
+ thredzGlobal: { method: "GET", path: "/api/thredz", group: "thredz" },
719
+
720
+ // ---- group "inspect": the raw browsers + settings.json -----------------
721
+ // secrets/, the raw audit files and .env are excluded server-side.
722
+ inspectIndex: { method: "GET", path: "/api/h/:id/inspect", group: "inspect" },
723
+ inspectRaw: { method: "GET", path: "/api/h/:id/inspect/raw", group: "inspect" }, // ?path=
724
+ inspectStore: { method: "GET", path: "/api/h/:id/inspect/:store", group: "inspect" },
725
+ inspectEntry: { method: "GET", path: "/api/h/:id/inspect/:store/:name", group: "inspect" },
726
+ settingsWrite: {
727
+ method: "PUT",
728
+ path: "/api/h/:id/inspect/settings",
729
+ body: "SettingsWrite",
730
+ group: "inspect",
731
+ },
732
+
733
+ // ---- group "runtime": the mcp-server + dev run classes -----------------
734
+ // Both are PROCESSES: they get runfiles, ledger rows and ledger-claimed
735
+ // ports, and their live output rides the ONE existing SSE feed
736
+ // (`runEvents`) — M3 adds no second streaming mechanism.
737
+ mcpServers: { method: "GET", path: "/api/h/:id/mcp-servers", group: "runtime" },
738
+ mcpServerStart: {
739
+ method: "POST",
740
+ path: "/api/h/:id/mcp-servers/start",
741
+ body: "McpServerStart",
742
+ group: "runtime",
743
+ },
744
+ mcpServerStop: {
745
+ method: "POST",
746
+ path: "/api/h/:id/mcp-servers/stop",
747
+ body: "Empty",
748
+ group: "runtime",
749
+ },
750
+ dev: { method: "GET", path: "/api/h/:id/dev", group: "runtime" },
751
+ devStart: { method: "POST", path: "/api/h/:id/dev/start", body: "DevStart", group: "runtime" },
752
+ devStop: { method: "POST", path: "/api/h/:id/dev/stop", body: "Empty", group: "runtime" },
753
+ };
754
+
755
+ /** The M3 area groups, in tab order. A route's `group` names the module that
756
+ * owns it on the server and the view that renders it in the console. */
757
+ export const M3_GROUPS = [
758
+ "spec",
759
+ "memory",
760
+ "evals",
761
+ "data",
762
+ "feedback",
763
+ "creds",
764
+ "channels",
765
+ "security",
766
+ "thredz",
767
+ "inspect",
768
+ "runtime",
769
+ ];
770
+
771
+ /** Every route key belonging to `group`, in map order. The views render
772
+ * their own surface from this while the handlers are still stubs, so a
773
+ * "not built yet" screen still tells the truth about what is coming. */
774
+ export function routeKeysInGroup(group) {
775
+ return Object.keys(ROUTES).filter((key) => ROUTES[key].group === group);
776
+ }
777
+
778
+ /** Every M3 route key (any route carrying a `group`). */
779
+ export function m3RouteKeys() {
780
+ return Object.keys(ROUTES).filter((key) => typeof ROUTES[key].group === "string");
781
+ }
782
+
783
+ /** Fill a path template's `:name` segments from `params`, URI-encoding each
784
+ * value. Throws on a missing param — a build bug, never a user input. */
785
+ export function buildPath(template, params = {}) {
786
+ return template.replace(/:([A-Za-z][A-Za-z0-9]*)/g, (_match, name) => {
787
+ const value = params[name];
788
+ if (value === undefined || value === null) {
789
+ throw new Error(`missing route param "${name}" for ${template}`);
790
+ }
791
+ return encodeURIComponent(String(value));
792
+ });
793
+ }