omp-conductor 0.18.2 → 0.19.1

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 +379 -22
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +511 -101
  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 +325 -1159
  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 +326 -47
  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
@@ -29,6 +29,16 @@ const panelReports = document.getElementById("panel-reports");
29
29
  const runDetail = document.getElementById("run-detail");
30
30
  const runTitle = document.getElementById("run-title");
31
31
  const runAttempts = document.getElementById("run-attempts");
32
+ const controls = document.getElementById("controls");
33
+ const controlResult = document.getElementById("control-result");
34
+ const transcriptPane = document.getElementById("transcript");
35
+ const transcriptToggle = document.getElementById("transcript-toggle");
36
+ const transcriptAutoscroll = document.getElementById("transcript-autoscroll");
37
+ const transcriptState = document.getElementById("transcript-state");
38
+ const panelStats = document.getElementById("panel-stats");
39
+ const statsBody = document.getElementById("stats-body");
40
+ const statsWindowBar = document.getElementById("stats-window");
41
+ const statsWindowLabel = document.getElementById("stats-window-label");
32
42
 
33
43
  /** Human verdict per daemon state — "down" spelled out by design. */
34
44
  const STATE_TEXT = {
@@ -56,6 +66,7 @@ const LANE_TITLES = {
56
66
  let token = localStorage.getItem(TOKEN_KEY);
57
67
  let currentProject = null;
58
68
  let currentTab = "board";
69
+ let currentRunIssue = null;
59
70
  let pollTimer = null;
60
71
 
61
72
  function el(tag, className, text) {
@@ -80,6 +91,112 @@ async function api(path) {
80
91
  return res.json();
81
92
  }
82
93
 
94
+ // --- controls (#295) ---
95
+
96
+ // The mutating twin of `api`. Separate on purpose: every caller has to name a
97
+ // method, so a read can never become a write by editing one string, and the
98
+ // answer body is returned even for a refusal because the refusal text IS the
99
+ // useful part (the daemon's own 409/422 wording, or a 502 naming the daemon).
100
+ async function apiPost(path, body) {
101
+ const res = await fetch(path, {
102
+ method: "POST",
103
+ headers: {
104
+ Authorization: `Bearer ${token}`,
105
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
106
+ },
107
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
108
+ });
109
+ if (res.status === 401) {
110
+ showTokenPrompt();
111
+ throw new Error("token refused");
112
+ }
113
+ let parsed = null;
114
+ try {
115
+ parsed = await res.json();
116
+ } catch {
117
+ parsed = null;
118
+ }
119
+ return { ok: res.ok, status: res.status, body: parsed };
120
+ }
121
+
122
+ /** One line of feedback under the control bar — the server's own words. */
123
+ function showControlResult(text, failed) {
124
+ controlResult.textContent = text;
125
+ controlResult.hidden = false;
126
+ controlResult.classList.toggle("state-degraded", failed === true);
127
+ }
128
+
129
+ /**
130
+ * Run one control and report it, then refresh so the page shows the new truth
131
+ * rather than the operator's expectation of it.
132
+ */
133
+ async function runControl(path, body, describe) {
134
+ showControlResult(`${describe}…`, false);
135
+ try {
136
+ const answer = await apiPost(path, body);
137
+ const detail =
138
+ answer.body !== null && typeof answer.body === "object" && typeof answer.body.error === "string"
139
+ ? answer.body.error
140
+ : JSON.stringify(answer.body);
141
+ showControlResult(
142
+ answer.ok ? `${describe}: ok` : `${describe} refused (${answer.status}): ${detail}`,
143
+ __omp_shell("answer.ok,")
144
+ );
145
+ } catch (err) {
146
+ showControlResult(`${describe} failed: ${String(err.message ?? err)}`, true);
147
+ return;
148
+ }
149
+ await refreshProject(currentTab).catch(() => undefined);
150
+ }
151
+
152
+ /**
153
+ * The confirm gate for a destructive action, naming exactly what will happen.
154
+ *
155
+ * `hold` and `disarm` are here alongside worker-stop and `unblock --force`
156
+ * because both take the heartbeat down, and re-arming costs a Telegram
157
+ * challenge an operator has to answer in the chat — that is not something a
158
+ * mis-click should buy.
159
+ */
160
+ function confirmDestructive(text) {
161
+ return window.confirm(text);
162
+ }
163
+
164
+ function projectPath(suffix) {
165
+ return `/api/projects/${encodeURIComponent(currentProject)}${suffix}`;
166
+ }
167
+
168
+ controls.addEventListener("click", (event) => {
169
+ const action = event.target?.dataset?.control;
170
+ if (action === undefined || currentProject === null) return;
171
+ event.preventDefault();
172
+ if (action === "resume") {
173
+ runControl(projectPath("/resume"), undefined, "resume dispatch");
174
+ return;
175
+ }
176
+ if (action === "disarm") {
177
+ if (!confirmDestructive("Disarm ticks? Re-arming needs a Telegram challenge answered in the chat.")) {
178
+ return;
179
+ }
180
+ runControl(projectPath("/disarm"), undefined, "disarm ticks");
181
+ return;
182
+ }
183
+ const reason = window.prompt(
184
+ action === "hold"
185
+ ? "Reason for holding the fleet (recorded in the pause sentinel):"
186
+ : "Reason for pausing dispatch (recorded in the pause sentinel):",
187
+ );
188
+ if (reason === null || reason.trim() === "") return;
189
+ if (
190
+ action === "hold" &&
191
+ __omp_shell("confirmDestructive(")
192
+ "Hold stops new claims AND disarms ticks. Re-arming needs a Telegram challenge answered in the chat. Continue?",
193
+ )
194
+ ) {
195
+ return;
196
+ }
197
+ runControl(projectPath(`/${action}`), { reason: reason.trim() }, `${action} fleet`);
198
+ });
199
+
83
200
  function daemonLine(view) {
84
201
  const { daemon } = view;
85
202
  const text =
@@ -209,17 +326,26 @@ function setTab(tab) {
209
326
  panelBoard.hidden = tab !== "board";
210
327
  panelLedger.hidden = tab !== "ledger";
211
328
  panelReports.hidden = tab !== "reports";
329
+ panelStats.hidden = tab !== "stats";
212
330
  runDetail.hidden = true;
213
331
  }
214
332
 
215
333
  async function refreshProject(tab) {
216
334
  if (tab === "ledger") return refreshLedger();
217
335
  if (tab === "reports") return refreshReports();
336
+ if (tab === "stats") return refreshStats();
218
337
  return refreshBoard();
219
338
  }
220
339
 
221
340
  function renderProjectError(err) {
222
- const target = currentTab === "ledger" ? panelLedger : currentTab === "reports" ? panelReports : panelBoard;
341
+ const target =
342
+ currentTab === "ledger"
343
+ ? panelLedger
344
+ : currentTab === "reports"
345
+ ? panelReports
346
+ : currentTab === "stats"
347
+ ? statsBody
348
+ : panelBoard;
223
349
  clear(target).appendChild(el("p", "error", `Could not load: ${err.message}`));
224
350
  }
225
351
 
@@ -281,9 +407,87 @@ function showRunDetail(issue) {
281
407
  runTitle.textContent = `${currentProject} #${issue}`;
282
408
  clear(runAttempts);
283
409
  runDetail.hidden = false;
410
+ // A stream belongs to one issue. Navigating to another run closes it rather
411
+ // than leaving it following the run the operator just left.
412
+ closeTranscript();
413
+ currentRunIssue = issue;
414
+ transcriptPane.hidden = true;
415
+ clear(transcriptPane);
416
+ transcriptToggle.textContent = "Watch transcript";
417
+ transcriptState.textContent = "";
418
+ transcriptState.classList.remove("state-degraded");
419
+ renderRunControls(issue);
284
420
  refreshRuns(issue);
285
421
  }
286
422
 
423
+ /**
424
+ * Per-run actions (#295), rebuilt for whichever issue is open.
425
+ *
426
+ * Worker pause/resume/stop and extend go to the owning daemon, so a dead daemon
427
+ * answers 502 and the button says so — it never reports a pause that did not
428
+ * happen. `unblock --force` and worker-stop are confirmed by name because both
429
+ * destroy something: a terminal settlement, or an uncommitted worktree that is
430
+ * the only copy of a worker's output.
431
+ */
432
+ function renderRunControls(issue) {
433
+ const bar = clear(document.getElementById("run-controls"));
434
+ const button = (label, className, handler) => {
435
+ const node = el("button", className, label);
436
+ node.type = "button";
437
+ node.addEventListener("click", handler);
438
+ bar.appendChild(node);
439
+ };
440
+ button("Pause worker", undefined, () =>
441
+ runControl(projectPath(`/runs/${issue}/worker/pause`), {}, `pause worker #${issue}`),
442
+ );
443
+ button("Resume worker", undefined, () =>
444
+ runControl(projectPath(`/runs/${issue}/worker/resume`), {}, `resume worker #${issue}`),
445
+ );
446
+ button("Stop worker", "destructive", () => {
447
+ const reason = window.prompt(`Reason for stopping #${issue} (recorded in the run's report):`);
448
+ if (reason === null || reason.trim() === "") return;
449
+ if (
450
+ __omp_shell("confirmDestructive(")
451
+ `Stop #${issue}? This settles the run terminally, salvages its tree and frees the slot. It cannot be resumed.`,
452
+ )
453
+ ) {
454
+ return;
455
+ }
456
+ runControl(
457
+ projectPath(`/runs/${issue}/worker/stop`),
458
+ { reason: reason.trim() },
459
+ `stop worker #${issue}`,
460
+ );
461
+ });
462
+ button("Extend turns", undefined, () => {
463
+ const raw = window.prompt(`New turn ceiling for #${issue}:`);
464
+ if (raw === null) return;
465
+ const maxTurns = Number(raw);
466
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
467
+ showControlResult("extend refused: the turn ceiling must be a positive integer", true);
468
+ return;
469
+ }
470
+ runControl(projectPath(`/runs/${issue}/extend`), { maxTurns }, `extend #${issue} to ${maxTurns}`);
471
+ });
472
+ button("Unblock", undefined, () =>
473
+ runControl(projectPath(`/runs/${issue}/unblock`), {}, `unblock #${issue}`),
474
+ );
475
+ button("Unblock --force", "destructive", () => {
476
+ if (
477
+ __omp_shell("confirmDestructive(")
478
+ `Force-unblock #${issue}? This accepts the loss of uncommitted work in its worktree, which may be the only copy.`,
479
+ )
480
+ ) {
481
+ return;
482
+ }
483
+ runControl(
484
+ projectPath(`/runs/${issue}/unblock`),
485
+ { force: true },
486
+ `force-unblock #${issue}`,
487
+ );
488
+ });
489
+ }
490
+
287
491
  async function refreshRuns(issue) {
288
492
  const { runs } = await api(`/api/projects/${encodeURIComponent(currentProject)}/runs/${issue}`);
289
493
  const tbody = clear(runAttempts);
@@ -303,6 +507,265 @@ async function refreshRuns(issue) {
303
507
  }
304
508
  }
