@lchase/kiln 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ (function () {
2
+ var KEY = "kiln-needs-you";
3
+ var body = document.body;
4
+ var board = document.getElementById("board");
5
+ var count = Number(body.getAttribute("data-needs-you") || "0");
6
+ document.title = "(" + count + ") Kiln";
7
+
8
+ function note(n) {
9
+ var raw = null;
10
+ try { raw = localStorage.getItem(KEY); } catch (err) { raw = null; }
11
+ try { localStorage.setItem(KEY, String(n)); } catch (err) { /* private mode */ }
12
+ if (raw === null) return;
13
+ var prev = Number(raw);
14
+ if (!isFinite(prev) || n <= prev) return;
15
+ if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
16
+ var added = n - prev;
17
+ try {
18
+ new Notification("Kiln", { body: added === 1 ? "1 job needs you" : added + " jobs need you" });
19
+ } catch (err) { /* the browser refused the notification */ }
20
+ }
21
+ note(count);
22
+
23
+ var compact = true;
24
+ try { compact = localStorage.getItem("kiln-compact") !== "off"; } catch (err) { compact = true; }
25
+ var repo = "";
26
+ try { repo = localStorage.getItem("kiln-repo") || ""; } catch (err) { repo = ""; }
27
+
28
+ var COMPACT = {
29
+ on: ["expand empty columns", '<path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/>'],
30
+ off: ["collapse empty columns", '<path d="M10 12H3m0 0 3-3m-3 3 3 3M14 12h7m0 0-3-3m3 3-3 3M12 4v16"/>'],
31
+ };
32
+
33
+ function renderCompact() {
34
+ var pair = COMPACT[compact ? "on" : "off"];
35
+ var button = document.getElementById("compact");
36
+ if (!button) return;
37
+ button.setAttribute("data-tip", pair[0]);
38
+ button.setAttribute("aria-label", pair[0]);
39
+ button.setAttribute("aria-pressed", compact ? "true" : "false");
40
+ button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + pair[1] + "</svg>";
41
+ }
42
+
43
+ function layout() {
44
+ if (!board) return;
45
+ var tracks = [];
46
+ var columns = board.querySelectorAll(".col");
47
+ for (var i = 0; i < columns.length; i++) {
48
+ var col = columns[i];
49
+ var cards = col.querySelectorAll(".card");
50
+ var visible = 0;
51
+ for (var c = 0; c < cards.length; c++) if (!cards[c].hidden) visible += 1;
52
+ var slim = compact && visible === 0;
53
+ col.classList.toggle("slim", slim);
54
+ var fullCount = col.querySelector(".full .count");
55
+ if (fullCount) fullCount.textContent = visible ? String(visible) : "";
56
+ var slimCount = col.querySelector(".slim-face .count");
57
+ if (slimCount) slimCount.textContent = String(visible);
58
+ var empty = col.querySelector(".empty");
59
+ if (empty) empty.hidden = visible !== 0;
60
+ tracks.push(slim ? "40px" : "minmax(0, 1fr)");
61
+ }
62
+ board.style.setProperty("--cols", tracks.join(" "));
63
+ }
64
+
65
+ function applyRepo() {
66
+ var cards = document.querySelectorAll(".board .card");
67
+ for (var i = 0; i < cards.length; i++) {
68
+ var cardRepo = cards[i].getAttribute("data-repo") || "";
69
+ cards[i].hidden = Boolean(repo) && cardRepo !== repo;
70
+ }
71
+ var chips = document.querySelectorAll(".repo-f");
72
+ for (var j = 0; j < chips.length; j++) {
73
+ chips[j].setAttribute("aria-pressed", (chips[j].getAttribute("data-repo") || "") === repo ? "true" : "false");
74
+ }
75
+ layout();
76
+ }
77
+
78
+ function visibleSheet() {
79
+ var sheets = document.querySelectorAll(".sheet");
80
+ for (var i = 0; i < sheets.length; i++) if (!sheets[i].hidden) return sheets[i];
81
+ return null;
82
+ }
83
+
84
+ function esc(value) {
85
+ return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
86
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
87
+ });
88
+ }
89
+
90
+ function verdictTone(first) {
91
+ if (/^(VERDICT:\s*PASS|APPROVE|STATUS:\s*READY)/.test(first)) return "good";
92
+ if (/^VERDICT:\s*CHANGES/.test(first)) return "warn";
93
+ if (/^(ESCALATE|STATUS:\s*BLOCKED|VERDICT:\s*ESCALATE|VERDICT:\s*BLOCKED)/.test(first)) return "stop";
94
+ return "";
95
+ }
96
+
97
+ function inline(value) {
98
+ var codes = [];
99
+ var text = esc(value)
100
+ .replace(/`([^`]+)`/g, function (_match, code) {
101
+ codes.push(code);
102
+ return "\u0000" + (codes.length - 1) + "\u0000";
103
+ })
104
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
105
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
106
+ return text.replace(/\u0000(\d+)\u0000/g, function (_match, index) {
107
+ return "<code>" + codes[Number(index)] + "</code>";
108
+ });
109
+ }
110
+
111
+ function markdown(src) {
112
+ var lines = String(src).replace(/\r/g, "").split("\n");
113
+ var i = 0;
114
+ var html = "";
115
+ var tone = verdictTone(lines[0] ? lines[0].trim() : "");
116
+ if (tone) {
117
+ html += '<div class="verdict ' + tone + '">' + esc(lines[0].trim()) + "</div>";
118
+ i = 1;
119
+ }
120
+ function isList(line) { return /^\s*([-*+]|\d+\.)\s+/.test(line); }
121
+ function isBlock(line) { return /^(#{1,6}\s|```|\|)/.test(line) || isList(line); }
122
+ while (i < lines.length) {
123
+ var line = lines[i];
124
+ if (!line.trim()) { i += 1; continue; }
125
+ if (line.indexOf("```") === 0) {
126
+ var fence = [];
127
+ i += 1;
128
+ while (i < lines.length && lines[i].indexOf("```") !== 0) fence.push(lines[i++]);
129
+ i += 1;
130
+ html += "<pre><code>" + esc(fence.join("\n")) + "</code></pre>";
131
+ continue;
132
+ }
133
+ var heading = /^(#{1,6})\s+(.*)$/.exec(line);
134
+ if (heading) {
135
+ var level = Math.min(heading[1].length, 3);
136
+ html += "<h" + level + ">" + inline(heading[2]) + "</h" + level + ">";
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (line.indexOf("|") === 0) {
141
+ var rows = [];
142
+ while (i < lines.length && lines[i].indexOf("|") === 0) rows.push(lines[i++]);
143
+ html += "<table>";
144
+ var bodyRow = 0;
145
+ for (var r = 0; r < rows.length; r++) {
146
+ if (/^\|[\s:|-]+\|$/.test(rows[r])) continue;
147
+ var cells = rows[r].replace(/^\||\|$/g, "").split("|");
148
+ var tag = bodyRow === 0 ? "th" : "td";
149
+ html += "<tr>";
150
+ for (var cell = 0; cell < cells.length; cell++) html += "<" + tag + ">" + inline(cells[cell].trim()) + "</" + tag + ">";
151
+ html += "</tr>";
152
+ bodyRow += 1;
153
+ }
154
+ html += "</table>";
155
+ continue;
156
+ }
157
+ if (isList(line)) {
158
+ var ordered = /^\s*\d+\./.test(line);
159
+ var items = [];
160
+ while (i < lines.length && (isList(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length))) {
161
+ var item = lines[i++];
162
+ if (isList(item)) items.push(item.replace(/^\s*([-*+]|\d+\.)\s+/, ""));
163
+ else items[items.length - 1] += " " + item.trim();
164
+ }
165
+ html += ordered ? "<ol>" : "<ul>";
166
+ for (var n = 0; n < items.length; n++) html += "<li>" + inline(items[n]) + "</li>";
167
+ html += ordered ? "</ol>" : "</ul>";
168
+ continue;
169
+ }
170
+ var paragraph = [];
171
+ while (i < lines.length && lines[i].trim() && !isBlock(lines[i])) paragraph.push(lines[i++]);
172
+ html += "<p>" + inline(paragraph.join(" ")) + "</p>";
173
+ }
174
+ return html;
175
+ }
176
+
177
+ function renderFile(sheet) {
178
+ var current = sheet.querySelector("button.file.on");
179
+ var id = current ? current.getAttribute("data-file") || "" : "";
180
+ var src = null;
181
+ if (id && window.CSS && CSS.escape) src = sheet.querySelector('.src[data-file="' + CSS.escape(id) + '"]');
182
+ var view = sheet.querySelector("[data-view]");
183
+ if (!view) return;
184
+ var text = src ? src.textContent || "" : "";
185
+ view.innerHTML = text ? markdown(localizeIso(text)) : '<p class="empty">Nothing written yet.</p>';
186
+ }
187
+
188
+ function localTime(iso, withDate) {
189
+ var date = new Date(iso);
190
+ if (!isFinite(date.getTime())) return iso;
191
+ if (!withDate) return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
192
+ return date.toLocaleString();
193
+ }
194
+
195
+ function localizeIso(text) {
196
+ return String(text).replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g, function (iso) {
197
+ return localTime(iso, true);
198
+ });
199
+ }
200
+
201
+ function localizeTimes(root) {
202
+ var nodes = (root || document).querySelectorAll("time[datetime]");
203
+ for (var i = 0; i < nodes.length; i++) {
204
+ var iso = nodes[i].getAttribute("datetime");
205
+ if (iso) nodes[i].textContent = localTime(iso, nodes[i].getAttribute("data-time") !== "clock");
206
+ }
207
+ }
208
+
209
+ function openJob(id) {
210
+ if (!id) return;
211
+ var sheets = document.querySelectorAll(".sheet");
212
+ var found = null;
213
+ for (var i = 0; i < sheets.length; i++) {
214
+ var match = sheets[i].getAttribute("data-job") === id;
215
+ sheets[i].hidden = !match;
216
+ if (match) found = sheets[i];
217
+ }
218
+ if (!found) return;
219
+ body.classList.add("open");
220
+ renderFile(found);
221
+ try { localStorage.setItem("kiln-drawer", id); } catch (err) { /* private mode */ }
222
+ }
223
+
224
+ function closeDrawer() {
225
+ body.classList.remove("open");
226
+ var sheets = document.querySelectorAll(".sheet");
227
+ for (var i = 0; i < sheets.length; i++) sheets[i].hidden = true;
228
+ try { localStorage.removeItem("kiln-drawer"); } catch (err) { /* private mode */ }
229
+ }
230
+
231
+ function notifyLabel() {
232
+ var button = document.getElementById("notify");
233
+ if (!button) return;
234
+ if (typeof Notification === "undefined") {
235
+ button.hidden = true;
236
+ return;
237
+ }
238
+ button.hidden = false;
239
+ if (Notification.permission === "granted") {
240
+ button.setAttribute("data-tip", "Notifications on");
241
+ button.setAttribute("aria-label", "Notifications on");
242
+ } else if (Notification.permission === "denied") {
243
+ button.setAttribute("data-tip", "Notifications blocked");
244
+ button.setAttribute("aria-label", "Notifications blocked");
245
+ } else {
246
+ button.setAttribute("data-tip", "Notify me");
247
+ button.setAttribute("aria-label", "Notify me");
248
+ }
249
+ }
250
+
251
+ document.addEventListener("click", function (event) {
252
+ var target = event.target;
253
+ if (!target || !target.closest) return;
254
+ if (target.closest("#compact")) {
255
+ compact = !compact;
256
+ try { localStorage.setItem("kiln-compact", compact ? "on" : "off"); } catch (err) { /* private mode */ }
257
+ renderCompact();
258
+ layout();
259
+ return;
260
+ }
261
+ if (target.closest("#notify")) {
262
+ if (typeof Notification === "undefined" || Notification.permission !== "default") return;
263
+ Notification.requestPermission().then(notifyLabel);
264
+ return;
265
+ }
266
+ var act = target.closest("[data-act]");
267
+ if (act) {
268
+ var name = act.getAttribute("data-act");
269
+ var sheet = visibleSheet();
270
+ if (!sheet || !name) return;
271
+ if (name === "pr") {
272
+ var url = sheet.getAttribute("data-pr");
273
+ if (url) window.open(url, "_blank", "noopener");
274
+ return;
275
+ }
276
+ var form = sheet.querySelector("form." + name);
277
+ if (form && form.submit) form.submit();
278
+ return;
279
+ }
280
+ var chip = target.closest(".repo-f");
281
+ if (chip) {
282
+ repo = chip.getAttribute("data-repo") || "";
283
+ try {
284
+ if (repo) localStorage.setItem("kiln-repo", repo);
285
+ else localStorage.removeItem("kiln-repo");
286
+ } catch (err) { /* private mode */ }
287
+ applyRepo();
288
+ return;
289
+ }
290
+ if (target.closest("a[href]")) return;
291
+ if (target.closest("[data-close]")) {
292
+ closeDrawer();
293
+ return;
294
+ }
295
+ var file = target.closest("button.file");
296
+ if (file) {
297
+ var fileSheet = file.closest(".sheet");
298
+ if (!fileSheet) return;
299
+ var buttons = fileSheet.querySelectorAll("button.file");
300
+ for (var i = 0; i < buttons.length; i++) buttons[i].classList.toggle("on", buttons[i] === file);
301
+ var doc = fileSheet.querySelector(".doc");
302
+ if (doc) doc.scrollTop = 0;
303
+ renderFile(fileSheet);
304
+ return;
305
+ }
306
+ var card = target.closest(".card");
307
+ if (card) openJob(card.getAttribute("data-job") || "");
308
+ });
309
+
310
+ document.addEventListener("keydown", function (event) {
311
+ if (event.key === "Escape") closeDrawer();
312
+ var el = event.target;
313
+ if ((event.key === "Enter" || event.key === " ") && el && el.classList && el.classList.contains("card")) {
314
+ event.preventDefault();
315
+ openJob(el.getAttribute("data-job") || "");
316
+ }
317
+ });
318
+
319
+ function stamp(ok) {
320
+ var el = document.getElementById("status");
321
+ if (!el) return;
322
+ if (!ok) {
323
+ el.className = "status err";
324
+ el.textContent = "Failed to fetch";
325
+ return;
326
+ }
327
+ el.className = "status";
328
+ var jobs = document.querySelectorAll(".board .card").length;
329
+ el.textContent = jobs + (jobs === 1 ? " job · " : " jobs · ") + new Date().toLocaleTimeString();
330
+ localizeTimes(document);
331
+ }
332
+
333
+ function poll() {
334
+ fetch("/api/jobs", { cache: "no-store", headers: { Accept: "application/json" } })
335
+ .then(function (response) {
336
+ if (!response.ok) throw new Error("status");
337
+ return response.json();
338
+ })
339
+ .then(function (data) {
340
+ var rev = data && data.attention && data.attention.revision;
341
+ var current = body.getAttribute("data-revision");
342
+ if (rev && current && rev !== current) {
343
+ location.reload();
344
+ return;
345
+ }
346
+ stamp(true);
347
+ })
348
+ .catch(function () { stamp(false); });
349
+ }
350
+
351
+ renderCompact();
352
+ notifyLabel();
353
+ applyRepo();
354
+ stamp(true);
355
+ try {
356
+ var open = localStorage.getItem("kiln-drawer");
357
+ if (open) openJob(open);
358
+ } catch (err) { /* private mode */ }
359
+ poll();
360
+ setInterval(poll, 3000);
361
+ })();
@@ -0,0 +1,264 @@
1
+ import { createHash } from "node:crypto";
2
+ import { INVESTIGATION_WORKFLOW } from "./findings.js";
3
+ /**
4
+ * Attention order for `kiln serve`. Built from the same derived jobs as
5
+ * `kiln status`. The console renders this and does not classify jobs again.
6
+ *
7
+ * Needs you: a person has to act, a run is dead or failed with no automatic
8
+ * retry left, or a pull request is open. A failed job that still has a
9
+ * scheduled retry is in flight, not here.
10
+ * In flight: work is moving, waiting to launch, waiting to retry, or waiting
11
+ * on another job.
12
+ * Done recently: finished or rejected, collapsed in the page.
13
+ */
14
+ const NEEDS_YOU = ["needs-human", "dead", "failed", "in-review", "invalid"];
15
+ const IN_FLIGHT = ["running", "approved", "queued", "blocked"];
16
+ const SETTLED = ["done", "rejected"];
17
+ /** Reasons an automatic retry is allowed to schedule. A rejected gate is not one of these. */
18
+ const RETRYABLE_REASONS = new Set(["stalled", "crashed", "orphaned", "failed"]);
19
+ /**
20
+ * A failed job the driver will launch again on its own.
21
+ * Needs human, the `(N)` title, and desktop notifications all use this,
22
+ * so a waiting retry is not a call for a person.
23
+ */
24
+ export function scheduledRetry(job) {
25
+ if (job.state !== "failed")
26
+ return false;
27
+ if (!job.nextRetryAt || !Number.isFinite(Date.parse(job.nextRetryAt)))
28
+ return false;
29
+ const max = job.maxAttempts;
30
+ if (max === undefined || !((job.attemptCount ?? 0) < max))
31
+ return false;
32
+ return job.lastFailureReason !== undefined && RETRYABLE_REASONS.has(job.lastFailureReason);
33
+ }
34
+ export function buildAttention(jobs) {
35
+ const indexed = jobs.map((job, index) => ({ job, index }));
36
+ const needsYou = indexed
37
+ .filter((entry) => !scheduledRetry(entry.job) && NEEDS_YOU.includes(entry.job.state))
38
+ .sort((left, right) => byRank(NEEDS_YOU, left, right))
39
+ .map((entry) => toItem(entry.job));
40
+ const inFlight = indexed
41
+ .filter((entry) => scheduledRetry(entry.job) || IN_FLIGHT.includes(entry.job.state))
42
+ .sort((left, right) => byFlight(left, right))
43
+ .map((entry) => toItem(entry.job));
44
+ const doneRecently = indexed
45
+ .filter((entry) => SETTLED.includes(entry.job.state))
46
+ .sort(byRecent)
47
+ .map((entry) => toItem(entry.job));
48
+ const graph = attentionGraph(jobs);
49
+ const revision = attentionRevision(needsYou, inFlight, doneRecently, graph);
50
+ return {
51
+ needsYou,
52
+ inFlight,
53
+ doneRecently,
54
+ graph,
55
+ needsYouCount: needsYou.length,
56
+ revision,
57
+ };
58
+ }
59
+ export function attentionGraph(jobs) {
60
+ const nodes = jobs.map((job) => ({
61
+ jobId: job.jobId,
62
+ title: job.title || job.jobId,
63
+ repo: job.repo,
64
+ state: job.state,
65
+ }));
66
+ const known = new Set(nodes.map((node) => node.jobId));
67
+ const edges = [];
68
+ for (const job of jobs) {
69
+ for (const dep of job.dependsOn) {
70
+ if (!known.has(dep.jobId)) {
71
+ known.add(dep.jobId);
72
+ nodes.push({
73
+ jobId: dep.jobId,
74
+ title: dep.jobId,
75
+ repo: "",
76
+ state: dep.state,
77
+ });
78
+ }
79
+ edges.push({ from: dep.jobId, to: job.jobId, stack: job.stack });
80
+ }
81
+ }
82
+ return { nodes, edges };
83
+ }
84
+ function toItem(job) {
85
+ const tail = job.state === "failed" || job.state === "dead" ? errorTail(job) : undefined;
86
+ const findings = findingsOf(job);
87
+ const since = job.startedAt ?? job.createdAt;
88
+ return {
89
+ job,
90
+ reason: attentionReason(job, tail),
91
+ ...(tail ? { errorTail: tail } : {}),
92
+ ...(findings ? { findings } : {}),
93
+ retry: job.state === "failed" || job.state === "dead",
94
+ ...(since ? { since } : {}),
95
+ };
96
+ }
97
+ export function attentionReason(job, tail = errorTail(job)) {
98
+ switch (job.state) {
99
+ case "needs-human": {
100
+ if (job.feedbackReason)
101
+ return job.feedbackReason;
102
+ const question = gateQuestion(job);
103
+ return question ? `Gate: ${oneLine(question)}` : "Waiting on a person";
104
+ }
105
+ case "dead":
106
+ return "Run died before it finished";
107
+ case "failed": {
108
+ const why = firstLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
109
+ return `Failed: ${oneLine(why)}`;
110
+ }
111
+ case "in-review":
112
+ if (job.prNumber)
113
+ return `Pull request #${job.prNumber} is open`;
114
+ if (job.prPending)
115
+ return "Pull request not opened yet";
116
+ return "In review";
117
+ case "invalid":
118
+ return job.invalidReason ? `Invalid: ${oneLine(job.invalidReason)}` : "Job file is invalid";
119
+ case "blocked":
120
+ return oneLine(waitingOn(job));
121
+ case "running": {
122
+ const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
123
+ return current ? `Running ${current.id}` : "Running";
124
+ }
125
+ case "approved":
126
+ return "Approved, still finishing";
127
+ case "queued":
128
+ return "Queued, ready to launch";
129
+ case "done":
130
+ return job.prUrl ? "Merged" : "Done";
131
+ case "rejected":
132
+ return "Rejected";
133
+ default:
134
+ return job.state;
135
+ }
136
+ }
137
+ function waitingOn(job) {
138
+ const details = job.dependsOn.map((dep) => dep.blockedDetail).filter((detail) => Boolean(detail));
139
+ if (details.length > 0)
140
+ return details.join("; ");
141
+ if (job.dependsOn.length === 0)
142
+ return "Blocked";
143
+ return `Waiting on ${job.dependsOn.map((dep) => `${dep.jobId} (${dep.state})`).join(", ")}`;
144
+ }
145
+ function gateQuestion(job) {
146
+ const gate = job.gates[0];
147
+ if (!gate)
148
+ return undefined;
149
+ const first = gate.rendered?.split("\n")[0]?.trim();
150
+ return first || gate.prompt;
151
+ }
152
+ function findingsOf(job) {
153
+ if (job.state !== "needs-human" || job.workflow !== INVESTIGATION_WORKFLOW)
154
+ return undefined;
155
+ const rendered = job.gates.map((gate) => gate.rendered).find((text) => text && text.includes("\n"));
156
+ if (!rendered)
157
+ return undefined;
158
+ const body = rendered.split("\n").slice(1).join("\n").trim();
159
+ return body || undefined;
160
+ }
161
+ function errorTail(job) {
162
+ const parts = [];
163
+ if (job.launchError?.trim())
164
+ parts.push(job.launchError.trim());
165
+ if (job.deliveryError?.trim())
166
+ parts.push(job.deliveryError.trim());
167
+ if (job.failureDetail?.trim())
168
+ parts.push(job.failureDetail.trim());
169
+ if (job.prClosedUnmerged && job.prNumber) {
170
+ parts.push(`Pull request #${job.prNumber} was closed without merging`);
171
+ }
172
+ if (job.noReadableApproval)
173
+ parts.push("No readable approval decision");
174
+ if (job.logTail?.trim() && !parts.includes(job.logTail.trim()))
175
+ parts.push(job.logTail.trim());
176
+ if (parts.length === 0)
177
+ return undefined;
178
+ return parts.join("\n");
179
+ }
180
+ function byFlight(left, right) {
181
+ const rank = flightRank(left.job) - flightRank(right.job);
182
+ if (rank !== 0)
183
+ return rank;
184
+ return left.index - right.index;
185
+ }
186
+ /** Running first, then a job waiting out its backoff, then the rest of the in-flight states. */
187
+ function flightRank(job) {
188
+ if (scheduledRetry(job))
189
+ return 1;
190
+ switch (job.state) {
191
+ case "running":
192
+ return 0;
193
+ case "approved":
194
+ return 2;
195
+ case "queued":
196
+ return 3;
197
+ case "blocked":
198
+ return 4;
199
+ default:
200
+ return 5;
201
+ }
202
+ }
203
+ function byRank(order, left, right) {
204
+ const rank = order.indexOf(left.job.state) - order.indexOf(right.job.state);
205
+ if (rank !== 0)
206
+ return rank;
207
+ return left.index - right.index;
208
+ }
209
+ function byRecent(left, right) {
210
+ const leftAt = left.job.finishedAt ?? "";
211
+ const rightAt = right.job.finishedAt ?? "";
212
+ if (leftAt !== rightAt) {
213
+ if (!leftAt)
214
+ return 1;
215
+ if (!rightAt)
216
+ return -1;
217
+ return rightAt < leftAt ? -1 : 1;
218
+ }
219
+ return right.index - left.index;
220
+ }
221
+ function attentionRevision(needsYou, inFlight, doneRecently, graph) {
222
+ const body = JSON.stringify({
223
+ needsYou: needsYou.map((item) => [
224
+ item.job.jobId,
225
+ item.job.state,
226
+ item.reason,
227
+ item.job.attemptCount,
228
+ item.job.lastFailureReason ?? "",
229
+ item.job.nextRetryAt ?? "",
230
+ feedbackStamp(item.job),
231
+ ]),
232
+ inFlight: inFlight.map((item) => [
233
+ item.job.jobId,
234
+ item.job.state,
235
+ item.reason,
236
+ item.job.steps.map((step) => `${step.id}:${step.phase}`).join(","),
237
+ item.job.attemptCount,
238
+ item.job.lastFailureReason ?? "",
239
+ item.job.nextRetryAt ?? "",
240
+ feedbackStamp(item.job),
241
+ ]),
242
+ doneRecently: doneRecently.map((item) => [item.job.jobId, item.job.state]),
243
+ edges: graph.edges.map((edge) => [edge.from, edge.to, edge.stack]),
244
+ });
245
+ return createHash("sha256").update(body).digest("hex").slice(0, 16);
246
+ }
247
+ function feedbackStamp(job) {
248
+ const rounds = (job.feedbackRounds ?? [])
249
+ .map((round) => `${round.round}:${round.trigger}:${round.outcome ?? ""}:${round.headSha}`)
250
+ .join("|");
251
+ return `${job.feedbackReason ?? ""}:${rounds}`;
252
+ }
253
+ function oneLine(text, max = 180) {
254
+ const flat = text.replace(/\s+/g, " ").trim();
255
+ if (flat.length <= max)
256
+ return flat;
257
+ return `${flat.slice(0, max - 1)}…`;
258
+ }
259
+ function firstLine(text) {
260
+ if (!text)
261
+ return undefined;
262
+ const line = text.split("\n").find((candidate) => candidate.trim().length > 0);
263
+ return line?.trim();
264
+ }