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.
- package/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/dashboard/app.js
CHANGED
|
@@ -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 =
|
|
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
|
});
|