@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.
@@ -1,20 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { buildBoard, } from "./board-view.js";
4
3
  import { packageRoot } from "./paths.js";
4
+ import { buildBoard, } from "./board-view.js";
5
+ import { COLUMN_LABEL, PIPELINE_COLUMNS, formatCost, formatDuration, formatLocal, formatRelative, shortSha, stateTone, } from "./job-view.js";
6
+ import { renderMarkdown } from "./markdown.js";
7
+ import { kilnVersion } from "./project.js";
5
8
  import { formatSpend } from "./rails.js";
6
9
  import { listWorkflowNames } from "./workflow.js";
7
10
  const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
8
- function kilnVersion() {
9
- try {
10
- const parsed = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
11
- return typeof parsed.version === "string" ? parsed.version : "";
12
- }
13
- catch {
14
- return "";
15
- }
16
- }
17
- const VERSION = kilnVersion();
18
11
  export function renderConsole(css, attention, page) {
19
12
  const jobs = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently].map((item) => item.job);
20
13
  const board = buildBoard(jobs, []);
@@ -24,7 +17,8 @@ export function renderConsole(css, attention, page) {
24
17
  export function renderApp(model) {
25
18
  const page = model.page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
26
19
  const paused = page.paused === true;
27
- const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
20
+ const versionText = kilnVersion();
21
+ const version = versionText && versionText !== "unknown" ? `<span class="ver">v${escapeHtml(versionText)}</span>` : "";
28
22
  const spend = page.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(page.spend))}</span>` : `<span class="spend" id="spend"></span>`;
29
23
  const capNote = page.spend?.blocked ? `<span class="cap" id="cap-note">cap hit</span>` : `<span class="cap" id="cap-note" hidden>cap hit</span>`;
30
24
  const body = `<div class="shell">
@@ -69,7 +63,7 @@ export function renderTextPage(css, title, text) {
69
63
  revision: "",
70
64
  section: "job",
71
65
  live: false,
72
- body: `<a class="back" href="/">Inbox</a>
66
+ body: `<a class="back" href="/">Pipeline</a>
73
67
  <div class="page"><h1>${escapeHtml(title)}</h1>
74
68
  <pre class="text">${escapeHtml(text)}</pre></div>`,
75
69
  });
@@ -82,7 +76,7 @@ export function renderMessagePage(css, title, message) {
82
76
  revision: "",
83
77
  section: "job",
84
78
  live: false,
85
- body: `<a class="back" href="/">Inbox</a>
79
+ body: `<a class="back" href="/">Pipeline</a>
86
80
  <div class="page"><h1>${escapeHtml(title)}</h1>
87
81
  <p class="empty">${escapeHtml(message)}</p></div>`,
88
82
  });
@@ -102,6 +96,7 @@ function navHtml(model) {
102
96
  })
103
97
  .join("");
104
98
  return `<nav class="side" aria-label="Sections">
99
+ ${item("/", "pipeline", "Pipeline", "pipeline")}
105
100
  ${item("/inbox", "inbox", "Inbox", "inbox", board.inboxCount)}
106
101
  <p class="unseen" id="inbox-unseen" hidden></p>
107
102
  ${item("/hand", "hand", "Needs a hand", "hand", board.handCount)}
@@ -131,6 +126,9 @@ function mainHtml(model, page) {
131
126
  if (model.section === "triggers") {
132
127
  return `${error}<main class="main">${triggersHtml(model.triggers ?? [])}</main>`;
133
128
  }
129
+ if (model.section === "pipeline") {
130
+ return `${error}<main class="main">${pipelineHtml(allJobs(model.board))}</main>`;
131
+ }
134
132
  return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
135
133
  }
136
134
  function triggersHtml(rows) {
@@ -142,6 +140,15 @@ function triggersHtml(rows) {
142
140
  function triggerCard(row) {
143
141
  const on = row.enabled;
144
142
  const bits = [row.kind, row.schedule, on ? "enabled" : "disabled"];
143
+ if (row.label)
144
+ bits.push(`label ${row.label}`);
145
+ if (row.maxNew !== undefined)
146
+ bits.push(`${row.maxNew} new/fire`);
147
+ if (row.maxQueued !== undefined)
148
+ bits.push(`${row.maxQueued} queued or running`);
149
+ if (row.repoLabels && row.repoLabels.length > 0) {
150
+ bits.push(row.repoLabels.map((item) => `${item.repo} label ${item.label}`).join(", "));
151
+ }
145
152
  const next = row.nextFire ? `<p class="meta">next <time datetime="${escapeHtml(row.nextFire)}" data-time>${escapeHtml(row.nextFire)}</time></p>` : "";
146
153
  const last = row.lastFire ? `<p class="meta">last fire <time datetime="${escapeHtml(row.lastFire)}" data-time>${escapeHtml(row.lastFire)}</time></p>` : `<p class="meta">last fire none</p>`;
147
154
  const error = row.lastError
@@ -160,10 +167,13 @@ ${next}${last}${error}
160
167
  ${recent}
161
168
  </article>`;
