omp-conductor 0.18.2 → 0.19.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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
@@ -32,9 +32,32 @@ import { classifyDaemonProjectHealth, fleetLayers } from "../fleet.ts";
32
32
  import type { FleetLayers } from "../status-render.ts";
33
33
  import { statusSnapshot, type StatusSnapshot } from "../daemon.ts";
34
34
  import { boardJson, boardSnapshotOnce } from "../board.ts";
35
+ import { computeStats, parseStatsWindow, type StatsReport } from "../stats.ts";
35
36
  import { dbPath, openStore } from "../store.ts";
36
37
  import type { ProjectConfig } from "../types.ts";
37
- import type { BaseHealth, DigestBacklog, HeldNotice, ReportRecord, RunRecord, TurnOverride, VerbLedgerEntry } from "../types.ts";
38
+ import {
39
+ dashboardAnswerDecision,
40
+ dashboardDisarm,
41
+ dashboardExtend,
42
+ dashboardHold,
43
+ dashboardPause,
44
+ dashboardResume,
45
+ dashboardUnblock,
46
+ dashboardWorkerControl,
47
+ type ControlDeps,
48
+ type ControlOutcome,
49
+ } from "./controls.ts";
50
+ import { transcriptStreamResponse } from "./transcript.ts";
51
+ import type {
52
+ BaseHealth,
53
+ DecisionRecord,
54
+ DigestBacklog,
55
+ HeldNotice,
56
+ ReportRecord,
57
+ RunRecord,
58
+ TurnOverride,
59
+ VerbLedgerEntry,
60
+ } from "../types.ts";
38
61
 
39
62
  /** The dashboard's default bind address — loopback, like the daemon port. */
40
63
  export const DASHBOARD_HOST = "127.0.0.1";
