@lchase/kiln 0.13.0 → 0.15.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 CHANGED
@@ -271,13 +271,23 @@ Two triggers are built in. `review-requests` is a schedule, cron `0 * * * *` (mi
271
271
  }
272
272
  ```
273
273
 
274
- `every` defaults to `5m` (`30s`, `5m`, `1h`). `label` and `status` compile to JQL. A `jql` string replaces them. `enabled` defaults to true. The Triggers page and `PUT /api/settings` send `{ "triggers": [{ "id", "enabled" }] }` and leave cron, JQL, and repo on the file.
274
+ `every` defaults to `5m` (`30s`, `5m`, `1h`). `label` and `status` compile to JQL. A `jql` string replaces them. `enabled` defaults to true. The Triggers page and `PUT /api/settings` send `{ "triggers": [{ "id", "enabled" }] }` and leave cron, JQL, label, and repo on the file. The Triggers page shows each trigger's schedule, whether it is enabled, and, for `review-requests`, the label and the two caps. Times on that page are the browser's local time.
275
275
 
276
276
  A cron fires once per slot. While serve was down, startup fires the latest missed slot once and does not replay the ones behind it. A Jira poll that was missed does the same: one search, not one per skipped interval. The router writes `trigger.routed` with a `dedupe` property (`review:<repo>:<number>:<head sha>`, `jira:<trigger>:<issue>`, `pr:<job>:<comment>`). A later tick that sees that value does not create another job. Comments and commits from `kiln`, a `[bot]` login, a `kiln:` subject, or a `Kiln-Job` trailer are ignored.
277
277
 
278
- A failed fire stores `last_error` and `lastErrorAt` under `<kiln-home>/triggers/state.json`, appends `trigger.failed`, and waits for the next slot. It does not retry on the next tick. Pause and the daily dollar cap append `trigger.skipped` with reason `paused` or `capped` and do not queue the fire.
278
+ A failed fire stores `last_error` and `lastErrorAt` under `<kiln-home>/triggers/state.json`, appends `trigger.failed`, and waits for the next slot. It does not retry on the next tick. Pause and the daily dollar cap append `trigger.skipped` with reason `paused` or `capped` and do not queue the fire. A review over the per-fire cap is `burst-cap`. A review that would pass the number of queued or running review jobs is `queued-cap`. Those two are not queued, so the next fire can pick them up. A disable is read again after the pull-request list returns and before each job is created, including the startup fire, so a trigger that is already disabled does not fire.
279
279
 
280
- `review-requests` lists open pull requests where your `gh` user is the requested reviewer (`gh pr list --search review-requested:@me` in each registered GitHub repo). A pull request already drafted at that head SHA is skipped. The `review` workflow returns findings (severity, file, line, suggested comment) and stops on a gate in the Inbox. Approving that gate posts the draft as a pull request comment (`COMMENT` only) under your `gh` user: the markdown is the body, and findings with a file and line are inline comments. Kiln does not approve the pull request or merge it. Rejecting or revising the gate does not post. Pause and the daily cap do not post. Posting the same repo, pull request, and head SHA again does not create a second review. A failed post stays in Needs a hand with `review.failed` and does not count as posted. `reviewPrompt` is a path to your own prompt file. Omitted, Kiln uses `prompts/kiln/review.md`.
280
+ `review-requests` lists open pull requests where your `gh` user is the requested reviewer and the pull request carries a label (`gh pr list --search 'review-requested:@me label:"kiln-review"'` in each registered GitHub repo). The label defaults to `kiln-review` when `triggers[].label` is omitted. That is opt-in: unlabeled pull requests are not picked. Set `label` on the `review-requests` trigger to use a different one. `repos.<name>.reviewLabel` overrides it for that checkout. One fire creates at most `maxNew` new review jobs (default 3). At most `maxQueued` review jobs may be queued or running at once (default 5). A review waiting on the Inbox gate does not count. A pull request already drafted at that head SHA is skipped and does not use a slot. The `review` workflow returns findings (severity, file, line, suggested comment) and stops on a gate in the Inbox. Approving that gate posts the draft as a pull request comment (`COMMENT` only) under your `gh` user: the markdown is the body, and findings with a file and line are inline comments. Kiln does not approve the pull request or merge it. Rejecting or revising the gate does not post. Pause and the daily cap do not post. Posting the same repo, pull request, and head SHA again does not create a second review. A failed post stays in Needs a hand with `review.failed` and does not count as posted. `reviewPrompt` is a path to your own prompt file. Omitted, Kiln uses `prompts/kiln/review.md`.
281
+
282
+ ```json
283
+ {
284
+ "triggers": [
285
+ { "id": "review-requests", "kind": "schedule", "label": "kiln-review", "maxNew": 3, "maxQueued": 5 }
286
+ ]
287
+ }
288
+ ```
289
+
290
+ `kiln cancel --queued` cancels jobs that have not started. `--repo` and `--workflow` narrow it. `POST /api/jobs/cancel` with `{ "queued": true }` does the same for the board. Both are idempotent: a second call cancels nothing new and writes no second `job.cancelled` event. Running, blocked, and finished jobs stay. Cancelling does not delete branches.
281
291
 
282
292
  Jira search sends `JIRA_API_TOKEN` only as a Basic header in a child process. The token is not written into the job, the spec, or the event log. Polling is the only Jira ingress. A webhook can later call the same issue-to-event function.
283
293
 
@@ -311,6 +321,7 @@ State is derived from the yak journal, the gate files, and (for `deliver=pr`) `g
311
321
  | State | Meaning |