162
169
  }
170
+ function allJobs(board) {
171
+ return board.repos.flatMap((group) => group.jobs);
172
+ }
163
173
  function listHtml(model, csrf) {
164
174
  const board = model.board;
165
175
  if (model.section === "inbox") {
166
- return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Kiln does not do this on its own.</p></header>${jobList(board.inbox, "inbox", "Nothing is waiting on a gate.", csrf)}`;
176
+ return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Approving a review posts the draft as a comment. Kiln does not approve or merge.</p></header>${jobList(board.inbox, "inbox", "Nothing is waiting on a gate.", csrf)}`;
167
177
  }
168
178
  if (model.section === "hand") {
169
179
  return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, a review could not be posted, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
@@ -174,10 +184,71 @@ function listHtml(model, csrf) {
174
184
  const groups = model.repo ? board.repos.filter((group) => group.repo === model.repo) : board.repos;
175
185
  if (groups.length === 0)
176
186
  return `<header class="list-head"><h2>Repos</h2></header><p class="empty">No jobs.</p>`;
177
- return `<header class="list-head"><h2>${model.repo ? escapeHtml(model.repo) : "Repos"}</h2></header>${groups
178
- .map((group) => `<section class="repo-block"><h3>${escapeHtml(group.repo)}</h3>${jobList(group.jobs, "repos", "No jobs.", csrf)}</section>`)
187
+ const heading = model.repo ? escapeHtml(model.repo) : "Repos";
188
+ return `<header class="list-head"><h2>${heading}</h2></header>${groups
189
+ .map((group) => {
190
+ const label = model.repo ? "" : `<h3>${escapeHtml(group.repo)}</h3>`;
191
+ return `<section class="repo-block">${label}${groupedJobs(group.jobs, "repos", csrf)}</section>`;
192
+ })
179
193
  .join("")}`;
180
194
  }
195
+ function pipelineHtml(jobs) {
196
+ const columns = PIPELINE_COLUMNS.map((column) => {
197
+ const cards = jobs.filter((job) => job.column === column).map((job) => pipeCard(job)).join("");
198
+ return `<section class="col" data-column="${column}">
199
+ <header class="full"><h2>${escapeHtml(COLUMN_LABEL[column])}</h2><span class="count"></span></header>
200
+ <div class="slim-face"><span>${escapeHtml(COLUMN_LABEL[column])}</span><span class="count"></span></div>
201
+ ${cards}<p class="empty">${escapeHtml(emptyColumn(column))}</p>
202
+ </section>`;
203
+ }).join("");
204
+ return `<header class="list-head"><div class="head-row"><h2>Pipeline</h2><button type="button" id="compact" aria-pressed="true" aria-label="collapse empty columns"></button></div><p class="meta">Jobs moving through queued, running, needs you, needs a hand, and done.</p></header>${filtersHtml(jobs)}<div class="board-scroll"><div class="board" id="board">${columns}</div></div>`;
205
+ }
206
+ function emptyColumn(column) {
207
+ switch (column) {
208
+ case "queued":
209
+ return "Nothing queued.";
210
+ case "running":
211
+ return "Nothing running.";
212
+ case "needs-you":
213
+ return "Nothing needs you.";
214
+ case "needs-hand":
215
+ return "Nothing needs a hand.";
216
+ case "done":
217
+ return "Nothing finished.";
218
+ }
219
+ }
220
+ function filtersHtml(jobs) {
221
+ const repos = [...new Set(jobs.map((job) => job.repo))].sort((left, right) => left.localeCompare(right));
222
+ const workflows = [...new Set(jobs.map((job) => job.workflow))].sort((left, right) => left.localeCompare(right));
223
+ const repoButtons = [`<button type="button" class="repo-f" data-repo="" aria-pressed="true">All</button>`]
224
+ .concat(repos.map((repo) => `<button type="button" class="repo-f" data-repo="${escapeHtml(repo)}">${escapeHtml(repo)}</button>`))
225
+ .join("");
226
+ const workflowButtons = [`<button type="button" class="wf-f" data-workflow="" aria-pressed="true">All</button>`]
227
+ .concat(workflows.map((workflow) => `<button type="button" class="wf-f" data-workflow="${escapeHtml(workflow)}">${escapeHtml(workflow)}</button>`))
228
+ .join("");
229
+ return `<div class="filters"><div class="filter"><span class="k">Repo</span>${repoButtons}</div><div class="filter"><span class="k">Workflow</span>${workflowButtons}</div></div>`;
230
+ }
231
+ function pipeCard(job) {
232
+ const id = encodeURIComponent(job.jobId);
233
+ const cost = formatCost(job.costUsd);
234
+ const age = job.since ? timeHtml(job.since, "rel") : "";
235
+ const phase = job.currentPhase ? `<span class="meta">${escapeHtml(job.currentPhase)}</span>` : "";
236
+ return `<article class="card" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-state="${escapeHtml(job.state)}" data-repo="${escapeHtml(job.repo)}" data-workflow="${escapeHtml(job.workflow)}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
237
+ <a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a>
238
+ <div class="job-meta"><span class="meta">${escapeHtml(job.repo)}</span><span class="chip">${escapeHtml(job.workflow)}</span>${age}${cost ? `<span class="meta">${escapeHtml(cost)}</span>` : ""}${phase}</div>
239
+ </article>`;
240
+ }
241
+ function groupedJobs(jobs, section, csrf) {
242
+ const blocks = PIPELINE_COLUMNS.flatMap((column) => {
243
+ const rows = jobs.filter((job) => job.column === column);
244
+ if (rows.length === 0)
245
+ return [];
246
+ return [
247
+ `<section class="state-group"><h3><span class="chip ${column}">${escapeHtml(COLUMN_LABEL[column])}</span><span class="badge">${rows.length}</span></h3><div class="jobs">${rows.map((job) => jobRow(job, section, csrf)).join("\n")}</div></section>`,
248
+ ];
249
+ });
250
+ return blocks.length === 0 ? `<p class="empty">No jobs.</p>` : blocks.join("");
251
+ }
181
252
  function jobList(jobs, section, empty, csrf) {
182
253
  if (jobs.length === 0)
183
254
  return `<p class="empty">${escapeHtml(empty)}</p>`;
@@ -186,6 +257,8 @@ function jobList(jobs, section, empty, csrf) {
186
257
  function jobRow(job, section, csrf) {
187
258
  const id = encodeURIComponent(job.jobId);
188
259
  const classes = ["job"];
260
+ if (section === "inbox")
261
+ classes.push("decision");
189
262
  if (job.scheduledRetry)
190
263
  classes.push("retrying");
191
264
  if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
@@ -195,28 +268,141 @@ function jobRow(job, section, csrf) {
195
268
  const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
196
269
  const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
197
270
  const retrying = job.scheduledRetry && job.nextRetryAt
198
- ? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
271
+ ? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(formatRelative(job.nextRetryAt))}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
199
272
  : "";
200
273
  const attempt = !retrying && job.attemptLine && (section === "hand" || section === "inbox") ? `<div class="meta">${escapeHtml(job.attemptLine)}</div>` : "";
201
274
  const history = job.historyLine ? `<div class="meta">${escapeHtml(job.historyLine)}</div>` : "";
202
275
  const unsafe = job.prUrl && !safeUrl(job.prUrl) ? `<div class="branch">${escapeHtml(job.prUrl)}</div>` : "";
203
276
  const deps = dependsHtml(job);
204
- const evidence = section === "inbox" ? evidenceHtml(job) : "";
277
+ const body = section === "inbox" ? decisionBody(job) : "";
278
+ const reason = section === "inbox" && !showReason(job) ? "" : `<p class="reason">${escapeHtml(job.reason)}</p>`;
205
279
  const feedback = feedbackHtml(job);
206
- const tail = section !== "inbox" && job.logTail ? `<div class="note">${escapeHtml(clipTail(job.logTail))}</div>` : "";
207
- const notes = job.notes.length ? `<div class="note">${job.notes.map((line) => escapeHtml(line)).join("<br>")}</div>` : "";
280
+ const tail = section !== "inbox" && job.logTail ? logDisclosure(job.logTail) : "";
281
+ const notes = visibleNotes(job, section);
208
282
  const unseen = `<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>`;
209
283
  const links = fileLinks(job);
210
- return `<article class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-state="${escapeHtml(job.state)}" data-repo="${escapeHtml(job.repo)}" data-deps="${escapeHtml(job.dependsOn.map((dep) => dep.jobId).join(" "))}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
284
+ const age = job.since ? timeHtml(job.since, "rel") : "";
285
+ const cost = formatCost(job.costUsd);
286
+ return `<article class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-state="${escapeHtml(job.state)}" data-repo="${escapeHtml(job.repo)}" data-workflow="${escapeHtml(job.workflow)}" data-deps="${escapeHtml(job.dependsOn.map((dep) => dep.jobId).join(" "))}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
211
287
  <div class="job-main">
212
288
  <div class="job-top">${flag}<a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a></div>
213
- <div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="meta">${escapeHtml(job.state)}</span>${sandbox}${pr}</div>
214
- <p class="reason">${escapeHtml(job.reason)}</p>
215
- ${history}${unsafe}${retrying}${attempt}${deps}${feedback}${evidence}${notes}${tail}${unseen}${links}
289
+ <div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="chip ${stateTone(job.state)}">${escapeHtml(job.state)}</span><span class="chip">${escapeHtml(job.workflow)}</span>${age}${cost ? `<span class="meta">${escapeHtml(cost)}</span>` : ""}${sandbox}${pr}</div>
290
+ ${reason}${section === "inbox" ? ranHtml(job) : ""}${history}${unsafe}${retrying}${attempt}${deps}${feedback}${body}${notes}${tail}${unseen}${links}
216
291
  </div>
217
292
  ${actionsHtml(job, section, csrf)}
218
293
  </article>`;
219
294
  }
295
+ function showReason(job) {
296
+ if (!job.ask)
297
+ return true;
298
+ const norm = (value) => value.replace(/^Gate:\s*/i, "").replace(/\s+/g, " ").trim().toLowerCase();
299
+ const ask = norm(job.ask);
300
+ const reason = norm(job.reason);
301
+ return reason !== ask && !reason.endsWith(ask);
302
+ }
303
+ function visibleNotes(job, section) {
304
+ const lines = job.notes.filter((line) => section === "inbox" || !looksLikeLog(line));
305
+ if (lines.length === 0)
306
+ return "";
307
+ return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
308
+ }
309
+ function decisionBody(job) {
310
+ const ask = job.ask ? `<p class="ask">${escapeHtml(job.ask)}</p>` : "";
311
+ const policy = job.workflow === "review" && job.canApprove
312
+ ? `<p class="meta">Approving posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.</p>`
313
+ : "";
314
+ return `${ask}${policy}${ticksHtml(job.phases)}${deliverableHtml(job)}`;
315
+ }
316
+ function ranHtml(job) {
317
+ if (job.phases.length === 0 && job.costUsd === null)
318
+ return "";
319
+ const bits = [job.workflow];
320
+ if (job.phases.length > 0)
321
+ bits.push(job.phases.map((phase) => `${phase.label} ${phase.status}`).join(", "));
322
+ const durations = job.phases.flatMap((phase) => (phase.durationMs === undefined ? [] : [phase.durationMs]));
323
+ if (durations.length > 0)
324
+ bits.push(formatDuration(durations.reduce((sum, ms) => sum + ms, 0)));
325
+ const cost = formatCost(job.costUsd);
326
+ if (cost)
327
+ bits.push(cost);
328
+ return `<p class="ran">${escapeHtml(bits.join(" · "))}</p>`;
329
+ }
330
+ function ticksHtml(phases) {
331
+ if (phases.length === 0)
332
+ return "";
333
+ const items = phases
334
+ .map((phase) => {
335
+ const mark = phase.status === "done" ? "✓" : phase.status === "failed" ? "×" : phase.status === "running" ? "●" : "○";
336
+ return `<li class="tick ${phase.status}"><span class="mark" aria-hidden="true">${mark}</span>${escapeHtml(phase.label)}</li>`;
337
+ })
338
+ .join("");
339
+ return `<ol class="ticks">${items}</ol>`;
340
+ }
341
+ function deliverableHtml(job, skipPr = false) {
342
+ const deliverable = job.deliverable;
343
+ if (!deliverable)
344
+ return "";
345
+ if (deliverable.kind === "review")
346
+ return reviewHtml(job, deliverable, skipPr);
347
+ const prose = deliverable.markdown ? `<div class="prose">${renderMarkdown(deliverable.markdown)}</div>` : "";
348
+ const notes = deliverable.notes.length
349
+ ? `<ul class="notes">${deliverable.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}</ul>`
350
+ : "";
351
+ return `${prose}${notes}`;
352
+ }
353
+ function reviewHtml(job, review, skipPr = false) {
354
+ const link = skipPr ? "" : prLinkHtml(job.repo, review.prNumber ?? job.prNumber, review.prUrl ?? job.prUrl, review.headSha ?? job.headSha);
355
+ const verdict = review.verdict ? `<p class="verdict">${escapeHtml(review.verdict)}</p>` : "";
356
+ const prose = review.markdown ? `<div class="prose">${renderMarkdown(review.markdown)}</div>` : "";
357
+ const raw = review.raw ? `<details class="raw"><summary>Raw output</summary><pre class="text">${escapeHtml(review.raw)}</pre></details>` : "";
358
+ return `${link}${verdict}${prose}${findingsHtml(review.findings)}${raw}`;
359
+ }
360
+ function findingsHtml(findings) {
361
+ if (findings.length === 0)
362
+ return `<p class="empty">No findings.</p>`;
363
+ const groups = [];
364
+ let severity = "";
365
+ let items = [];
366
+ const flush = () => {
367
+ if (!severity)
368
+ return;
369
+ groups.push(`<section class="finding-group"><h3>${escapeHtml(severity)}</h3><ul>${items.join("")}</ul></section>`);
370
+ };
371
+ for (const finding of findings) {
372
+ if (finding.severity !== severity) {
373
+ flush();
374
+ severity = finding.severity;
375
+ items = [];
376
+ }
377
+ const loc = finding.file && finding.line !== undefined
378
+ ? `<code>${escapeHtml(`${finding.file}:${finding.line}`)}</code>`
379
+ : finding.file
380
+ ? `<code>${escapeHtml(finding.file)}</code>`
381
+ : "";
382
+ items.push(`<li>${loc}<span>${escapeHtml(finding.comment)}</span></li>`);
383
+ }
384
+ flush();
385
+ return `<div class="findings">${groups.join("")}</div>`;
386
+ }
387
+ function prLinkHtml(repo, number, url, sha) {
388
+ if (!number && !url && !sha)
389
+ return "";
390
+ const label = number ? `${repo}#${number}` : "pull request";
391
+ const safe = url ? safeUrl(url) : undefined;
392
+ const link = safe ? `<a href="${escapeHtml(safe)}">${escapeHtml(label)}</a>` : escapeHtml(label);
393
+ const short = sha ? ` <code>${escapeHtml(shortSha(sha))}</code>` : "";
394
+ return `<p class="pr-line">${link}${short}</p>`;
395
+ }
396
+ function logDisclosure(text) {
397
+ return `<details class="log"><summary>Log</summary><pre class="text">${escapeHtml(clipTail(text))}</pre></details>`;
398
+ }
399
+ function looksLikeLog(text) {
400
+ return /yak resume|worktree branch|suspended —/.test(text) || text.split("\n").length > 8;
401
+ }
402
+ function timeHtml(iso, mode) {
403
+ const text = mode === "rel" ? formatRelative(iso) : mode === "clock" ? formatLocal(iso) : formatLocal(iso);
404
+ return `<time datetime="${escapeHtml(iso)}" data-time="${mode}">${escapeHtml(text)}</time>`;
405
+ }
220
406
  function clipTail(text) {
221
407
  const lines = text.split("\n").filter((line) => line.trim().length > 0);
222
408
  const tail = lines.slice(-8);
@@ -235,12 +421,6 @@ function fileLinks(job) {
235
421
  return "";
236
422
  return `<p class="links">${links.join(" ")}</p>`;
237
423
  }
238
- function evidenceHtml(job) {
239
- if (job.evidence.length === 0)
240
- return "";
241
- const texts = job.evidence.map((text) => (text.length > 500 ? `${text.slice(0, 499)}…` : text));
242
- return `<div class="evidence">${texts.map((text) => `<pre class="text">${escapeHtml(text)}</pre>`).join("")}</div>`;
243
- }
244
424
  function feedbackHtml(job) {
245
425
  if (job.feedback.length === 0)
246
426
  return "";
@@ -261,7 +441,7 @@ function dependsHtml(job) {
261
441
  .join(", ");
262
442
  const detail = job.dependsOn
263
443
  .map((dep) => dep.detail)
264
- .filter((line) => Boolean(line))
444
+ .filter((line) => Boolean(line) && line !== job.reason)
265
445
  .join("; ");
266
446
  return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
267
447
  }
@@ -300,9 +480,18 @@ function detailHtml(detail, tab, _csrf) {
300
480
  })
301
481
  .join("");
302
482
  const panel = tab === "changes" ? changesHtml(detail) : tab === "timeline" ? timelineHtml(detail) : overviewHtml(detail);
483
+ const back = job.inbox ? "/inbox" : "/";
484
+ const backLabel = job.inbox ? "Inbox" : "Pipeline";
303
485
  return `<div class="detail-main">
304
- <a class="back" href="/inbox">Inbox</a>
305
- <header class="list-head"><h2>${escapeHtml(job.title)}</h2><p class="reason">${escapeHtml(job.reason)}</p><p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p></header>
486
+ <a class="back" href="${back}">${backLabel}</a>
487
+ <header class="list-head">
488
+ <div class="job-top"><h2>${escapeHtml(job.title)}</h2><span class="chip ${stateTone(job.state)}">${escapeHtml(job.state)}</span></div>
489
+ <div class="job-meta"><a href="/repos/${encodeURIComponent(job.repo)}">${escapeHtml(job.repo)}</a><span class="chip">${escapeHtml(job.workflow)}</span>${job.branch ? `<span class="mono">${escapeHtml(job.branch)}</span>` : ""}${prLinkHtml(job.repo, job.prNumber, job.prUrl, job.headSha)}</div>
490
+ ${job.ask ? `<p class="ask">${escapeHtml(job.ask)}</p>` : ""}
491
+ ${job.workflow === "review" && job.canApprove ? `<p class="meta">Approving posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.</p>` : ""}
492
+ ${showReason(job) ? `<p class="reason">${escapeHtml(job.reason)}</p>` : ""}
493
+ <p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>
494
+ </header>
306
495
  <nav class="tabs" aria-label="Job">${tabs}</nav>
307
496
  ${actionsHtml(job, "job", _csrf)}
308
497
  ${panel}
@@ -310,28 +499,40 @@ ${panel}
310
499
  ${railHtml(detail)}`;
311
500
  }
312
501
  function overviewHtml(detail) {
313
- const cards = detail.overview;
314
- if (cards.length === 0)
502
+ const phases = detail.job.phases;
503
+ if (phases.length === 0)
315
504
  return `<p class="empty">No phases in the event log yet.</p>`;
316
- return `<ol class="phases">${cards.map((card) => phaseHtml(card)).join("")}</ol>`;
505
+ const cost = formatCost(detail.job.costUsd);
506
+ const summary = cost ? `<p class="meta">Cost ${escapeHtml(cost)}</p>` : "";
507
+ return `${summary}${deliverableHtml(detail.job, true)}<ol class="phases">${phases.map((phase) => phaseHtml(phase)).join("")}</ol>`;
317
508
  }
318
- function phaseHtml(card) {
509
+ function phaseHtml(phase) {
319
510
  const bits = [];
320
- if (card.verdict)
321
- bits.push(`<p class="verdict">${escapeHtml(card.verdict)}</p>`);
322
- if (card.detail)
323
- bits.push(`<pre class="text">${escapeHtml(card.detail)}</pre>`);
324
- if (card.testCommand || card.exitCode !== undefined) {
325
- const exit = card.exitCode === undefined ? "" : ` · exit ${card.exitCode}`;
326
- bits.push(`<p class="meta">${escapeHtml(`${card.testCommand ?? "test"}${exit}`)}</p>`);
511
+ if (phase.verdict)
512
+ bits.push(`<p class="verdict">${escapeHtml(phase.verdict)}</p>`);
513
+ if (phase.detail)
514
+ bits.push(phaseDetailHtml(phase.detail));
515
+ if (phase.testCommand || phase.exitCode !== undefined) {
516
+ const exit = phase.exitCode === undefined ? "" : ` · exit ${phase.exitCode}`;
517
+ bits.push(`<p class="meta">${escapeHtml(`${phase.testCommand ?? "test"}${exit}`)}</p>`);
327
518
  }
328
- if (card.failureClass || card.fingerprint) {
329
- bits.push(`<p class="meta">${escapeHtml([card.failureClass, card.fingerprint].filter(Boolean).join(" · "))}</p>`);
519
+ if (phase.failureClass || phase.fingerprint) {
520
+ bits.push(`<p class="meta">${escapeHtml([phase.failureClass, phase.fingerprint].filter(Boolean).join(" · "))}</p>`);
330
521
  }
331
- if (card.note)
332
- bits.push(`<blockquote class="note">${escapeHtml(card.note)}</blockquote>`);
333
- const iteration = card.iteration !== undefined ? ` <span class="meta">#${card.iteration}</span>` : "";
334
- return `<li class="phase ${escapeHtml(card.outcome)}"><header><span class="step">${escapeHtml(card.label)}</span>${iteration}<span class="outcome">${escapeHtml(card.outcome)}</span><time datetime="${escapeHtml(card.ts)}">${escapeHtml(card.ts)}</time></header>${bits.join("")}</li>`;
522
+ if (phase.note)
523
+ bits.push(`<blockquote class="note">${escapeHtml(phase.note)}</blockquote>`);
524
+ const iteration = phase.iteration !== undefined ? ` <span class="meta">#${phase.iteration}</span>` : "";
525
+ const duration = phase.durationMs !== undefined ? `<span class="meta">${escapeHtml(formatDuration(phase.durationMs))}</span>` : "";
526
+ const when = phase.finishedAt ?? phase.startedAt;
527
+ const clock = when ? timeHtml(when, "local") + timeHtml(when, "rel") : "";
528
+ return `<li class="phase ${escapeHtml(phase.status)}"><header><span class="mark" aria-hidden="true">${phase.status === "done" ? "✓" : phase.status === "failed" ? "×" : phase.status === "running" ? "●" : "○"}</span><span class="step">${escapeHtml(phase.label)}</span>${iteration}<span class="outcome">${escapeHtml(phase.status)}</span>${duration}${clock}</header>${bits.join("")}</li>`;
529
+ }
530
+ function phaseDetailHtml(detail) {
531
+ if (looksLikeLog(detail))
532
+ return logDisclosure(detail);
533
+ if (detail.includes("\n") || detail.length > 280)
534
+ return `<div class="prose">${renderMarkdown(detail)}</div>`;
535
+ return `<p>${escapeHtml(detail)}</p>`;
335
536
  }
336
537
  function changesHtml(detail) {
337
538
  const changes = detail.changes;
@@ -350,15 +551,18 @@ function changesHtml(detail) {
350
551
  return `<section class="changes">${branch}${base}${pr}${diff}<h3>Commits</h3>${commits}</section>`;
351
552
  }
352
553
  function timelineHtml(detail) {
353
- if (detail.timeline.length === 0)
554
+ const runs = detail.job.timeline;
555
+ if (runs.length === 0)
354
556
  return `<p class="empty">No events.</p>`;
355
- const rows = detail.timeline
356
- .map((event) => {
357
- const step = event.step ? `<span class="meta">${escapeHtml(event.step)}</span>` : "";
358
- return `<li><time datetime="${escapeHtml(event.ts)}">${escapeHtml(event.ts)}</time> <span class="ev">${escapeHtml(event.event)}</span> ${step}<pre class="text">${escapeHtml(JSON.stringify(event.properties))}</pre></li>`;
557
+ return runs
558
+ .map((run) => {
559
+ const label = run.runId ? `Run ${run.runId}` : "Job";
560
+ const rows = run.entries
561
+ .map((entry) => `<li>${timeHtml(entry.ts, "local")}${timeHtml(entry.ts, "rel")}<span>${escapeHtml(entry.summary)}</span></li>`)
562
+ .join("");
563
+ return `<section class="run-group"><h3>${escapeHtml(label)}</h3><ol class="timeline">${rows}</ol></section>`;
359
564
  })
360
565
  .join("");
361
- return `<ol class="timeline">${rows}</ol>`;
362
566
  }
363
567
  function railHtml(detail) {
364
568
  const rail = detail.rail;
@@ -414,12 +618,14 @@ function statsHtml(stats) {
414
618
  return `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}<ul>${reasons}</ul></li>`;
415
619
  })
416
620
  .join("")}</ul><p class="meta">${stats.interventions.total} ${stats.interventions.total === 1 ? "intervention" : "interventions"}</p>`;
417
- return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav></header>
418
- <section class="stat"><h3>Spend</h3><p>Today ${escapeHtml(stats.spend.day)} ${escapeHtml(formatDollars(stats.spend.usd))} / ${escapeHtml(cap)}</p><p class="meta">This window ${escapeHtml(formatDollars(stats.windowUsd))}</p></section>
621
+ return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav><p class="meta">Since ${timeHtml(stats.since, "local")}</p></header>
622
+ <div class="stat-grid">
623
+ <section class="stat"><h3>Spend</h3><p class="stat-num">${escapeHtml(formatDollars(stats.spend.usd))}</p><p class="meta">Today ${escapeHtml(stats.spend.day)} / ${escapeHtml(cap)}</p><p class="meta">This window ${escapeHtml(formatDollars(stats.windowUsd))}</p></section>
624
+ <section class="stat"><h3>Runs</h3><p class="stat-num">${stats.runs.finished}</p><p class="meta">${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
625
+ <section class="stat"><h3>Interventions</h3>${interventions}</section>
626
+ </div>
419
627
  <section class="stat"><h3>Cost by repo</h3>${repoRows}</section>
420
- <section class="stat"><h3>Cost by phase</h3>${phaseRows}</section>
421
- <section class="stat"><h3>Runs</h3><p>${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
422
- <section class="stat"><h3>Interventions</h3>${interventions}</section>`;
628
+ <section class="stat"><h3>Cost by phase</h3>${phaseRows}</section>`;
423
629
  }
424
630
  function reportHtml(report) {
425
631
  const cap = report.spend.cap === null ? "no cap" : formatDollars(report.spend.cap);
@@ -429,7 +635,7 @@ function reportHtml(report) {
429
635
  .map((run) => {
430
636
  const cost = run.usd === null ? "" : ` · ${escapeHtml(formatDollars(run.usd))}`;
431
637
  const klass = run.failureClass ? ` · ${escapeHtml(run.failureClass)}` : "";
432
- return `<li><a href="/jobs/${encodeURIComponent(run.jobId)}">${escapeHtml(run.title)}</a> <span class="meta">${escapeHtml(run.repo)} · ${escapeHtml(run.outcome)}${klass}${cost}</span></li>`;
638
+ return `<li><a href="/jobs/${encodeURIComponent(run.jobId)}">${escapeHtml(run.title)}</a> <span class="chip ${escapeHtml(run.outcome)}">${escapeHtml(run.outcome)}</span> <span class="meta">${escapeHtml(run.repo)}${klass}${cost}</span> ${timeHtml(run.at, "rel")}</li>`;
433
639
  })
434
640
  .join("")}</ul>`;
435
641
  const interventions = report.interventions.total === 0
@@ -438,9 +644,12 @@ function reportHtml(report) {
438
644
  .map((row) => `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}</li>`)
439
645
  .join("")}</ul>`;
440
646
  return `<header class="list-head"><h2>Today</h2><p class="meta">${escapeHtml(report.day)}</p></header>
441
- <section class="stat"><h3>Spend</h3><p>${escapeHtml(formatDollars(report.spend.usd))} / ${escapeHtml(cap)}</p></section>
442
- <section class="stat"><h3>Runs</h3>${runs}</section>
443
- <section class="stat"><h3>Interventions</h3>${interventions}</section>`;
647
+ <div class="stat-grid">
648
+ <section class="stat"><h3>Spend</h3><p class="stat-num">${escapeHtml(formatDollars(report.spend.usd))}</p><p class="meta">of ${escapeHtml(cap)}</p></section>
649
+ <section class="stat"><h3>Runs</h3><p class="stat-num">${report.runs.length}</p></section>
650
+ <section class="stat"><h3>Interventions</h3>${interventions}</section>
651
+ </div>
652
+ <section class="stat"><h3>What ran</h3>${runs}</section>`;
444
653
  }
445
654
  function tableRows(rows, empty) {
446
655
  if (rows.length === 0)
package/dist/serve.js CHANGED
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve, sep } from "node:path";
6
6
  import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
7
7
  import { loadKilnConfig } from "./config.js";
8
- import { answerJob, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
8
+ import { answerJob, cancelQueuedJobs, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
9
9
  import { browse } from "./fs-browse.js";
10
10
  import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
11
11
  import { appendEvent, eventsForJob, readEvents } from "./events.js";
@@ -167,6 +167,10 @@ async function handle(req, res, state, boundPort, css, csrf) {
167
167
  await createJob(req, res, state, raw);
168
168
  return;
169
169
  }
170
+ if (path === "/api/jobs/cancel") {
171
+ await cancelQueued(res, config, secrets, raw);
172
+ return;
173
+ }
170
174
  if (path === "/api/pause" || path === "/api/resume") {
171
175
  const pausing = path === "/api/pause";
172
176
  const changed = pausing ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
@@ -341,6 +345,44 @@ async function handle(req, res, state, boundPort, css, csrf) {
341
345
  else
342
346
  sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
343
347
  }
348
+ async function cancelQueued(res, config, secrets, raw) {
349
+ let parsed;
350
+ try {
351
+ parsed = JSON.parse(raw || "{}");
352
+ }
353
+ catch {
354
+ sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
355
+ return;
356
+ }
357
+ if (!isRecord(parsed) || parsed.queued !== true) {
358
+ sendJson(res, 400, { error: "Cancel requires queued: true." }, secrets);
359
+ return;
360
+ }
361
+ const repo = parsed.repo;
362
+ const workflow = parsed.workflow;
363
+ if (repo !== undefined && typeof repo !== "string") {
364
+ sendJson(res, 400, { error: "repo must be a string." }, secrets);
365
+ return;
366
+ }
367
+ if (workflow !== undefined && typeof workflow !== "string") {
368
+ sendJson(res, 400, { error: "workflow must be a string." }, secrets);
369
+ return;
370
+ }
371
+ try {
372
+ const result = cancelQueuedJobs(config, {
373
+ ...(typeof repo === "string" && repo.length > 0 ? { repo } : {}),
374
+ ...(typeof workflow === "string" && workflow.length > 0 ? { workflow } : {}),
375
+ });
376
+ sendJson(res, 200, { ok: true, cancelled: result.cancelled }, secrets);
377
+ }
378
+ catch (error) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ sendJson(res, 400, { error: message }, secrets);
381
+ }
382
+ }
383
+ function isRecord(value) {
384
+ return typeof value === "object" && value !== null && !Array.isArray(value);
385
+ }
344
386
  async function answer(req, res, config, css, secrets, jobId, choice) {
345
387
  try {
346
388
  answerJob(config, jobId, choice);
@@ -724,7 +766,9 @@ function statusDocument(state) {
724
766
  };
725
767
  }
726
768
  function listSection(path) {
727
- if (path === "/" || path === "" || path === "/board" || path === "/inbox")
769
+ if (path === "/" || path === "" || path === "/board" || path === "/pipeline")
770
+ return "pipeline";
771
+ if (path === "/inbox")
728
772
  return "inbox";
729
773
  if (path === "/hand")
730
774
  return "hand";
package/dist/settings.js CHANGED
@@ -5,7 +5,7 @@ import { basename, dirname, join } from "node:path";
5
5
  import { assertBranchName, assertCheckoutDirectory } from "./config.js";
6
6
  import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
7
7
  import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, TriggerConfigSchema, } from "./schemas.js";
8
- import { applyTriggerToggles, effectiveTriggers, knownTrigger } from "./triggers.js";
8
+ import { applyTriggerToggles, effectiveTriggers, knownTrigger, REVIEW_REQUESTS_TRIGGER, reviewMaxNew, reviewMaxQueued, reviewRequestLabel, } from "./triggers.js";
9
9
  const DEFAULTS = {
10
10
  deliver: "local",
11
11
  prTitle: DEFAULT_PR_TITLE,
@@ -129,6 +129,7 @@ function mergeSettings(raw, settings) {
129
129
  return next;
130
130
  }
131
131
  function triggerView(trigger) {
132
+ const review = trigger.id === REVIEW_REQUESTS_TRIGGER;
132
133
  return {
133
134
  id: trigger.id,
134
135
  kind: trigger.kind,
@@ -137,8 +138,9 @@ function triggerView(trigger) {
137
138
  ...(trigger.every ? { every: trigger.every } : {}),
138
139
  ...(trigger.repo ? { repo: trigger.repo } : {}),
139
140
  ...(trigger.jql ? { jql: trigger.jql } : {}),
140
- ...(trigger.label ? { label: trigger.label } : {}),
141
+ ...(trigger.label || review ? { label: review ? reviewRequestLabel(trigger) : trigger.label } : {}),
141
142
  ...(trigger.status ? { status: trigger.status } : {}),
143
+ ...(review ? { maxNew: reviewMaxNew(trigger), maxQueued: reviewMaxQueued(trigger) } : {}),
142
144
  };
143
145
  }
144
146
  function storedTriggers(value) {