@@ -259,13 +282,22 @@ async function ledgerProducer(
259
282
  * open reports, the digest backlog, and the held notices awaiting a digest. */
260
283
  async function reportsProducer(
261
284
  name: string,
262
- ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }> {
285
+ ): Promise<{
286
+ openReports: ReportRecord[];
287
+ digestBacklog: DigestBacklog;
288
+ heldNotices: HeldNotice[];
289
+ openDecisions: DecisionRecord[];
290
+ }> {
263
291
  const store = openStore(dbPath());
264
292
  try {
265
293
  return {
266
294
  openReports: store.openReports(name),
267
295
  digestBacklog: store.digestBacklog(name),
268
296
  heldNotices: store.undigestedNotices(name),
297
+ // The questions waiting on an operator. Carried here so the answer verb
298
+ // #295 added has something to answer: an endpoint with no surface that
299
+ // lists what it can act on is an endpoint nobody uses.
300
+ openDecisions: store.openDecisions(name),
269
301
  };
270
302
  } finally {
271
303
  store.close();
@@ -274,6 +306,37 @@ async function reportsProducer(
274
306
 
275
307
  /** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts,
276
308
  * states, spend, failure classes, PR URLs. */
309
+ /**
310
+ * The analytics payload (#297): byte-for-byte what `stats --json` prints.
311
+ *
312
+ * One aggregation, two consumers — `computeStats` and `parseStatsWindow` are the
313
+ * same functions the CLI runs, fed by the same read-only store queries. That is
314
+ * the whole of the slice's first acceptance criterion ("numbers on the page equal
315
+ * `stats --json` for the same window"): it holds because there is nothing else
316
+ * here that could compute a different number.
317
+ *
318
+ * Store-only by construction, which is the second criterion: `statsRuns` and
319
+ * `ghCallsBetween` are local reads, and no GitHub client exists in this module.
320
+ */
321
+ async function statsProducer(name: string, since: string): Promise<StatsReport | undefined> {
322
+ const window = parseStatsWindow(since, Date.now());
323
+ // An unparseable window is the caller's error, and the caller turns this into
324
+ // a 400. Defaulting it would measure a different week and say nothing — the
325
+ // exact failure the shared parser exists to prevent.
326
+ if (window === undefined) return undefined;
327
+ const store = openStore(dbPath());
328
+ try {
329
+ return computeStats({
330
+ project: name,
331
+ window,
332
+ ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
333
+ runs: store.statsRuns(name, window.sinceEpochMs),
334
+ });
335
+ } finally {
336
+ store.close();
337
+ }
338
+ }
339
+
277
340
  async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> {
278
341
  const store = openStore(dbPath());
279
342
  try {
@@ -302,8 +365,27 @@ export interface DashboardHttpDeps {
302
365
  ): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>;
303
366
  reports(
304
367
  name: string,
305
- ): Promise<{ openReports: ReportRecord[]; digestBacklog: DigestBacklog; heldNotices: HeldNotice[] }>;
368
+ ): Promise<{
369
+ openReports: ReportRecord[];
370
+ digestBacklog: DigestBacklog;
371
+ heldNotices: HeldNotice[];
372
+ openDecisions: DecisionRecord[];
373
+ }>;
306
374
  runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>;
375
+ /** The analytics payload (#297); `undefined` for an unparseable window. */
376
+ stats(name: string, since: string): Promise<StatsReport | undefined>;
377
+ /**
378
+ * The live transcript stream (#296). Returns the SSE response itself rather
379
+ * than data, because the whole point is a connection that stays open — a
380
+ * producer returning a value would have to buffer the run to completion first.
381
+ */
382
+ transcript?: (name: string, issue: number, req: Request) => Response;
383
+ /**
384
+ * The mutating half (#295). Held as one injected object rather than eight
385
+ * function fields so a test overrides exactly the action it drives, and so the
386
+ * read surface above stays visibly read-only.
387
+ */
388
+ controls?: Partial<ControlDeps>;
307
389
  }
308
390
 
309
391
  /** The deps bound to the real producers — what `startDashboard` serves with. */
@@ -317,6 +399,7 @@ export function defaultDashboardDeps(token: string): DashboardHttpDeps {
317
399
  ledger: ledgerProducer,
318
400
  reports: reportsProducer,
319
401
  runs: runsProducer,
402
+ stats: statsProducer,
320
403
  };
321
404
  }
322
405
 
@@ -341,6 +424,32 @@ function bearerToken(req: Request): string | undefined {
341
424
  return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
342
425
  }
343
426
 
427
+ /**
428
+ * The token from `?token=`, accepted on the SSE stream path and nowhere else
429
+ * (#296).
430
+ *
431
+ * `EventSource` is the browser API for Server-Sent Events and it cannot set an
432
+ * `Authorization` header — that is a documented limitation of the API, not
433
+ * something the UI is choosing. The two honest alternatives are a cookie or a
434
+ * query parameter, and a cookie is strictly worse here: it would ride on every
435
+ * request including the unauthenticated static ones, and it would need SameSite
436
+ * and expiry policy this product has no other use for.
437
+ *
438
+ * So the exposure is accepted, narrowed, and stated: a URL can reach a server
439
+ * log or a browser history entry, and this endpoint is the only place that can
440
+ * happen. It is scoped to the exact stream path so no other route can ever be
441
+ * reached with a URL-borne credential, and the dashboard binds loopback by
442
+ * default — the `--host` opt-in already carries its own warning about the token
443
+ * being the only thing between the network and the fleet.
444
+ */
445
+ function streamQueryToken(url: URL): string | undefined {
446
+ if (!/^\/api\/projects\/[^/]+\/runs\/\d+\/transcript\/stream$/.test(url.pathname)) {
447
+ return undefined;
448
+ }
449
+ const token = url.searchParams.get("token");
450
+ return token === null || token === "" ? undefined : token;
451
+ }
452
+
344
453
  /**
345
454
  * The ledger's query params. `limit` defaults to 50 — the CLI's own default —
346
455
  * so the endpoint and `omp-conductor ledger` cannot disagree. An explicit
@@ -361,6 +470,109 @@ function parseLedgerParams(url: URL): { issue?: number; limit: number } | null {
361
470
  return { ...(issue === undefined ? {} : { issue }), limit };
362
471
  }
363
472
 
473
+ /**
474
+ * The configured project a `/api/projects/:name/...` path names, or undefined.
475
+ *
476
+ * One resolver for reads and mutations both: an unknown name is a 404 rather
477
+ * than a producer's throw, and — the part that matters for #295 — an action can
478
+ * never be applied to a project this host does not configure.
479
+ */
480
+ function projectFromPath(path: string): ProjectConfig | undefined {
481
+ const match = /^\/api\/projects\/([^/]+)(?:\/|$)/.exec(path);
482
+ if (match === null) return undefined;
483
+ let name: string;
484
+ try {
485
+ name = decodeURIComponent(match[1]!);
486
+ } catch {
487
+ return undefined;
488
+ }
489
+ return loadConfig().projects.find((p) => p.name === name);
490
+ }
491
+
492
+ /**
493
+ * The JSON body of a mutating request, or the 400 that says why not.
494
+ *
495
+ * An absent body is `{}` rather than an error: `resume` and `disarm` take no
496
+ * arguments, and requiring an empty object from them would be ceremony. A body
497
+ * that is present and unparseable is always an error — that is a client bug, and
498
+ * treating it as "no arguments" is how a `stop` arrives with no reason.
499
+ */
500
+ async function mutationBody(req: Request): Promise<unknown | { __malformed: true }> {
501
+ const raw = await req.text();
502
+ if (raw.trim() === "") return {};
503
+ try {
504
+ return JSON.parse(raw) as unknown;
505
+ } catch {
506
+ return { __malformed: true };
507
+ }
508
+ }
509
+
510
+ /**
511
+ * The mutating surface (#295). Every branch delegates to `controls.ts`, which
512
+ * delegates to the CLI's own code or the owning daemon's own endpoint — there is
513
+ * no fleet logic in this function, only routing.
514
+ */
515
+ async function mutatingResponse(
516
+ req: Request,
517
+ path: string,
518
+ project: ProjectConfig,
519
+ d: DashboardHttpDeps,
520
+ ): Promise<Response> {
521
+ const body = await mutationBody(req);
522
+ if (body !== null && typeof body === "object" && Reflect.get(body, "__malformed") === true) {
523
+ return Response.json({ error: "request body must be valid JSON" }, { status: 400 });
524
+ }
525
+ const deps: ControlDeps = { project, ...d.controls };
526
+ const answer = async (): Promise<ControlOutcome | undefined> => {
527
+ const fleet = /^\/api\/projects\/[^/]+\/(pause|resume|hold|disarm)$/.exec(path);
528
+ if (fleet !== null) {
529
+ switch (fleet[1]) {
530
+ case "pause":
531
+ return dashboardPause(body, deps);
532
+ case "resume":
533
+ return dashboardResume(deps);
534
+ case "hold":
535
+ return dashboardHold(body, deps);
536
+ case "disarm":
537
+ return dashboardDisarm(deps);
538
+ }
539
+ }
540
+ const run = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/(extend|unblock)$/.exec(path);
541
+ if (run !== null) {
542
+ const issue = Number.parseInt(run[1]!, 10);
543
+ if (!Number.isSafeInteger(issue)) return undefined;
544
+ return run[2] === "extend"
545
+ ? await dashboardExtend(issue, body, deps)
546
+ : await dashboardUnblock(issue, body, deps);
547
+ }
548
+ const worker = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/worker\/(pause|resume|stop)$/.exec(path);
549
+ if (worker !== null) {
550
+ const issue = Number.parseInt(worker[1]!, 10);
551
+ if (!Number.isSafeInteger(issue)) return undefined;
552
+ return await dashboardWorkerControl(
553
+ issue,
554
+ worker[2] as "pause" | "resume" | "stop",
555
+ body,
556
+ deps,
557
+ );
558
+ }
559
+ const decision = /^\/api\/projects\/[^/]+\/decisions\/([^/]+)\/answer$/.exec(path);
560
+ if (decision !== null) {
561
+ let id: string;
562
+ try {
563
+ id = decodeURIComponent(decision[1]!);
564
+ } catch {
565
+ return undefined;
566
+ }
567
+ return dashboardAnswerDecision(id, body, deps);
568
+ }
569
+ return undefined;
570
+ };
571
+ const outcome = await answer();
572
+ if (outcome === undefined) return new Response("", { status: 404 });
573
+ return Response.json(outcome.body, { status: outcome.status });
574
+ }
575
+
364
576
  /**
365
577
  * The dashboard's whole HTTP surface, separated from the socket exactly like
366
578
  * `daemonHttpResponse` so a test can drive it without binding a port. The UI
@@ -370,58 +582,79 @@ export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Pro
370
582
  const url = new URL(req.url);
371
583
  const path = url.pathname;
372
584
  if (path.startsWith("/api/")) {
373
- const presented = bearerToken(req);
585
+ const presented = bearerToken(req) ?? streamQueryToken(url);
374
586
  if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
375
587
  // Bare 401: no body, no scheme hint — nothing about the token leaks.
376
588
  return new Response("", { status: 401 });
377
589
  }
378
- // Read-only surface: every /api route is a GET. Everything else stays
379
- // 404 (after the auth check, so the surface cannot be probed).
380
- if (req.method !== "GET") return new Response("", { status: 404 });
590
+ // Two methods now (#295): GET reads, POST mutates. Anything else stays 404
591
+ // after the auth check, so the surface cannot be probed unauthenticated.
592
+ if (req.method !== "GET" && req.method !== "POST") {
593
+ return new Response("", { status: 404 });
594
+ }
595
+
596
+ // The mutating routes first (#295), so a read route can never answer a POST.
597
+ // Reads are then explicitly GET-gated below: without that, `POST .../board`
598
+ // would have fallen through and returned the board, which is a mutating
599
+ // method quietly served by a read handler.
600
+ const project = projectFromPath(path);
601
+ if (req.method === "POST") {
602
+ if (project === undefined) return new Response("", { status: 404 });
603
+ return mutatingResponse(req, path, project, d);
604
+ }
381
605
 
382
606
  if (path === "/api/projects") return Response.json(await d.projects());
383
607
  if (path === "/api/overview") return Response.json(await d.overview());
384
608
 
385
- // `/api/projects/:name/{board,status,ledger,reports}` and
386
- // `/api/projects/:name/runs/:issue`. Project names are matched against the
387
- // configured set so an unknown name is a 404, never the producer's throw.
388
- const name = (m: RegExpExecArray): string | undefined => {
389
- try {
390
- return decodeURIComponent(m[1]!);
391
- } catch {
392
- return undefined;
393
- }
394
- };
395
- const known = (project: string | undefined): project is string =>
396
- project !== undefined && loadConfig().projects.some((p) => p.name === project);
397
-
398
- const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports)$/.exec(path);
609
+ const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports|stats)$/.exec(path);
399
610
  if (proj !== null) {
400
- const project = name(proj);
401
- if (!known(project)) return new Response("", { status: 404 });
611
+ if (project === undefined) return new Response("", { status: 404 });
402
612
  switch (proj[2]) {
403
613
  case "board":
404
- return Response.json(await d.board(project!));
614
+ return Response.json(await d.board(project.name));
405
615
  case "status":
406
- return Response.json(await d.status(project!));
616
+ return Response.json(await d.status(project.name));
407
617
  case "ledger": {
408
618
  const opts = parseLedgerParams(url);
409
619
  if (opts === null) return new Response("", { status: 400 });
410
- return Response.json(await d.ledger(project!, opts));
620
+ return Response.json(await d.ledger(project.name, opts));
411
621
  }
412
622
  case "reports":
413
- return Response.json(await d.reports(project!));
623
+ return Response.json(await d.reports(project.name));
624
+ case "stats": {
625
+ // `7d` is the CLI's own default, so an unqualified page and an
626
+ // unqualified `stats` describe the same window.
627
+ const report = await d.stats(project.name, url.searchParams.get("since") ?? "7d");
628
+ if (report === undefined) {
629
+ return Response.json(
630
+ { error: "since must be 7d, 30d, any Nd, or YYYY-MM-DD" },
631
+ { status: 400 },
632
+ );
633
+ }
634
+ return Response.json(report);
635
+ }
636
+ }
637
+ }
638
+
639
+ // The transcript stream (#296) is matched before the run-detail route so
640
+ // `/runs/42/transcript/stream` cannot be read as an issue named
641
+ // "42/transcript".
642
+ const stream = /^\/api\/projects\/([^/]+)\/runs\/(\d+)\/transcript\/stream$/.exec(path);
643
+ if (stream !== null) {
644
+ const issue = Number.parseInt(stream[2]!, 10);
645
+ if (project === undefined || !Number.isSafeInteger(issue)) {
646
+ return new Response("", { status: 404 });
414
647
  }
648
+ return (d.transcript ?? transcriptStreamResponse)(project.name, issue, req);
415
649
  }
416
650
 
417
651
  const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path);
418
652
  if (runs !== null) {
419
- const project = name(runs);
420
653
  const issue = Number.parseInt(runs[2]!, 10);
421
- if (!known(project) || !Number.isSafeInteger(issue)) {
654
+ if (project === undefined || !Number.isSafeInteger(issue)) {
422
655
  return new Response("", { status: 404 });
423
656
  }
424
- return Response.json(await d.runs(project!, issue));
657
+ return Response.json(await d.runs(project.name, issue));
425
658
  }
426
659
 
427
660
  return new Response("", { status: 404 });
@@ -461,6 +694,14 @@ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<voi
461
694
  const server = Bun.serve({
462
695
  hostname: host,
463
696
  port: opts.port ?? DASHBOARD_PORT,
697
+ // Long-lived by necessity (#296). `Bun.serve` defaults to a 10s idle
698
+ // timeout, which closes a transcript stream mid-run — a quiet worker is
699
+ // exactly the case where watching matters most, and no keepalive interval
700
+ // short enough to beat that default would be sane. Disabled here rather
701
+ // than tuned, because the streams are the only long connections this server
702
+ // has and each one already ends on its own: the follow returns when the run
703
+ // goes terminal, and the client vanishing aborts it.
704
+ idleTimeout: 0,
464
705
  fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)),
465
706
  });
466
707
  // An IPv6 literal needs brackets to be a clickable URL.
@@ -346,3 +346,119 @@ ul.reports li {
346
346
  --down: #ff8a80;
347
347
  }
348
348
  }
349
+
350
+ /* The control surface (#295). Destructive actions are visually distinct because
351
+ the confirm dialog is the second line of defence, not the first. */
352
+ .controls {
353
+ display: flex;
354
+ flex-wrap: wrap;
355
+ gap: 0.5rem;
356
+ align-items: center;
357
+ margin: 0.75rem 0;
358
+ }
359
+
360
+ .controls button {
361
+ background: #1b1f27;
362
+ color: #d7dae0;
363
+ border: 1px solid #2f3542;
364
+ border-radius: 4px;
365
+ padding: 0.35rem 0.7rem;
366
+ font: inherit;
367
+ cursor: pointer;
368
+ }
369
+
370
+ .controls button:hover {
371
+ border-color: #4a5568;
372
+ }
373
+
374
+ .controls button.destructive {
375
+ border-color: #6b2f2f;
376
+ color: #e8b4b4;
377
+ }
378
+
379
+ .controls button.destructive:hover {
380
+ background: #2a1b1b;
381
+ }
382
+
383
+ .controls #control-result,
384
+ .controls > p.state {
385
+ flex-basis: 100%;
386
+ margin: 0.25rem 0 0;
387
+ }
388
+
389
+ /* Live transcript (#296). Fixed height with its own scroll so a long run does
390
+ not push the run controls off the page. */
391
+ .transcript-head {
392
+ display: flex;
393
+ gap: 0.75rem;
394
+ align-items: center;
395
+ flex-wrap: wrap;
396
+ margin: 0.75rem 0 0.25rem;
397
+ }
398
+
399
+ .transcript-head label {
400
+ font-size: 0.85rem;
401
+ color: #9aa0aa;
402
+ }
403
+
404
+ .transcript {
405
+ max-height: 26rem;
406
+ overflow-y: auto;
407
+ margin: 0;
408
+ padding: 0.6rem 0.75rem;
409
+ background: #0f1115;
410
+ border: 1px solid #2f3542;
411
+ border-radius: 4px;
412
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
413
+ font-size: 0.8rem;
414
+ line-height: 1.45;
415
+ white-space: pre-wrap;
416
+ word-break: break-word;
417
+ }
418
+
419
+ /* Analytics (#297). Hand-rolled SVG, per the epic's no-dependency constraint. */
420
+ .chart {
421
+ margin: 1rem 0;
422
+ }
423
+
424
+ .chart h4 {
425
+ margin: 0 0 0.4rem;
426
+ font-size: 0.9rem;
427
+ font-weight: 600;
428
+ color: #c8ccd4;
429
+ }
430
+
431
+ .chart .bars {
432
+ width: 100%;
433
+ max-width: 40rem;
434
+ height: auto;
435
+ }
436
+
437
+ .chart .bar {
438
+ fill: #3d6ea8;
439
+ }
440
+
441
+ .chart .bar-label,
442
+ .chart .bar-value {
443
+ fill: #9aa0aa;
444
+ font-size: 11px;
445
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
446
+ }
447
+
448
+ .stat {
449
+ display: flex;
450
+ justify-content: space-between;
451
+ gap: 1rem;
452
+ padding: 0.2rem 0;
453
+ border-bottom: 1px solid #1e222a;
454
+ font-size: 0.85rem;
455
+ }
456
+
457
+ .stat-label {
458
+ color: #9aa0aa;
459
+ }
460
+
461
+ .stat-value {
462
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
463
+ color: #d7dae0;
464
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Live transcript streaming for the dashboard (epic #292, slice 4/5 — #296).
3
+ *
4
+ * The run detail view could show what a run *did*; this makes it possible to
5
+ * watch one happening, which is the difference between reading a postmortem and
6
+ * noticing a worker is stuck at turn eight.
7
+ *
8
+ * ## It follows the file, not the daemon
9
+ *
10
+ * `tailRun` is the follow — the same function `omp-conductor tail` runs, with
11
+ * the same 1s re-stat cadence, the same read-from-byte-zero behaviour (attaching
12
+ * to a run ten turns in and showing nothing until turn eleven is not watching
13
+ * it), and the same advisor-transcript discovery. This module only turns its
14
+ * lines into Server-Sent Events.
15
+ *
16
+ * That is what makes the acceptance criterion "neither blocks the daemon" true by
17
+ * construction rather than by measurement: nothing here talks to the daemon at
18
+ * all. Two browsers on one run are two read-only file follows, and the daemon
19
+ * does not know either of them exists.
20
+ *
21
+ * ## Why SSE and not a websocket
22
+ *
23
+ * The data is one-directional, text, and append-only, which is exactly what SSE
24
+ * is. A websocket would add a handshake, a framing layer and a dependency for a
25
+ * feature whose whole payload is lines of text — and the epic's constraint is no
26
+ * runtime dependencies. `EventSource` is in every browser and reconnects on its
27
+ * own.
28
+ */
29
+
30
+ import { tailRun } from "../commands/tail.ts";
31
+ import type { Store } from "../types.ts";
32
+
33
+ /** How often a comment frame is written when nothing else is. */
34
+ export const KEEPALIVE_MS = 15_000;
35
+
36
+ /** Injected so a test drives the whole stream with no file and no clock. */
37
+ export interface TranscriptStreamDeps {
38
+ /**
39
+ * The follow itself. Defaults to `tailRun`, and a test replaces it — the point
40
+ * of the seam is that this module owns the *framing*, not the following.
41
+ */
42
+ follow?: (
43
+ project: string,
44
+ issue: number,
45
+ opts: { write: (line: string) => void; signal: AbortSignal; store?: Store },
46
+ ) => Promise<void>;
47
+ /**
48
+ * A store for the follow to read the run row through. Optional, and NOT closed
49
+ * here: `tailRun` closes whatever store it is given, so opening one in this
50
+ * module would double-close it. Production passes none and the follow opens
51
+ * (and closes) its own, exactly as `omp-conductor tail` does.
52
+ */
53
+ store?: Store;
54
+ keepaliveMs?: number;
55
+ }
56
+
57
+ /**
58
+ * One SSE frame.
59
+ *
60
+ * Exported because the framing is the contract a browser parses, and a test that
61
+ * asserts on assembled frames is asserting on the thing `EventSource` sees. Data
62
+ * is split per line so a payload containing a newline cannot terminate the frame
63
+ * early — the one way a text stream corrupts an SSE connection.
64
+ */
65
+ export function sseFrame(event: string | undefined, data: string): string {
66
+ const lines = data.split("\n").map((line) => `data: ${line}`);
67
+ return `${event === undefined ? "" : `event: ${event}\n`}${lines.join("\n")}\n\n`;
68
+ }
69
+
70
+ /**
71
+ * Stream one run's transcript as Server-Sent Events.
72
+ *
73
+ * Three terminal shapes, and each is said out loud rather than inferred from a
74
+ * closed connection:
75
+ *
76
+ * - **`error`** — there is no transcript to follow (no run, or a claimed run
77
+ * whose session never opened one). `tail` throws in exactly that case, and
78
+ * the message is its message. A silent empty stream would read as "a quiet
79
+ * worker", which is the wrong conclusion to invite.
80
+ * - **`end`** — the follow returned, which `tailRun` does once the run row has
81
+ * left the live states and stayed quiet. Its last written line is
82
+ * `run ended: <state>`, so the terminal state is already in the stream.
83
+ * - **the client vanishing** — the request's signal aborts the follow, which is
84
+ * why `tailRun` takes one: a closed tab must not leave a poll loop holding
85
+ * file descriptors until the run happens to finish.
86
+ */
87
+ export function transcriptStreamResponse(
88
+ project: string,
89
+ issue: number,
90
+ req: Request,
91
+ deps: TranscriptStreamDeps = {},
92
+ ): Response {
93
+ const follow = deps.follow ?? defaultFollow;
94
+ const keepaliveMs = deps.keepaliveMs ?? KEEPALIVE_MS;
95
+ const controller = new AbortController();
96
+ // Both directions matter: the client going away aborts the follow, and the
97
+ // follow finishing means nobody is waiting on the client any more.
98
+ req.signal.addEventListener("abort", () => controller.abort(), { once: true });
99
+
100
+ const encoder = new TextEncoder();
101
+ const body = new ReadableStream<Uint8Array>({
102
+ start(sink) {
103
+ let closed = false;
104
+ const send = (frame: string): void => {
105
+ if (closed) return;
106
+ try {
107
+ sink.enqueue(encoder.encode(frame));
108
+ } catch {
109
+ // The client hung up between our abort check and this write. Stop
110
+ // following rather than throwing inside a stream nobody reads.
111
+ closed = true;
112
+ controller.abort();
113
+ }
114
+ };
115
+ // A comment frame — `EventSource` ignores it, proxies and load balancers
116
+ // do not, which is the whole reason it exists.
117
+ const keepalive = setInterval(() => send(": keepalive\n\n"), keepaliveMs);
118
+ const finish = (): void => {
119
+ if (closed) return;
120
+ closed = true;
121
+ clearInterval(keepalive);
122
+ try {
123
+ sink.close();
124
+ } catch {
125
+ // Already closed by the client; nothing to do.
126
+ }
127
+ };
128
+
129
+ void (async () => {
130
+ try {
131
+ await follow(project, issue, {
132
+ write: (line) => send(sseFrame(undefined, line)),
133
+ signal: controller.signal,
134
+ ...(deps.store === undefined ? {} : { store: deps.store }),
135
+ });
136
+ if (!controller.signal.aborted) send(sseFrame("end", "stream closed"));
137
+ } catch (err) {
138
+ // `tail`'s own wording: "no run recorded for #N", or
139
+ // "no transcript yet (state: claimed)". Both are answers, not faults.
140
+ send(sseFrame("error", err instanceof Error ? err.message : String(err)));
141
+ } finally {
142
+ finish();
143
+ }
144
+ })();
145
+ },
146
+ cancel() {
147
+ controller.abort();
148
+ },
149
+ });
150
+
151
+ return new Response(body, {
152
+ headers: {
153
+ "content-type": "text/event-stream; charset=utf-8",
154
+ // No buffering anywhere in between, or the 1s cadence becomes minutes.
155
+ "cache-control": "no-cache, no-transform",
156
+ connection: "keep-alive",
157
+ "x-accel-buffering": "no",
158
+ },
159
+ });
160
+ }
161
+
162
+ /** `tailRun` with the stream's store, so one connection opens one reader. */
163
+ async function defaultFollow(
164
+ project: string,
165
+ issue: number,
166
+ opts: { write: (line: string) => void; signal: AbortSignal; store?: Store },
167
+ ): Promise<void> {
168
+ await tailRun(project, issue, {
169
+ write: opts.write,
170
+ signal: opts.signal,
171
+ ...(opts.store === undefined ? {} : { store: opts.store }),
172
+ });
173
+ }