312
322
  | --- | --- |
313
323
  | `queued` | Dependencies are met, and the job has not started |
324
+ | `cancelled` | Cancelled before it started. Tick does not launch it. No branch is deleted |
314
325
  | `blocked` | An after-merged dependency is not `done`, or a stacked dependency has not landed. A missing job id is named |
315
326
  | `running` | Yak is alive, or the launch process is alive and has not written a journal |
316
327
  | `needs-human` | A gate is open, feedback is exhausted or ambiguous, the attempt cap is used, or the same fingerprint has failed `maxSameFingerprint` times. `kiln retry` clears an attempt-cap or fingerprint stop. `kiln revise --note` sends a gate back with a note |
package/assets/serve.css CHANGED
@@ -108,16 +108,31 @@ h3 { margin: 0 0 8px; font-size: 13px; line-height: 18px; color: var(--muted); t
108
108
  .list-head { margin-bottom: 16px; }
109
109
  .list-head .meta, .meta { color: var(--muted); }
110
110
  .meta { margin: 0; font-size: 12px; line-height: 16px; }
111
- .jobs { display: flex; flex-direction: column; border-top: 1px solid var(--line); }
111
+ .jobs { display: flex; flex-direction: column; gap: 10px; }
112
112
  .job {
113
- display: grid;
114
- grid-template-columns: minmax(0, 1fr) auto;
115
- gap: 12px;
116
- padding: 12px 4px;
117
- border-bottom: 1px solid var(--line);
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 8px;
116
+ padding: 14px 16px 12px;
117
+ background: var(--card);
118
+ border: 1px solid var(--line);
119
+ border-radius: 10px;
120
+ }
121
+ .job.human { box-shadow: inset 3px 0 0 var(--human); }
122
+ .job.retrying { box-shadow: inset 3px 0 0 var(--changes); }
123
+ .job .d-actions {
124
+ margin: 4px -16px -12px;
125
+ padding: 10px 16px;
126
+ border-top: 1px solid var(--line);
127
+ background: color-mix(in srgb, var(--bg) 55%, transparent);
128
+ border-radius: 0 0 10px 10px;
129
+ }
130
+ .job.decision .approve button {
131
+ background: var(--primary);
132
+ color: white;
133
+ border-color: transparent;
134
+ font-weight: 500;
118
135
  }
119
- .job.human { box-shadow: inset 2px 0 0 var(--human); }
120
- .job.retrying { box-shadow: inset 2px 0 0 var(--changes); }
121
136
  .job-top { display: flex; align-items: baseline; gap: 8px; }
122
137
  .job-title { color: var(--ink); font-size: 15px; line-height: 22px; }
123
138
  .job-title:hover { color: var(--code); }
@@ -140,8 +155,116 @@ h3 { margin: 0 0 8px; font-size: 13px; line-height: 18px; color: var(--muted); t
140
155
  }
141
156
  .links { margin: 6px 0 0; display: flex; gap: 10px; }
142
157
  .deps { margin: 6px 0 0; }
143
- .evidence { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
144
- .evidence .text, .phase .text, .timeline .text, .changes .text { max-height: 180px; overflow: auto; }
158
+ .ask { margin: 8px 0 0; font-size: 15px; line-height: 22px; }
159
+ .ran { margin: 4px 0 0; color: var(--muted); font-size: 12px; line-height: 16px; }
160
+ .ticks { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; margin: 10px 0 0; padding: 0; }
161
+ .tick {
162
+ display: inline-flex; align-items: center; gap: 4px;
163
+ padding: 2px 8px; border-radius: 999px; background: var(--code-bg);
164
+ font: 11px/16px var(--mono); color: var(--faint);
165
+ }
166
+ .tick.done { color: var(--pass); }
167
+ .tick.running { color: var(--live); }
168
+ .tick.failed { color: var(--human); }
169
+ .mark { font-size: 11px; }
170
+ .chip {
171
+ display: inline-flex; align-items: center; height: 18px; padding: 0 7px;
172
+ border-radius: 999px; background: var(--code-bg); color: var(--muted);
173
+ font: 11px/18px var(--mono);
174
+ }
175
+ .chip.running, .chip.ok { color: var(--live); }
176
+ .chip.needs-you { color: var(--human); }
177
+ .chip.needs-hand, .chip.failed { color: var(--changes); }
178
+ .chip.done { color: var(--pass); }
179
+ .chip.queued { color: var(--faint); }
180
+ .prose { margin-top: 10px; }
181
+ .prose h1, .prose h2, .prose h3 {
182
+ margin: 12px 0 6px; color: var(--ink); font-size: 15px; line-height: 22px;
183
+ text-transform: none; letter-spacing: 0;
184
+ }
185
+ .prose p { margin: 8px 0; }
186
+ .prose ul, .prose ol { margin: 8px 0; padding-left: 1.2rem; }
187
+ .prose li { margin: 2px 0; }
188
+ .prose pre {
189
+ margin: 8px 0; padding: 8px 10px; overflow: auto; border-radius: 6px; background: var(--bg);
190
+ }
191
+ .prose code { font-family: var(--mono); font-size: 12px; }
192
+ .prose a { color: var(--code); }
193
+ .findings { margin-top: 12px; }
194
+ .finding-group { margin: 0 0 10px; }
195
+ .finding-group h3 { margin: 0 0 4px; }
196
+ .finding-group ul { list-style: none; margin: 0; padding: 0; }
197
+ .finding-group li {
198
+ display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px;
199
+ padding: 6px 0; border-top: 1px solid var(--line); font-size: 13px; line-height: 18px;
200
+ }
201
+ .finding-group code { color: var(--code); }
202
+ .notes { margin: 8px 0 0; padding-left: 18px; }
203
+ .pr-line { margin: 8px 0 0; }
204
+ .raw, .log { margin-top: 10px; }
205
+ .raw summary, .log summary { cursor: pointer; color: var(--muted); font-size: 12px; }
206
+ .head-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
207
+ #compact {
208
+ width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--line-strong);
209
+ background: var(--card); color: var(--ink); cursor: pointer;
210
+ }
211
+ #compact svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; }
212
+ .filters { display: flex; flex-wrap: wrap; gap: 16px; margin: 0 0 14px; }
213
+ .filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
214
+ .repo-f, .wf-f {
215
+ height: 28px; padding: 0 12px; border: 1px solid var(--line-strong); border-radius: 999px;
216
+ background: var(--card); color: var(--muted); font: 12px/16px var(--mono); cursor: pointer;
217
+ }
218
+ .repo-f[aria-pressed="true"], .wf-f[aria-pressed="true"] {
219
+ border-color: transparent; background: color-mix(in srgb, var(--merged) 28%, var(--card)); color: var(--ink);
220
+ }
221
+ .repo-f:hover, .wf-f:hover { color: var(--ink); }
222
+ .board-scroll { overflow-x: auto; padding-bottom: 8px; }
223
+ .board {
224
+ display: grid;
225
+ grid-template-columns: var(--cols, repeat(5, minmax(180px, 1fr)));
226
+ gap: 12px;
227
+ align-items: start;
228
+ min-width: 920px;
229
+ }
230
+ .col {
231
+ background: var(--panel);
232
+ border: 1px solid var(--line);
233
+ border-radius: 10px;
234
+ padding: 10px;
235
+ min-height: 140px;
236
+ }
237
+ .col .full { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
238
+ .col h2 { font-size: 13px; line-height: 18px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
239
+ .col .count { font: 11px/16px var(--mono); color: var(--faint); }
240
+ .slim-face { display: none; }
241
+ .col.slim { min-height: 160px; padding: 8px; }
242
+ .col.slim .full, .col.slim .card, .col.slim .empty { display: none; }
243
+ .col.slim .slim-face {
244
+ display: flex; align-items: center; gap: 8px; writing-mode: vertical-rl; transform: rotate(180deg);
245
+ color: var(--muted); font-size: 12px; letter-spacing: .04em; text-transform: uppercase;
246
+ }
247
+ .card {
248
+ display: block;
249
+ margin-top: 8px;
250
+ padding: 10px 12px;
251
+ background: var(--card);
252
+ border: 1px solid var(--line);
253
+ border-radius: 8px;
254
+ }
255
+ .card .job-title { display: block; color: var(--ink); font-size: 14px; line-height: 20px; }
256
+ .card .job-meta { margin-top: 6px; }
257
+ .card:hover { background: var(--card-hover); }
258
+ .state-group { margin: 0 0 18px; }
259
+ .state-group h3 { display: flex; align-items: center; gap: 8px; }
260
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 0 0 18px; }
261
+ .stat-grid .stat {
262
+ margin: 0; padding: 14px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
263
+ }
264
+ .stat-num { margin: 0; font-size: 28px; line-height: 34px; font-weight: 500; }
265
+ .run-group { margin: 0 0 18px; }
266
+ .run-group h3 { color: var(--ink); text-transform: none; letter-spacing: 0; font-size: 13px; }
267
+ .phase .text, .timeline .text, .changes .text, .raw .text, .log .text { max-height: 220px; overflow: auto; }
145
268
  .feedback { margin: 6px 0 0; padding-left: 18px; }
146
269
  .repo-block { margin-bottom: 24px; }
147
270
  .repo-block h3 { margin-bottom: 0; }
@@ -157,7 +280,8 @@ h3 { margin: 0 0 8px; font-size: 13px; line-height: 18px; color: var(--muted); t
157
280
  padding: 0 0 16px 14px;
158
281
  margin-left: 6px;
159
282
  }
160
- .phase.completed { border-color: var(--pass); }
283
+ .phase.done, .phase.completed { border-color: var(--pass); }
284
+ .phase.running { border-color: var(--live); }
161
285
  .phase.failed { border-color: var(--human); }
162
286
  .phase header, .timeline li { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
163
287
  .step { font-weight: 500; }
@@ -285,4 +409,5 @@ body.open .drawer { transform: none; }
285
409
  .rail { position: static; }
286
410
  .d-actions .revise input { min-width: 0; width: 100%; }
287
411
  .job { grid-template-columns: 1fr; }
412
+ .board { min-width: 720px; }
288
413
  }
package/assets/serve.js CHANGED
@@ -24,6 +24,8 @@
24
24
  try { compact = localStorage.getItem("kiln-compact") !== "off"; } catch (err) { compact = true; }
25
25
  var repo = "";
26
26
  try { repo = localStorage.getItem("kiln-repo") || ""; } catch (err) { repo = ""; }
27
+ var workflow = "";
28
+ try { workflow = localStorage.getItem("kiln-workflow") || ""; } catch (err) { workflow = ""; }
27
29
 
28
30
  var COMPACT = {
29
31
  on: ["expand empty columns", '<path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/>'],
@@ -66,12 +68,19 @@
66
68
  var cards = document.querySelectorAll(".board .card");
67
69
  for (var i = 0; i < cards.length; i++) {
68
70
  var cardRepo = cards[i].getAttribute("data-repo") || "";
69
- cards[i].hidden = Boolean(repo) && cardRepo !== repo;
71
+ var cardWorkflow = cards[i].getAttribute("data-workflow") || "";
72
+ var hideRepo = Boolean(repo) && cardRepo !== repo;
73
+ var hideWorkflow = Boolean(workflow) && cardWorkflow !== workflow;
74
+ cards[i].hidden = hideRepo || hideWorkflow;
70
75
  }
71
76
  var chips = document.querySelectorAll(".repo-f");
72
77
  for (var j = 0; j < chips.length; j++) {
73
78
  chips[j].setAttribute("aria-pressed", (chips[j].getAttribute("data-repo") || "") === repo ? "true" : "false");
74
79
  }
80
+ var flows = document.querySelectorAll(".wf-f");
81
+ for (var k = 0; k < flows.length; k++) {
82
+ flows[k].setAttribute("aria-pressed", (flows[k].getAttribute("data-workflow") || "") === workflow ? "true" : "false");
83
+ }
75
84
  layout();
76
85
  }
77
86
 
@@ -185,13 +194,33 @@
185
194
  view.innerHTML = text ? markdown(localizeIso(text)) : '<p class="empty">Nothing written yet.</p>';
186
195
  }