305
509
 
510
+ // --- analytics (#297) ---
511
+
512
+ const SVG_NS = "http://www.w3.org/2000/svg";
513
+ let statsSince = "7d";
514
+
515
+ function svg(tag, attrs) {
516
+ const node = document.createElementNS(SVG_NS, tag);
517
+ for (const [key, value] of Object.entries(attrs ?? {})) {
518
+ node.setAttribute(key, String(value));
519
+ }
520
+ return node;
521
+ }
522
+
523
+ /**
524
+ * A horizontal bar chart, hand-rolled because the epic forbids a chart
525
+ * dependency — and because the whole vocabulary needed is a rect and a label.
526
+ *
527
+ * `rows` is `[label, value, note?]`. An empty set renders the explicit empty
528
+ * state rather than an axis with nothing on it: a chart with no bars reads as a
529
+ * measurement of zero, which is the misreading #282 and this slice both exist to
530
+ * prevent.
531
+ */
532
+ function barChart(title, rows, format) {
533
+ const box = el("div", "chart");
534
+ box.appendChild(el("h4", undefined, title));
535
+ if (rows.length === 0) {
536
+ box.appendChild(el("p", "empty", "no measurements in this window"));
537
+ return box;
538
+ }
539
+ const max = Math.max(...rows.map(([, value]) => value), 0);
540
+ const width = 520;
541
+ const rowH = 22;
542
+ const labelW = 168;
543
+ const chart = svg("svg", {
544
+ viewBox: `0 0 ${width} ${rows.length * rowH + 6}`,
545
+ role: "img",
546
+ "aria-label": title,
547
+ class: "bars",
548
+ });
549
+ rows.forEach(([label, value, note], i) => {
550
+ const y = i * rowH + 3;
551
+ const barW = max === 0 ? 0 : Math.max(1, Math.round(((width - labelW - 90) * value) / max));
552
+ chart.appendChild(
553
+ svg("text", { x: 0, y: y + 13, class: "bar-label" }),
554
+ ).textContent = label;
555
+ chart.appendChild(svg("rect", { x: labelW, y, width: barW, height: rowH - 8, class: "bar" }));
556
+ chart.appendChild(
557
+ svg("text", { x: labelW + barW + 6, y: y + 13, class: "bar-value" }),
558
+ ).textContent = note ?? format(value);
559
+ });
560
+ box.appendChild(chart);
561
+ return box;
562
+ }
563
+
564
+ function statLine(label, value) {
565
+ const row = el("div", "stat");
566
+ row.appendChild(el("span", "stat-label", label));
567
+ row.appendChild(el("span", "stat-value", value));
568
+ return row;
569
+ }
570
+
571
+ /** `null` means "not measured" everywhere in the report, and must never print
572
+ * as a zero — that is the difference between "no merges" and "free". */
573
+ function orDash(value, format) {
574
+ return value === null || value === undefined ? "—" : format(value);
575
+ }
576
+
577
+ function hours(ms) {
578
+ return `${(ms / 3_600_000).toFixed(1)}h`;
579
+ }
580
+
581
+ async function refreshStats() {
582
+ const path = projectPath(`/stats?since=${encodeURIComponent(statsSince)}`);
583
+ const report = await api(path);
584
+ statsWindowLabel.textContent = `${report.window.sinceDay} → ${report.window.untilDay}`;
585
+ const out = clear(statsBody);
586
+
587
+ if (report.empty === true) {
588
+ // The report's own verdict, not an inference from zeros.
589
+ out.appendChild(
590
+ el(
591
+ "p",
592
+ "empty",
593
+ "Nothing settled in this window — no measurements to show. (Zeros here would read as outcomes.)",
594
+ ),
595
+ );
596
+ out.appendChild(statLine("GitHub API calls", String(report.ghCalls)));
597
+ return;
598
+ }
599
+
600
+ const total = report.total;
601
+ const summary = el("div", "summary");
602
+ summary.appendChild(statLine("merged", String(total.merged)));
603
+ summary.appendChild(statLine("settled", String(total.settled)));
604
+ summary.appendChild(
605
+ statLine("merge rate", orDash(total.mergeRate, (v) => `${Math.round(v * 100)}%`)),
606
+ );
607
+ summary.appendChild(statLine("attempts / merge", orDash(total.runsPerMerged, (v) => v.toFixed(1))));
608
+ summary.appendChild(statLine("lead time p50", orDash(total.leadTimeMedianMs, hours)));
609
+ summary.appendChild(statLine("lead time p90", orDash(total.leadTimeP90Ms, hours)));
610
+ summary.appendChild(statLine("metered spend", usd(total.spendUsd)));
611
+ summary.appendChild(statLine("$ / merge", orDash(total.spendPerMerged, usd)));
612
+ summary.appendChild(statLine("GitHub API calls", String(report.ghCalls)));
613
+ out.appendChild(summary);
614
+
615
+ // Unmetered runs are their own series, never averaged in as free (#297's
616
+ // third criterion). Cost unknown is not cost zero, and the two must not sit
617
+ // in one number.
618
+ const unmetered = el("div", "chart");
619
+ unmetered.appendChild(el("h4", undefined, "Unmetered (telemetry absent — cost unknown, not $0)"));
620
+ const unmeteredRows = el("div", "summary");
621
+ unmeteredRows.appendChild(statLine("runs", String(total.unmeteredRuns)));
622
+ unmeteredRows.appendChild(statLine("merged issues", String(total.unmeteredMerged)));
623
+ unmetered.appendChild(unmeteredRows);
624
+ out.appendChild(unmetered);
625
+
626
+ out.appendChild(
627
+ barChart(
628
+ "Merges per repo",
629
+ report.repos.map((r) => [r.repo, r.merged]),
630
+ (v) => String(v),
631
+ ),
632
+ );
633
+ out.appendChild(
634
+ barChart(
635
+ "USD per merged PR",
636
+ report.repos
637
+ .filter((r) => r.spendPerMerged !== null)
638
+ .map((r) => [r.repo, r.spendPerMerged, usd(r.spendPerMerged)]),
639
+ usd,
640
+ ),
641
+ );
642
+ out.appendChild(
643
+ barChart(
644
+ "Lead time p90 per repo",
645
+ report.repos
646
+ .filter((r) => r.leadTimeP90Ms !== null)
647
+ .map((r) => [r.repo, r.leadTimeP90Ms, hours(r.leadTimeP90Ms)]),
648
+ hours,
649
+ ),
650
+ );
651
+ out.appendChild(
652
+ barChart(
653
+ "Failure classes",
654
+ Object.entries(total.failureClasses)
655
+ .sort((a, b) => b[1] - a[1])
656
+ .map(([cls, n]) => [cls, n]),
657
+ (v) => String(v),
658
+ ),
659
+ );
660
+ }
661
+
662
+ statsWindowBar.addEventListener("click", (event) => {
663
+ const since = event.target?.dataset?.since;
664
+ if (since === undefined || currentProject === null) return;
665
+ if (since === "custom") {
666
+ const raw = window.prompt("Window start — 7d, 30d, any Nd, or YYYY-MM-DD:", statsSince);
667
+ if (raw === null || raw.trim() === "") return;
668
+ statsSince = raw.trim();
669
+ } else {
670
+ statsSince = since;
671
+ }
672
+ refreshStats().catch((err) => renderProjectError(err));
673
+ });
674
+
675
+ // --- live transcript (#296) ---
676
+
677
+ // The open stream, if any. One at a time by construction: opening a second
678
+ // would double every line and leave the first following a run nobody is
679
+ // watching.
680
+ let transcriptSource = null;
681
+ let transcriptIssue = null;
682
+ // Cap on retained lines. A long run's transcript is unbounded and the DOM is
683
+ // not: keeping every line eventually stalls the tab, which is a worse failure
684
+ // than losing the top of a log the host still has in full.
685
+ const TRANSCRIPT_MAX_LINES = 4000;
686
+
687
+ function closeTranscript() {
688
+ if (transcriptSource !== null) {
689
+ transcriptSource.close();
690
+ transcriptSource = null;
691
+ }
692
+ transcriptIssue = null;
693
+ }
694
+
695
+ function transcriptAppend(text, className) {
696
+ const atBottom =
697
+ transcriptPane.scrollHeight - transcriptPane.scrollTop - transcriptPane.clientHeight < 24;
698
+ transcriptPane.appendChild(el("div", className, text));
699
+ while (transcriptPane.childElementCount > TRANSCRIPT_MAX_LINES) {
700
+ transcriptPane.removeChild(transcriptPane.firstElementChild);
701
+ }
702
+ // Autoscroll only when the operator is already at the bottom, even with the
703
+ // box ticked: yanking the view down while somebody is reading turn three is
704
+ // how a live pane becomes unusable.
705
+ if (transcriptAutoscroll.checked && atBottom) {
706
+ transcriptPane.scrollTop = transcriptPane.scrollHeight;
707
+ }
708
+ }
709
+
710
+ function openTranscript(issue) {
711
+ closeTranscript();
712
+ transcriptIssue = issue;
713
+ transcriptPane.hidden = false;
714
+ clear(transcriptPane);
715
+ transcriptToggle.textContent = "Stop watching";
716
+ transcriptState.textContent = "connecting…";
717
+ transcriptState.classList.remove("state-degraded");
718
+
719
+ // `EventSource` cannot send an Authorization header, so the token rides as a
720
+ // query parameter on this one endpoint. It is loopback by default and the
721
+ // page already holds the token; the alternative — a cookie — would be sent on
722
+ // every request including the static ones, which is strictly worse.
723
+ const url =
724
+ projectPath(`/runs/${issue}/transcript/stream`) + `?token=${encodeURIComponent(token)}`;
725
+ const source = new EventSource(url);
726
+ transcriptSource = source;
727
+
728
+ source.onopen = () => {
729
+ if (transcriptSource === source) transcriptState.textContent = "streaming";
730
+ };
731
+ source.onmessage = (event) => {
732
+ if (transcriptSource !== source) return;
733
+ transcriptAppend(event.data);
734
+ };
735
+ source.addEventListener("end", () => {
736
+ if (transcriptSource !== source) return;
737
+ transcriptState.textContent = "run ended — stream closed";
738
+ transcriptToggle.textContent = "Watch transcript";
739
+ closeTranscript();
740
+ });
741
+ source.addEventListener("error", (event) => {
742
+ if (transcriptSource !== source) return;
743
+ // A server-sent `error` event carries a reason (no transcript yet, no run);
744
+ // a transport error carries none, and EventSource retries that itself.
745
+ if (typeof event.data === "string" && event.data !== "") {
746
+ transcriptAppend(event.data, "state-degraded");
747
+ transcriptState.textContent = event.data;
748
+ transcriptState.classList.add("state-degraded");
749
+ transcriptToggle.textContent = "Watch transcript";
750
+ closeTranscript();
751
+ return;
752
+ }
753
+ transcriptState.textContent = "reconnecting…";
754
+ });
755
+ }
756
+
757
+ transcriptToggle.addEventListener("click", () => {
758
+ if (transcriptSource !== null) {
759
+ closeTranscript();
760
+ transcriptToggle.textContent = "Watch transcript";
761
+ transcriptState.textContent = "stopped";
762
+ return;
763
+ }
764
+ const issue = currentRunIssue;
765
+ if (issue === null) return;
766
+ openTranscript(issue);
767
+ });
768
+
306
769
  function stateClass(state) {
307
770
  if (state === "merged" || state === "settled") return "green";
308
771
  if (state === "failed" || state === "killed") return "red";
@@ -383,8 +846,43 @@ async function refreshLedger() {
383
846
 
384
847
  async function refreshReports() {
385
848
  const path = `/api/projects/${encodeURIComponent(currentProject)}/reports`;
386
- const { openReports, digestBacklog, heldNotices } = await api(path);
849
+ const { openReports, digestBacklog, heldNotices, openDecisions } = await api(path);
387
850
  const out = clear(panelReports);
851
+
852
+ // Questions waiting on the operator, answerable in place (#295's answer verb,
853
+ // which had no surface until #297 surfaced the list it acts on). Rendered
854
+ // first because it is the only thing on this tab that is *blocking* something.
855
+ const decisions = openDecisions ?? [];
856
+ out.appendChild(el("h3", undefined, `Open decisions (${decisions.length})`));
857
+ const dl = el("ul", "reports");
858
+ if (decisions.length === 0) dl.appendChild(el("li", "empty", "no open decisions"));
859
+ for (const decision of decisions) {
860
+ const li = el("li");
861
+ li.appendChild(el("div", undefined, decision.question));
862
+ const meta = [`asked ${new Date(decision.askedAt).toLocaleString()}`];
863
+ if (decision.blocks !== undefined && decision.blocks !== null) meta.push(`blocks ${decision.blocks}`);
864
+ li.appendChild(el("div", "state-muted", meta.join(" · ")));
865
+ // A watch is the orchestrator's own condition, never a question put to a
866
+ // human — offering an answer box for one would invite answering something
867
+ // nobody asked.
868
+ if (decision.kind !== "watch") {
869
+ const answer = el("button", undefined, "Answer…");
870
+ answer.type = "button";
871
+ answer.addEventListener("click", () => {
872
+ const text = window.prompt(decision.question);
873
+ if (text === null || text.trim() === "") return;
874
+ runControl(
875
+ projectPath(`/decisions/${encodeURIComponent(decision.id)}/answer`),
876
+ { answer: text.trim() },
877
+ `answer decision ${decision.id.slice(0, 8)}`,
878
+ );
879
+ });
880
+ li.appendChild(answer);
881
+ }
882
+ dl.appendChild(li);
883
+ }
884
+ out.appendChild(dl);
885
+
388
886
  out.appendChild(el("h3", undefined, `Open reports (${openReports.length})`));
389
887
  const ul = el("ul", "reports");
390
888
  if (openReports.length === 0) ul.appendChild(el("li", "empty", "no open reports"));
@@ -419,11 +917,15 @@ async function refreshReports() {
419
917
 
420
918
  document.getElementById("back-to-fleet").addEventListener("click", (event) => {
421
919
  event.preventDefault();
920
+ // Leaving the run view closes its stream (#296). Without this, navigating
921
+ // away leaves a follow running on the host for a pane nobody can see.
922
+ closeTranscript();
422
923
  showFleetView();
423
924
  });
424
925
 
425
926
  document.getElementById("back-to-board").addEventListener("click", (event) => {
426
927
  event.preventDefault();
928
+ closeTranscript();
427
929
  runDetail.hidden = true;
428
930
  refreshBoard().catch((err) => renderProjectError(err));
429
931
  });