omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * The fleet dashboard server (epic #292, slice 1/5): a separate `Bun.serve`
3
+ * process that renders the fleet in a browser.
4
+ *
5
+ * Deliberately NOT a route on the daemon port: that surface is unauthenticated
6
+ * and loopback-only by design, and `verbs/server.test.ts` pins every non-daemon
7
+ * path (and every mutating method) to 404. The dashboard is its own process
8
+ * with its own bearer token, and it only ever *reads* fleet state — it never
9
+ * starts, stops or mutates a daemon.
10
+ *
11
+ * The token is minted once at `<stateDir()>/dashboard-token` — the same
12
+ * 0700-dir / 0600-file atomic discipline `writeConfigRaw` uses — and reused on
13
+ * every later start. Every `/api/*` request must present it as
14
+ * `Authorization: Bearer <token>`; anything else is a bare 401 with no body
15
+ * detail. Static assets ship on disk next to this module and are served
16
+ * unauthenticated; the UI asks for the token once and keeps it in
17
+ * localStorage.
18
+ *
19
+ * Project liveness is the one membership convention `status` and `board`
20
+ * already share (#379): `livingDaemon()` + `healthCheck(port)`, classified by
21
+ * `classifyDaemonProjectHealth`. A project whose record is absent, whose
22
+ * daemon will not answer `/healthz`, or whose record is pinned to another
23
+ * project renders as down — never as an empty healthy fleet.
24
+ */
25
+
26
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { loadConfig, stateDir } from "../config.ts";
30
+ import { healthCheck, livingDaemon, type DaemonRecord } from "../lifecycle.ts";
31
+ import { classifyDaemonProjectHealth, fleetLayers, type FleetLayers } from "../fleet.ts";
32
+ import { statusSnapshot, type StatusSnapshot } from "../daemon.ts";
33
+ import { boardJson, boardSnapshotOnce } from "../board.ts";
34
+ import { dbPath, openStore } from "../store.ts";
35
+ import type { ProjectConfig } from "../types.ts";
36
+ import type { BaseHealth, DigestBacklog, HeldNotice, ReportRecord, RunRecord, TurnOverride, VerbLedgerEntry } from "../types.ts";
37
+
38
+ /** The dashboard's default bind address — loopback, like the daemon port. */
39
+ export const DASHBOARD_HOST = "127.0.0.1";
40
+ /** The dashboard's default port. Away from the daemon's 8787 and the verbs. */
41
+ export const DASHBOARD_PORT = 8788;
42
+
43
+ const TOKEN_FILE = "dashboard-token";
44
+
45
+ /** Where the bearer token lives, next to config.json under `$OMP_CONDUCTOR_HOME`. */
46
+ export function tokenPath(): string {
47
+ return join(stateDir(), TOKEN_FILE);
48
+ }
49
+
50
+ /** The stored token, or undefined when the dashboard has never been started. */
51
+ export function readDashboardToken(): string | undefined {
52
+ try {
53
+ const raw = readFileSync(tokenPath(), "utf8").trim();
54
+ return raw.length > 0 ? raw : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function hashToken(value: string): Buffer {
61
+ return createHash("sha256").update(value).digest();
62
+ }
63
+
64
+ /**
65
+ * Constant-time bearer check. Both sides are hashed to fixed length first so a
66
+ * wrong-length candidate cannot leak the stored token's length through the
67
+ * compare — the arm-challenge spirit: verify by hash, never by a plaintext
68
+ * comparison that leaks shape in timing.
69
+ */
70
+ export function verifyDashboardToken(presented: string | undefined, stored: string): boolean {
71
+ if (presented === undefined || presented.length === 0) return false;
72
+ return timingSafeEqual(hashToken(presented), hashToken(stored));
73
+ }
74
+
75
+ /**
76
+ * Mints `<stateDir()>/dashboard-token` on first start; later starts reuse the
77
+ * existing token. The write discipline is `writeConfigRaw`'s: the state dir is
78
+ * created 0700 (chmod only what this call created, so an operator-chosen mode
79
+ * survives), the file is written 0600 through a same-directory temp file and
80
+ * an atomic rename so a crash mid-write leaves the previous token intact
81
+ * rather than a truncated one.
82
+ */
83
+ export function ensureDashboardToken(): string {
84
+ const existing = readDashboardToken();
85
+ if (existing !== undefined) return existing;
86
+
87
+ const token = randomBytes(32).toString("base64url");
88
+ const dir = stateDir();
89
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ // mkdir's mode is masked by umask; chmod only what we just created so an
91
+ // existing directory keeps whatever the user chose for it.
92
+ if (created !== undefined) chmodSync(dir, 0o700);
93
+
94
+ const target = tokenPath();
95
+ const tmp = join(dir, `.dashboard-token.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
96
+ try {
97
+ writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
98
+ renameSync(tmp, target);
99
+ } catch (err) {
100
+ rmSync(tmp, { force: true });
101
+ throw err;
102
+ }
103
+ // The rename preserves the temp's 0600; state it anyway so the promise never
104
+ // leans on umask moods.
105
+ chmodSync(target, 0o600);
106
+ return token;
107
+ }
108
+
109
+ /** One configured project as `/api/projects` reports it. */
110
+ export interface DashboardProjectView {
111
+ name: string;
112
+ /** `tracker.repo` — the issue source the project dispatches. */
113
+ repo: string;
114
+ daemon: {
115
+ /** The shared verdict every viewer uses (#379). */
116
+ state: "ok" | "stopped" | "unreachable" | "other-project";
117
+ /** The living daemon's port when a record exists; null when there is none. */
118
+ port: number | null;
119
+ /** The raw `/healthz` body, parsed, when this project's daemon answered it. */
120
+ healthz?: unknown;
121
+ /** Which project a foreign living record is pinned to, when state is other-project. */
122
+ serves?: string;
123
+ };
124
+ }
125
+
126
+ /**
127
+ * One project against a record already read for the request. The record is
128
+ * shared across projects so one pidfile read serves one whole request.
129
+ */
130
+ async function probeProject(project: ProjectConfig, record: DaemonRecord | undefined): Promise<DashboardProjectView> {
131
+ // The status/board gate (#379): never health-check a record pinned to
132
+ // another project — its `/healthz` would describe a daemon that does not
133
+ // serve this one, and mixing the two is how an empty fleet reads as healthy.
134
+ const pinnedElsewhere =
135
+ record !== undefined && record.project !== undefined && record.project !== project.name;
136
+ const health =
137
+ record !== undefined && !pinnedElsewhere ? await healthCheck(record.port) : undefined;
138
+ const classified = classifyDaemonProjectHealth(record, health, project.name);
139
+
140
+ const view: DashboardProjectView = {
141
+ name: project.name,
142
+ repo: project.tracker.repo,
143
+ daemon: { state: classified.kind, port: record?.port ?? null },
144
+ };
145
+ if (classified.kind === "ok") {
146
+ let healthz: unknown;
147
+ try {
148
+ healthz = JSON.parse(health?.body ?? "null") as unknown;
149
+ } catch {
150
+ healthz = undefined;
151
+ }
152
+ view.daemon.healthz = healthz;
153
+ } else if (classified.kind === "other-project") {
154
+ view.daemon.serves = classified.serves;
155
+ }
156
+ return view;
157
+ }
158
+
159
+ /**
160
+ * Every configured project, annotated for the dashboard. Read-only: never
161
+ * starts a daemon, never touches a pidfile beyond `livingDaemon()`'s read.
162
+ */
163
+ export async function dashboardProjects(): Promise<DashboardProjectView[]> {
164
+ const cfg = loadConfig();
165
+ const record = livingDaemon();
166
+ return Promise.all(cfg.projects.map((project) => probeProject(project, record)));
167
+ }
168
+
169
+ /**
170
+ * One project on the fleet-overview row (epic #292, slice 2/5). Every field is
171
+ * the existing producer's own number — `statusSnapshot` for workers, spend and
172
+ * base health, `fleetLayers` for the dispatch verdict — so a row can never
173
+ * disagree with `omp-conductor status` read at the same moment.
174
+ */
175
+ export interface FleetOverviewRow {
176
+ name: string;
177
+ repo: string;
178
+ /** The same shared daemon-health verdict `/api/projects` reports (#379). */
179
+ daemon: DashboardProjectView["daemon"];
180
+ /** The dispatch layer: `stopped` | `paused` | `running`. */
181
+ dispatch: FleetLayers["dispatch"];
182
+ liveWorkers: number;
183
+ spendTodayUsd: number;
184
+ baseHealth: BaseHealth[];
185
+ /**
186
+ * True when the daemon this project is pinned to is unreachable or serves
187
+ * another project. The UI renders DEGRADED instead of a fabricated healthy
188
+ * empty row — never zeros that read as a working fleet.
189
+ */
190
+ degraded: boolean;
191
+ }
192
+
193
+ /**
194
+ * Every configured project as a fleet-overview row. Reuses `dashboardProjects`
195
+ * for the membership verdict (one pidfile + one `/healthz` read serves the
196
+ * whole call), then `statusSnapshot` and `fleetLayers` per project for the
197
+ * numbers the `status` CLI renders from the very same producers.
198
+ */
199
+ export async function fleetOverview(): Promise<FleetOverviewRow[]> {
200
+ const views = await dashboardProjects();
201
+ return views.map((view) => {
202
+ const snap = statusSnapshot(view.name);
203
+ const layers = fleetLayers(view.name);
204
+ return {
205
+ name: view.name,
206
+ repo: view.repo,
207
+ daemon: view.daemon,
208
+ dispatch: layers.dispatch,
209
+ liveWorkers: snap.liveWorkers,
210
+ spendTodayUsd: snap.spendTodayUsd,
211
+ baseHealth: snap.baseHealth,
212
+ degraded: view.daemon.state === "unreachable" || view.daemon.state === "other-project",
213
+ };
214
+ });
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // The read-only monitoring surface (epic #292, slice 2/5). Each handler hands
219
+ // the existing CLI producer its arguments and returns what it already computes
220
+ // verbatim — nothing here re-derives a lane, a spend figure or a hold.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** `/api/projects/:name/board` — the exact lanes `omp-conductor board --json`
224
+ * prints for the same project at the same moment, parsed so the UI consumes
225
+ * them as an object. */
226
+ async function boardProducer(name: string): Promise<unknown> {
227
+ const snapshot = await boardSnapshotOnce(name);
228
+ return JSON.parse(boardJson(snapshot)) as unknown;
229
+ }
230
+
231
+ /** `/api/projects/:name/status` — the `StatusSnapshot` plus the `fleetLayers`
232
+ * verdict, structured (not the rendered text). */
233
+ async function statusProducer(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }> {
234
+ return { status: statusSnapshot(name), fleetLayers: fleetLayers(name) };
235
+ }
236
+
237
+ /** `/api/projects/:name/ledger` — the verb ledger + turn-override ledger, with
238
+ * the CLI's own defaults (limit 50) so the two cannot disagree. */
239
+ async function ledgerProducer(
240
+ name: string,
241
+ opts: { issue?: number; limit: number },
242
+ ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }> {
243
+ const store = openStore(dbPath());
244
+ try {
245
+ return {
246
+ verb: store.verbLedger(name, opts),
247
+ turnOverrides: store.turnOverrideLedger(name, opts),
248
+ };
249
+ } finally {
250
+ store.close();
251
+ }
252
+ }
253
+
254
+ /** `/api/projects/:name/reports` — the store readers `status` already uses:
255
+ * open reports, the digest backlog, and the held notices awaiting a digest. */
256
+ async function reportsProducer(
257
+ name: string,
258
+ ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }> {
259
+ const store = openStore(dbPath());
260
+ try {
261
+ return {
262
+ openReports: store.openReports(name),
263
+ digestBacklog: store.digestBacklog(name),
264
+ heldNotices: store.undigestedNotices(name),
265
+ };
266
+ } finally {
267
+ store.close();
268
+ }
269
+ }
270
+
271
+ /** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts,
272
+ * states, spend, failure classes, PR URLs. */
273
+ async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> {
274
+ const store = openStore(dbPath());
275
+ try {
276
+ return { runs: store.runsForIssue(name, issue) };
277
+ } finally {
278
+ store.close();
279
+ }
280
+ }
281
+
282
+ /**
283
+ * The deps a live dashboard is wired with. Every endpoint defaults to the real
284
+ * producer above; a test overrides only the slice it exercises.
285
+ */
286
+ export interface DashboardHttpDeps {
287
+ /** The bearer token every `/api/*` request must carry. */
288
+ token: string;
289
+ /** The `/api/projects` payload. */
290
+ projects(): Promise<DashboardProjectView[]>;
291
+ /** The `/api/overview` fleet rows. */
292
+ overview(): Promise<FleetOverviewRow[]>;
293
+ board(name: string): Promise<unknown>;
294
+ status(name: string): Promise<{ status: StatusSnapshot; fleetLayers: FleetLayers }>;
295
+ ledger(
296
+ name: string,
297
+ opts: { issue?: number; limit: number },
298
+ ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>;
299
+ reports(
300
+ name: string,
301
+ ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }>;
302
+ runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>;
303
+ }
304
+
305
+ /** The deps bound to the real producers — what `startDashboard` serves with. */
306
+ export function defaultDashboardDeps(token: string): DashboardHttpDeps {
307
+ return {
308
+ token,
309
+ projects: dashboardProjects,
310
+ overview: fleetOverview,
311
+ board: boardProducer,
312
+ status: statusProducer,
313
+ ledger: ledgerProducer,
314
+ reports: reportsProducer,
315
+ runs: runsProducer,
316
+ };
317
+ }
318
+
319
+ const STATIC_FILES: Record<string, string> = {
320
+ "/": "index.html",
321
+ "/index.html": "index.html",
322
+ "/app.js": "app.js",
323
+ "/style.css": "style.css",
324
+ };
325
+
326
+ const STATIC_MIME: Record<string, string> = {
327
+ ".html": "text/html; charset=utf-8",
328
+ ".js": "text/javascript; charset=utf-8",
329
+ ".css": "text/css; charset=utf-8",
330
+ };
331
+
332
+ /** The value of `Authorization: Bearer <token>`, or undefined for any other form. */
333
+ function bearerToken(req: Request): string | undefined {
334
+ const header = req.headers.get("authorization");
335
+ if (header === null) return undefined;
336
+ const prefix = "Bearer ";
337
+ return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
338
+ }
339
+
340
+ /**
341
+ * The ledger's query params. `limit` defaults to 50 — the CLI's own default —
342
+ * so the endpoint and `omp-conductor ledger` cannot disagree. An explicit
343
+ * non-positive or non-integral limit or issue is a client error (400), never a
344
+ * silent fallback that hides a typo'd URL.
345
+ */
346
+ function parseLedgerParams(url: URL): { issue?: number; limit: number } | null {
347
+ const issueParam = url.searchParams.get("issue");
348
+ const limitParam = url.searchParams.get("limit");
349
+ // `Number`, not `parseInt`: `1.5` and `-1` must be rejected, never truncated
350
+ // down to a valid-seeming value.
351
+ const issueNumber = issueParam === null ? undefined : Number(issueParam);
352
+ const issue = issueNumber !== undefined && Number.isInteger(issueNumber) ? issueNumber : undefined;
353
+ const limitParamValue = limitParam === null ? 50 : Number(limitParam);
354
+ const limit = Number.isInteger(limitParamValue) ? limitParamValue : Number.NaN;
355
+ if (issueParam !== null && (issue === undefined || issue < 1)) return null;
356
+ if (!Number.isSafeInteger(limit) || limit < 1) return null;
357
+ return { ...(issue === undefined ? {} : { issue }), limit };
358
+ }
359
+
360
+ /**
361
+ * The dashboard's whole HTTP surface, separated from the socket exactly like
362
+ * `daemonHttpResponse` so a test can drive it without binding a port. The UI
363
+ * ships on disk and is served without auth; `/api/*` is bearer-authenticated.
364
+ */
365
+ export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Promise<Response> {
366
+ const url = new URL(req.url);
367
+ const path = url.pathname;
368
+ if (path.startsWith("/api/")) {
369
+ const presented = bearerToken(req);
370
+ if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
371
+ // Bare 401: no body, no scheme hint — nothing about the token leaks.
372
+ return new Response("", { status: 401 });
373
+ }
374
+ // Read-only surface: every /api route is a GET. Everything else stays
375
+ // 404 (after the auth check, so the surface cannot be probed).
376
+ if (req.method !== "GET") return new Response("", { status: 404 });
377
+
378
+ if (path === "/api/projects") return Response.json(await d.projects());
379
+ if (path === "/api/overview") return Response.json(await d.overview());
380
+
381
+ // `/api/projects/:name/{board,status,ledger,reports}` and
382
+ // `/api/projects/:name/runs/:issue`. Project names are matched against the
383
+ // configured set so an unknown name is a 404, never the producer's throw.
384
+ const name = (m: RegExpExecArray): string | undefined => {
385
+ try {
386
+ return decodeURIComponent(m[1]!);
387
+ } catch {
388
+ return undefined;
389
+ }
390
+ };
391
+ const known = (project: string | undefined): project is string =>
392
+ project !== undefined && loadConfig().projects.some((p) => p.name === project);
393
+
394
+ const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports)$/.exec(path);
395
+ if (proj !== null) {
396
+ const project = name(proj);
397
+ if (!known(project)) return new Response("", { status: 404 });
398
+ switch (proj[2]) {
399
+ case "board":
400
+ return Response.json(await d.board(project!));
401
+ case "status":
402
+ return Response.json(await d.status(project!));
403
+ case "ledger": {
404
+ const opts = parseLedgerParams(url);
405
+ if (opts === null) return new Response("", { status: 400 });
406
+ return Response.json(await d.ledger(project!, opts));
407
+ }
408
+ case "reports":
409
+ return Response.json(await d.reports(project!));
410
+ }
411
+ }
412
+
413
+ const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path);
414
+ if (runs !== null) {
415
+ const project = name(runs);
416
+ const issue = Number.parseInt(runs[2]!, 10);
417
+ if (!known(project) || !Number.isSafeInteger(issue)) {
418
+ return new Response("", { status: 404 });
419
+ }
420
+ return Response.json(await d.runs(project!, issue));
421
+ }
422
+
423
+ return new Response("", { status: 404 });
424
+ }
425
+ // Everything outside /api is static UI, unauthenticated by contract.
426
+ if (req.method !== "GET" && req.method !== "HEAD") {
427
+ return new Response("", { status: 405 });
428
+ }
429
+ const name = STATIC_FILES[path];
430
+ if (name === undefined) return new Response("not found", { status: 404 });
431
+ // Small fixed set of files, read on demand like the config: no cache to go
432
+ // stale when an operator edits the UI beside a running dashboard.
433
+ const body = readFileSync(join(import.meta.dir, name));
434
+ const mime = STATIC_MIME[name.slice(name.lastIndexOf("."))] ?? "application/octet-stream";
435
+ return new Response(body, { headers: { "content-type": mime } });
436
+ }
437
+
438
+ export interface DashboardStartOpts {
439
+ port?: number;
440
+ /** Bind address; defaults to loopback. A non-loopback host still starts,
441
+ * with a one-line warning naming the token file. */
442
+ host?: string;
443
+ }
444
+
445
+ /** Loopback is the safe posture; anything else gets the token warning. */
446
+ function isLoopbackHost(host: string): boolean {
447
+ return host === "localhost" || host === "::1" || host.startsWith("127.");
448
+ }
449
+
450
+ /**
451
+ * Runs the dashboard until SIGINT/SIGTERM, like the foreground daemon. Mints
452
+ * the token on first start and prints where the server is listening.
453
+ */
454
+ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<void> {
455
+ const token = ensureDashboardToken();
456
+ const host = opts.host ?? DASHBOARD_HOST;
457
+ const server = Bun.serve({
458
+ hostname: host,
459
+ port: opts.port ?? DASHBOARD_PORT,
460
+ fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)),
461
+ });
462
+ // An IPv6 literal needs brackets to be a clickable URL.
463
+ const displayHost = host.includes(":") ? `[${host}]` : host;
464
+ process.stdout.write(`dashboard on http://${displayHost}:${server.port}\n`);
465
+ if (!isLoopbackHost(host)) {
466
+ // The one-line warning the contract promises: the token file is the only
467
+ // door onto the project list, so say where it is before a request arrives.
468
+ process.stderr.write(
469
+ `warning: dashboard bound to ${host} — anyone who can reach it can use the token at ${tokenPath()}; keep it secret\n`,
470
+ );
471
+ }
472
+ await new Promise<void>((resolve) => {
473
+ const stop = (): void => {
474
+ process.off("SIGINT", stop);
475
+ process.off("SIGTERM", stop);
476
+ void server.stop(true).finally(() => resolve());
477
+ };
478
+ process.on("SIGINT", stop);
479
+ process.on("SIGTERM", stop);
480
+ });
481
+ }