187
196
 
188
- function localTime(iso, withDate) {
197
+ function relativeTime(iso) {
189
198
  var date = new Date(iso);
190
199
  if (!isFinite(date.getTime())) return iso;
191
- if (!withDate) return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
200
+ var delta = Date.now() - date.getTime();
201
+ var future = delta < 0;
202
+ var abs = Math.abs(delta);
203
+ var sec = Math.round(abs / 1000);
204
+ if (sec < 45) return future ? "in a moment" : "just now";
205
+ var min = Math.round(sec / 60);
206
+ var prefix = future ? "in " : "";
207
+ var suffix = future ? "" : " ago";
208
+ if (min < 60) return prefix + min + "m" + suffix;
209
+ var hr = Math.round(min / 60);
210
+ if (hr < 36) return prefix + hr + "h" + suffix;
211
+ var day = Math.round(hr / 24);
212
+ if (day < 14) return prefix + day + "d" + suffix;
192
213
  return date.toLocaleString();
193
214
  }
194
215
 
216
+ function localTime(iso, mode) {
217
+ var date = new Date(iso);
218
+ if (!isFinite(date.getTime())) return iso;
219
+ if (mode === "clock" || mode === false) return date.toLocaleTimeString();
220
+ if (mode === "rel") return relativeTime(iso);
221
+ return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
222
+ }
223
+
195
224
  function localizeIso(text) {
196
225
  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
226
  return localTime(iso, true);
@@ -202,7 +231,7 @@
202
231
  var nodes = (root || document).querySelectorAll("time[datetime]");
203
232
  for (var i = 0; i < nodes.length; i++) {
204
233
  var iso = nodes[i].getAttribute("datetime");
205
- if (iso) nodes[i].textContent = localTime(iso, nodes[i].getAttribute("data-time") !== "clock");
234
+ if (iso) nodes[i].textContent = localTime(iso, nodes[i].getAttribute("data-time") || "local");
206
235
  }
207
236
  }
208
237
 
@@ -1185,6 +1214,16 @@
1185
1214
  applyRepo();
1186
1215
  return;
1187
1216
  }
