@lchase/kiln 0.12.0 → 0.14.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 the gate does not post, approve, or merge. There is no gated posting path for a review comment yet, so the draft stops there. `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
  /**
@@ -102,6 +102,8 @@ function toItem(job) {
102
102
  export function attentionReason(job, tail = errorTail(job)) {
103
103
  switch (job.state) {
104
104
  case "needs-human": {
105
+ if (job.reviewPostError)
106
+ return job.reviewPostError;
105
107
  if (job.stopReason === "attempt-cap")
106
108
  return "Attempt cap reached";
107
109
  if (job.stopReason === "fingerprint")
@@ -147,6 +149,8 @@ export function attentionReason(job, tail = errorTail(job)) {
147
149
  return job.prUrl ? "Merged" : "Done";
148
150
  case "rejected":
149
151
  return "Rejected";
152
+ case "cancelled":
153
+ return "Cancelled";
150
154
  default:
151
155
  return job.state;
152
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,25 +60,8 @@ export function parseSeenQuery(values) {
60
60
  }
61
61
  return seen;
62
62
  }
63
- export function isInbox(job) {
64
- if (job.canApprove || job.canReject || job.canRevise)
65
- return true;
66
- return job.state === "needs-human" && job.gates.length > 0 && !job.stopReason && !job.feedbackReason;
67
- }
68
- /**
69
- * Needs a person, and not an open gate. A failure that still has a scheduled
70
- * retry is in flight, so it stays off this list.
71
- */
72
- export function isHand(job) {
73
- if (isInbox(job) || scheduledRetry(job))
74
- return false;
75
- return job.state === "needs-human" || job.state === "failed" || job.state === "dead" || job.state === "invalid";
76
- }
77
- export function isFinished(job) {
78
- return job.state === "done" || job.state === "rejected";
79
- }
80
63
  export function buildBoard(jobs, events) {
81
- const rows = jobs.map((job) => toBoardJob(job, events));
64
+ const rows = jobs.map((job) => projectJob(job, events));
82
65
  const inbox = rows.filter((job) => job.inbox);
83
66
  const hand = rows.filter((job) => job.hand);
84
67
  const history = rows.filter((job) => job.finished);
@@ -272,7 +255,7 @@ export function jobDetail(input) {
272
255
  const jobEvents = input.events.filter((event) => event.job_id === input.job.jobId);
273
256
  const changes = jobChanges(input.job, input.events, input.commits ?? [], input.diffStat);
274
257
  return {
275
- job: toBoardJob(input.job, input.events),
258
+ job: projectJob(input.job, input.events),
276
259
  overview: phaseFeed(input.events, input.job.jobId),
277
260
  changes,
278
261
  timeline: jobEvents,
@@ -442,147 +425,6 @@ export function branchOf(job, events) {
442
425
  }
443
426
  return branch;
444
427
  }
445
- function toBoardJob(job, events) {
446
- const inbox = isInbox(job);
447
- const hand = isHand(job);
448
- const finished = isFinished(job);
449
- const failure = latestFailure(events, job.jobId);
450
- const activity = jobActivity(events, job.jobId);
451
- const waiting = scheduledRetry(job);
452
- const reason = hand ? handLine(job, failure) : attentionReason(job);
453
- const notes = [];
454
- if (job.launchError?.trim())
455
- notes.push(job.launchError.trim());
456
- if (job.failureDetail?.trim())
457
- notes.push(job.failureDetail.trim());
458
- if (job.waitReason)
459
- notes.push(job.waitReason);
460
- const feedback = [];
461
- for (const round of job.feedbackRounds ?? []) {
462
- const url = round.items.find((item) => item.url)?.url;
463
- feedback.push({ label: feedbackRoundLabel(round), ...(url ? { url } : {}) });
464
- }
465
- let historyLine;
466
- if (finished)
467
- historyLine = finishedLine(job);
468
- return {
469
- jobId: job.jobId,
470
- title: job.title || job.jobId,
471
- repo: job.repo,
472
- workflow: job.workflow,
473
- state: job.state,
474
- reason,
475
- inbox,
476
- hand,
477
- finished,
478
- retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) && job.gates.length === 0,
479
- sandboxed: job.sandboxed === true,
480
- canApprove: job.canApprove,
481
- canReject: job.canReject,
482
- canRevise: job.canRevise,
483
- attemptCount: job.attemptCount,
484
- maxAttempts: job.maxAttempts,
485
- scheduledRetry: waiting,
486
- ...(waiting && job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
487
- ...(attemptLine(job) ? { attemptLine: attemptLine(job) } : {}),
488
- ...(failure?.class ? { failureClass: failure.class } : {}),
489
- ...(failure?.fingerprint ? { fingerprint: shortFingerprint(failure.fingerprint) } : {}),
490
- ...(activity.latest ? { latestEventAt: activity.latest } : {}),
491
- eventCount: activity.count,
492
- ...(job.prUrl ? { prUrl: job.prUrl } : {}),
493
- ...(job.prNumber ? { prNumber: job.prNumber } : {}),
494
- dependsOn: job.dependsOn.map((dep) => ({
495
- jobId: dep.jobId,
496
- state: dep.state,
497
- ...(dep.blockedDetail ? { detail: dep.blockedDetail } : {}),
498
- })),
499
- evidence: evidenceTexts(job),
500
- feedback,
501
- notes,
502
- ...(historyLine ? { historyLine } : {}),
503
- findings: Boolean(job.findingsPath),
504
- spec: Boolean(job.specPath),
505
- log: Boolean(job.logPath),
506
- ...(job.logTail?.trim() ? { logTail: job.logTail.trim() } : {}),
507
- };
508
- }
509
- function finishedLine(job) {
510
- if (job.state === "rejected")
511
- return "Rejected";
512
- if (job.prNumber)
513
- return `merged · PR #${job.prNumber}`;
514
- if (job.prUrl && httpUrl(job.prUrl))
515
- return "merged · PR";
516
- return job.branch ? `landed locally · ${job.branch}` : "landed locally";
517
- }
518
- function httpUrl(url) {
519
- try {
520
- const parsed = new URL(url);
521
- return parsed.protocol === "http:" || parsed.protocol === "https:";
522
- }
523
- catch {
524
- return false;
525
- }
526
- }
527
- function handLine(job, failure) {
528
- let label = "Needs a person";
529
- if (job.stopReason === "attempt-cap")
530
- label = "Attempt cap";
531
- else if (job.stopReason === "fingerprint")
532
- label = "Same failure";
533
- else if (job.feedbackReason === "feedback-exhausted")
534
- label = "Fix round cap";
535
- else if (job.feedbackReason === "feedback-ambiguous")
536
- label = "Feedback unclear";
537
- else if (job.state === "failed" || job.state === "dead" || job.state === "invalid")
538
- label = attentionReason(job);
539
- const parts = [label];
540
- if (failure?.class)
541
- parts.push(failure.class);
542
- const fingerprint = shortFingerprint(failure?.fingerprint);
543
- if (fingerprint)
544
- parts.push(fingerprint);
545
- return parts.join(" · ");
546
- }
547
- function evidenceTexts(job) {
548
- const texts = [];
549
- if (job.workflow !== "investigation" && job.diffSummary?.trim())
550
- texts.push(job.diffSummary.trim());
551
- for (const gate of job.gates) {
552
- const body = gate.rendered?.trim() || gate.prompt?.trim();
553
- if (body)
554
- texts.push(body);
555
- for (const review of gate.reviews ?? [])
556
- if (review.summary?.trim())
557
- texts.push(review.summary.trim());
558
- for (const artifact of gate.artifacts ?? [])
559
- if (artifact.summary?.trim())
560
- texts.push(artifact.summary.trim());
561
- }
562
- return texts;
563
- }
564
- function attemptLine(job) {
565
- if (job.attemptCount <= 0 && !job.lastFailureReason)
566
- return undefined;
567
- const parts = [];
568
- if (job.attemptCount > 0)
569
- parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
570
- if (job.lastFailureReason)
571
- parts.push(job.lastFailureReason);
572
- return parts.join(" · ");
573
- }
574
- function jobActivity(events, jobId) {
575
- let count = 0;
576
- let latest;
577
- for (const event of events) {
578
- if (event.job_id !== jobId)
579
- continue;
580
- count += 1;
581
- if (!latest || event.ts > latest)
582
- latest = event.ts;
583
- }
584
- return { count, ...(latest ? { latest } : {}) };
585
- }
586
428
  function latestFailure(events, jobId) {
587
429
  let found;
588
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>]",
@@ -63,7 +64,9 @@ function usage() {
63
64
  "yak/<run-id> and opens a draft pull request. An investigation is done when",
64
65
  "its gate is approved. It does not open a pull request. status prints the",
65
66
  "derived state, and the findings path once an investigation is approved.",
66
- "approve and reject answer the human gate. retry launches a failed or dead",
67
+ "approve and reject answer the human gate. Approving a review draft posts",
68
+ "it as a pull request comment and does not approve or merge that pull request.",
69
+ "retry launches a failed or dead",
67
70
  "job again, the same way tick launches, and clears the attempt ledger.",
68
71
  "A landed job that only failed to open its pull request is delivered again.",
69
72
  "A running job with no yak event or log output for stallTimeoutMs (default",
@@ -94,6 +97,12 @@ function usage() {
94
97
  "when a slot was missed. Jira polls are per repo. Kiln-owned pull",
95
98
  "request activity uses the same read as the fix loop. A failed fire",
96
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.",
97
106
  "",
98
107
  "serve listens on 127.0.0.1. The page is a board of workflow stages:",
99
108
  "queued, spec, build, review, approve, land, PR review, Done (landed locally),",
@@ -116,7 +125,7 @@ function usage() {
116
125
  process.exit(2);
117
126
  }
118
127
  function parseFlags(argv) {
119
- const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false };
128
+ const bag = { positionals: [], after: [], dryRun: false, stack: false, noTick: false, queued: false };
120
129
  for (let i = 0; i < argv.length; i += 1) {
121
130
  const flag = argv[i];
122
131
  const next = argv[i + 1];
@@ -205,6 +214,10 @@ function parseFlags(argv) {
205
214
  bag.stack = true;
206
215
  continue;
207
216
  }
217
+ if (flag === "--queued") {
218
+ bag.queued = true;
219
+ continue;
220
+ }
208
221
  if (flag === "--deliver") {
209
222
  if (next !== "local" && next !== "pr")
210
223
  usage();
@@ -418,6 +431,37 @@ function statusCommand(argv) {
418
431
  const config = resolveKilnConfig({ configPath: flags.configPath });
419
432
  process.stdout.write(runStatus(config, flags.positionals[0]));
420
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
+ }
421
465
  function retryCommand(argv) {
422
466
  const flags = parseFlags(argv);
423
467
  rejectServeFlags(flags);
@@ -562,6 +606,10 @@ async function main() {
562
606
  retryCommand(argv.slice(1));
563
607
  return;
564
608
  }
609
+ if (command === "cancel") {
610
+ cancelCommand(argv.slice(1));
611
+ return;
612
+ }
565
613
  if (command === "revise") {
566
614
  reviseCommand(argv.slice(1));
567
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;