1217
+ var flow = target.closest(".wf-f");
1218
+ if (flow) {
1219
+ workflow = flow.getAttribute("data-workflow") || "";
1220
+ try {
1221
+ if (workflow) localStorage.setItem("kiln-workflow", workflow);
1222
+ else localStorage.removeItem("kiln-workflow");
1223
+ } catch (err) { /* private mode */ }
1224
+ applyRepo();
1225
+ return;
1226
+ }
1188
1227
  if (target.closest("a[href]")) return;
1189
1228
  if (target.closest("[data-close]")) {
1190
1229
  closeDrawer();
@@ -1215,6 +1254,7 @@
1215
1254
  });
1216
1255
 
1217
1256
  function stamp(ok) {
1257
+ localizeTimes(document);
1218
1258
  var el = document.getElementById("status");
1219
1259
  if (!el) return;
1220
1260
  if (!ok) {
@@ -1225,7 +1265,6 @@
1225
1265
  el.className = "status";
1226
1266
  var jobs = document.querySelectorAll(".job").length;
1227
1267
  el.textContent = jobs + (jobs === 1 ? " job · " : " jobs · ") + new Date().toLocaleTimeString();
1228
- localizeTimes(document);
1229
1268
  }
1230
1269
 
1231
1270
  function poll() {
package/dist/attention.js CHANGED
@@ -15,7 +15,7 @@ import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
15
15
  */
16
16
  const NEEDS_YOU = ["needs-human", "dead", "failed", "in-review", "invalid"];
17
17
  const IN_FLIGHT = ["running", "approved", "queued", "blocked"];
18
- const SETTLED = ["done", "rejected"];
18
+ const SETTLED = ["done", "rejected", "cancelled"];
19
19
  /** Reasons an automatic retry is allowed to schedule. A rejected gate is not one of these. */
20
20
  const RETRYABLE_REASONS = new Set(["stalled", "crashed", "orphaned", "failed"]);
21
21
  /**
@@ -149,6 +149,8 @@ export function attentionReason(job, tail = errorTail(job)) {
149
149
  return job.prUrl ? "Merged" : "Done";
150
150
  case "rejected":
151
151
  return "Rejected";
152
+ case "cancelled":
153
+ return "Cancelled";
152
154
  default:
153
155
  return job.state;
154
156
  }
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { attentionReason, scheduledRetry } from "./attention.js";
3
- import { feedbackRoundLabel } from "./feedback.js";
2
+ import { isFinished, isHand, isInbox, projectJob } from "./job-view.js";
4
3
  import { ledgerRows } from "./ledger.js";
5
4
  import { localDay, spendSnapshot } from "./rails.js";
5
+ export { isFinished, isHand, isInbox };
6
6
  /**
7
7
  * Lists, stats, and job pages for the serve shell. Job state still comes
8
8
  * from `jobState` (via the status view). Everything else is read off the
@@ -60,27 +60,8 @@ export function parseSeenQuery(values) {
60
60
  }
61
61
  return seen;
62
62
  }
63
- export function isInbox(job) {
64
- if (job.reviewPostError)
65
- return false;
66
- if (job.canApprove || job.canReject || job.canRevise)
67
- return true;
68
- return job.state === "needs-human" && job.gates.length > 0 && !job.stopReason && !job.feedbackReason;
69
- }
70
- /**
71
- * Needs a person, and not an open gate. A failure that still has a scheduled
72
- * retry is in flight, so it stays off this list.
73
- */
74
- export function isHand(job) {
75
- if (isInbox(job) || scheduledRetry(job))
76
- return false;
77
- return job.state === "needs-human" || job.state === "failed" || job.state === "dead" || job.state === "invalid";
78
- }
79
- export function isFinished(job) {
80
- return job.state === "done" || job.state === "rejected";
81
- }
82
63
  export function buildBoard(jobs, events) {
83
- const rows = jobs.map((job) => toBoardJob(job, events));
64
+ const rows = jobs.map((job) => projectJob(job, events));
84
65
  const inbox = rows.filter((job) => job.inbox);
85
66
  const hand = rows.filter((job) => job.hand);
86
67
  const history = rows.filter((job) => job.finished);
@@ -274,7 +255,7 @@ export function jobDetail(input) {
274
255
  const jobEvents = input.events.filter((event) => event.job_id === input.job.jobId);
275
256
  const changes = jobChanges(input.job, input.events, input.commits ?? [], input.diffStat);
276
257
  return {
277
- job: toBoardJob(input.job, input.events),
258
+ job: projectJob(input.job, input.events),
278
259
  overview: phaseFeed(input.events, input.job.jobId),
279
260
  changes,
280
261
  timeline: jobEvents,
@@ -444,152 +425,6 @@ export function branchOf(job, events) {
444
425
  }
445
426
  return branch;
446
427
  }
447
- function toBoardJob(job, events) {
448
- const inbox = isInbox(job);
449
- const hand = isHand(job);
450
- const finished = isFinished(job);
451
- const failure = latestFailure(events, job.jobId);
452
- const activity = jobActivity(events, job.jobId);
453
- const waiting = scheduledRetry(job);
454
- const reason = hand ? handLine(job, failure) : attentionReason(job);
455
- const notes = [];
456
- if (job.launchError?.trim())
457
- notes.push(job.launchError.trim());
458
- if (job.failureDetail?.trim())
459
- notes.push(job.failureDetail.trim());
460
- if (job.waitReason)
461
- notes.push(job.waitReason);
462
- const feedback = [];
463
- for (const round of job.feedbackRounds ?? []) {
464
- const url = round.items.find((item) => item.url)?.url;
465
- feedback.push({ label: feedbackRoundLabel(round), ...(url ? { url } : {}) });
466
- }
467
- let historyLine;
468
- if (finished)
469
- historyLine = finishedLine(job);
470
- return {
471
- jobId: job.jobId,
472
- title: job.title || job.jobId,
473
- repo: job.repo,
474
- workflow: job.workflow,
475
- state: job.state,
476
- reason,
477
- inbox,
478
- hand,
479
- finished,
480
- retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) && job.gates.length === 0,
481
- sandboxed: job.sandboxed === true,
482
- canApprove: job.canApprove,
483
- canReject: job.canReject,
484
- canRevise: job.canRevise,
485
- attemptCount: job.attemptCount,
486
- maxAttempts: job.maxAttempts,
487
- scheduledRetry: waiting,
488
- ...(waiting && job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
489
- ...(attemptLine(job) ? { attemptLine: attemptLine(job) } : {}),
490
- ...(failure?.class ? { failureClass: failure.class } : {}),
491
- ...(failure?.fingerprint ? { fingerprint: shortFingerprint(failure.fingerprint) } : {}),
492
- ...(activity.latest ? { latestEventAt: activity.latest } : {}),
493
- eventCount: activity.count,
494
- ...(job.prUrl ? { prUrl: job.prUrl } : {}),
495
- ...(job.prNumber ? { prNumber: job.prNumber } : {}),
496
- dependsOn: job.dependsOn.map((dep) => ({
497
- jobId: dep.jobId,
498
- state: dep.state,
499
- ...(dep.blockedDetail ? { detail: dep.blockedDetail } : {}),
500
- })),
501
- evidence: evidenceTexts(job),
502
- feedback,
503
- notes,
504
- ...(historyLine ? { historyLine } : {}),
505
- findings: Boolean(job.findingsPath),
506
- spec: Boolean(job.specPath),
507
- log: Boolean(job.logPath),
508
- ...(job.logTail?.trim() ? { logTail: job.logTail.trim() } : {}),
509
- ...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
510
- };
511
- }
512
- function finishedLine(job) {
513
- if (job.state === "rejected")
514
- return "Rejected";
515
- if (job.workflow === "review")
516
- return job.reviewUrl ? "comment posted" : "approved";
517
- if (job.prNumber)
518
- return `merged · PR #${job.prNumber}`;
519
- if (job.prUrl && httpUrl(job.prUrl))
520
- return "merged · PR";
521
- return job.branch ? `landed locally · ${job.branch}` : "landed locally";
522
- }
523
- function httpUrl(url) {
524
- try {
525
- const parsed = new URL(url);
526
- return parsed.protocol === "http:" || parsed.protocol === "https:";
527
- }
528
- catch {
529
- return false;
530
- }
531
- }
532
- function handLine(job, failure) {
533
- if (job.reviewPostError)
534
- return job.reviewPostError;
535
- let label = "Needs a person";
536
- if (job.stopReason === "attempt-cap")
537
- label = "Attempt cap";
538
- else if (job.stopReason === "fingerprint")
539
- label = "Same failure";
540
- else if (job.feedbackReason === "feedback-exhausted")
541
- label = "Fix round cap";
542
- else if (job.feedbackReason === "feedback-ambiguous")
543
- label = "Feedback unclear";
544
- else if (job.state === "failed" || job.state === "dead" || job.state === "invalid")
545
- label = attentionReason(job);
546
- const parts = [label];
547
- if (failure?.class)
548
- parts.push(failure.class);
549
- const fingerprint = shortFingerprint(failure?.fingerprint);
550
- if (fingerprint)
551
- parts.push(fingerprint);
552
- return parts.join(" · ");
553
- }
554
- function evidenceTexts(job) {
555
- const texts = [];
556
- if (job.workflow !== "investigation" && job.diffSummary?.trim())
557
- texts.push(job.diffSummary.trim());
558
- for (const gate of job.gates) {
559
- const body = gate.rendered?.trim() || gate.prompt?.trim();
560
- if (body)
561
- texts.push(body);
562
- for (const review of gate.reviews ?? [])
563
- if (review.summary?.trim())
564
- texts.push(review.summary.trim());
565
- for (const artifact of gate.artifacts ?? [])
566
- if (artifact.summary?.trim())
567
- texts.push(artifact.summary.trim());
568
- }
569
- return texts;
570
- }
571
- function attemptLine(job) {
572
- if (job.attemptCount <= 0 && !job.lastFailureReason)
573
- return undefined;
574
- const parts = [];
575
- if (job.attemptCount > 0)
576
- parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
577
- if (job.lastFailureReason)
578
- parts.push(job.lastFailureReason);
579
- return parts.join(" · ");
580
- }
581
- function jobActivity(events, jobId) {
582
- let count = 0;
583
- let latest;
584
- for (const event of events) {
585
- if (event.job_id !== jobId)
586
- continue;
587
- count += 1;
588
- if (!latest || event.ts > latest)
589
- latest = event.ts;
590
- }
591
- return { count, ...(latest ? { latest } : {}) };
592
- }
593
428
  function latestFailure(events, jobId) {
594
429
  let found;
595
430
  for (const event of events) {
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
3
  import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
4
- import { answerJob, retryJob, reviseJob, runStatus, runTick } from "./driver.js";
4
+ import { answerJob, cancelQueuedJobs, retryJob, reviseJob, runStatus, runTick } from "./driver.js";
5
5
  import { productionHost } from "./host.js";
6
6
  import { pauseFactory, resumeFactory } from "./rails.js";
7
7
  import { parseSince, renderLedgerFile } from "./ledger.js";
@@ -23,6 +23,7 @@ function usage() {
23
23
  " kiln reject <jobId> [--config <file>]",
24
24
  " kiln revise <jobId> --note <text> [--config <file>]",
25
25
  " kiln retry <jobId> [--config <file>]",
26
+ " kiln cancel --queued [--repo <name>] [--workflow <name>] [--config <file>]",
26
27
  " kiln pause [--config <file>]",
27
28
  " kiln resume [--config <file>]",
28
29
  " kiln ledger [--since 7d] [--repo <name>] [--config <file>]",
@@ -96,6 +97,12 @@ function usage() {
96
97
  "when a slot was missed. Jira polls are per repo. Kiln-owned pull",
97
98
  "request activity uses the same read as the fix loop. A failed fire",
98
99
  "waits for the next slot. Pause and the daily cap skip the fire.",
100
+ "review-requests only picks pull requests labeled kiln-review, unless",
101
+ "the trigger sets label (repos.<name>.reviewLabel overrides that repo).",
102
+ "One fire creates at most 3 new review jobs, and at most 5 review jobs",
103
+ "may be queued or running. The rest are skipped until a later fire.",
104
+ "kiln cancel --queued cancels jobs that have not started. It does not",
105
+ "delete branches. A second cancel changes nothing.",
99
106
  "",
100
107
  "serve listens on 127.0.0.1. The page is a board of workflow stages:",
101
108
  "queued, spec, build, review, approve, land, PR review, Done (landed locally),",
@@ -118,7 +125,7 @@ function usage() {
118
125
  process.exit(2);
119
126
  }
120
127
  function parseFlags(argv) {
121
- const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false };
128
+ const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false, queued: false };
122
129
  for (let i = 0; i < argv.length; i += 1) {
123
130
  const flag = argv[i];
124
131
  const next = argv[i + 1];
@@ -207,6 +214,10 @@ function parseFlags(argv) {
207
214
  bag.stack = true;
208
215
  continue;
209
216
  }
217
+ if (flag === "--queued") {
218
+ bag.queued = true;
219
+ continue;
220
+ }
210
221
  if (flag === "--deliver") {
211
222
  if (next !== "local" && next !== "pr")
212
223
  usage();
@@ -420,6 +431,37 @@ function statusCommand(argv) {
420
431
  const config = resolveKilnConfig({ configPath: flags.configPath });
421
432
  process.stdout.write(runStatus(config, flags.positionals[0]));
422
433
  }
434
+ function cancelCommand(argv) {
435
+ const flags = parseFlags(argv);
436
+ rejectServeFlags(flags);
437
+ if (!flags.queued ||
438
+ flags.positionals.length !== 0 ||
439
+ flags.test ||
440
+ flags.spec ||
441
+ flags.prompt ||
442
+ flags.jira ||
443
+ flags.title ||
444
+ flags.issue ||
445
+ flags.context ||
446
+ flags.dryRun ||
447
+ flags.stack ||
448
+ flags.after.length > 0 ||
449
+ flags.deliver ||
450
+ flags.base ||
451
+ flags.note) {
452
+ usage();
453
+ }
454
+ const config = resolveKilnConfig({ configPath: flags.configPath });
455
+ const result = cancelQueuedJobs(config, {
456
+ ...(flags.repo ? { repo: flags.repo } : {}),
457
+ ...(flags.workflow ? { workflow: flags.workflow } : {}),
458
+ });
459
+ if (result.cancelled.length === 0) {
460
+ process.stdout.write("cancelled 0\n");
461
+ return;
462
+ }
463
+ process.stdout.write(`${result.cancelled.map((id) => `cancelled ${id}`).join("\n")}\n`);
464
+ }
423
465
  function retryCommand(argv) {
424
466
  const flags = parseFlags(argv);
425
467
  rejectServeFlags(flags);
@@ -564,6 +606,10 @@ async function main() {
564
606
  retryCommand(argv.slice(1));
565
607
  return;
566
608
  }
609
+ if (command === "cancel") {
610
+ cancelCommand(argv.slice(1));
611
+ return;
612
+ }
567
613
  if (command === "revise") {
568
614
  reviseCommand(argv.slice(1));
569
615
  return;
package/dist/config.js CHANGED
@@ -33,6 +33,7 @@ export function loadKilnConfig(configPath, options = {}) {
33
33
  ...(entry.branch ? { branchTemplate: entry.branch } : {}),
34
34
  ...(entry.maxAttempts !== undefined ? { maxAttempts: entry.maxAttempts } : {}),
35
35
  ...(entry.maxFixRounds !== undefined ? { maxFixRounds: entry.maxFixRounds } : {}),
36
+ ...(entry.reviewLabel ? { reviewLabel: entry.reviewLabel } : {}),
36
37
  };
37
38
  }
38
39
  const kilnDir = configDir;