@lchase/kiln 0.9.0 → 0.11.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,17 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { scheduledRetry } from "./attention.js";
4
- import { readBoardFiles, stageOf, stagesFor } from "./board.js";
5
- import { feedbackRoundLabel } from "./feedback.js";
6
- import { INVESTIGATION_WORKFLOW } from "./findings.js";
3
+ import { buildBoard, } from "./board-view.js";
7
4
  import { packageRoot } from "./paths.js";
5
+ import { formatSpend } from "./rails.js";
8
6
  import { listWorkflowNames } from "./workflow.js";
9
- const REVIEWERS = [
10
- ["security", "sec"],
11
- ["ux", "ux"],
12
- ["ui", "ui"],
13
- ["code", "code"],
14
- ];
15
7
  const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
16
8
  function kilnVersion() {
17
9
  try {
@@ -24,58 +16,47 @@ function kilnVersion() {
24
16
  }
25
17
  const VERSION = kilnVersion();
26
18
  export function renderConsole(css, attention, page) {
27
- const intake = page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
28
- const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
29
- const stages = stagesFor(items.map((item) => item.job));
30
- const grouped = new Map();
31
- for (const item of items) {
32
- const stage = stageOf(item.job);
33
- const list = grouped.get(stage) ?? [];
34
- list.push(item);
35
- grouped.set(stage, list);
36
- }
37
- const tracks = stages
38
- .map((stage) => ((grouped.get(stage.id)?.length ?? 0) === 0 ? "40px" : "minmax(0, 1fr)"))
39
- .join(" ");
40
- const columns = stages.map((stage) => column(stage, grouped.get(stage.id) ?? [])).join("\n");
41
- const repos = [...new Set(items.map((item) => item.job.repo).filter((repo) => repo.length > 0))].sort();
42
- const chips = [
43
- chip("", "all", items.length, true),
44
- ...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
45
- ].join("");
19
+ const jobs = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently].map((item) => item.job);
20
+ const board = buildBoard(jobs, []);
21
+ const section = page?.section ?? "inbox";
22
+ return renderApp({ css, section, board, attention, ...(page ? { page, ...(page.repo ? { repo: page.repo } : {}) } : {}) });
23
+ }
24
+ export function renderApp(model) {
25
+ const page = model.page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
26
+ const paused = page.paused === true;
46
27
  const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
47
- const body = `<header>
48
- <div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><h1>Kiln</h1>${version}</div>
49
- <div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
50
- </header>
51
- <p class="meta config-error" id="config-error" role="status"${intake.configError ? "" : " hidden"}>${escapeHtml(intake.configError ?? "")}</p>
52
- <nav class="repos" aria-label="Filter by repo">${chips}</nav>
53
- <main class="board" id="board" style="--cols: ${tracks}">
54
- ${columns}
55
- </main>
56
- <nav class="toolbar" aria-label="Kiln commands">
57
- <button class="tool" type="button" id="add-job" data-tip="Add job" aria-label="Add job"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
58
- <button class="tool" type="button" data-act="approve" data-tip="Approve" aria-label="Approve"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
59
- <button class="tool" type="button" data-act="retry" data-tip="Retry" aria-label="Retry"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg></button>
60
- <button class="tool" type="button" data-act="pr" data-tip="Open pull request" aria-label="Open pull request"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V10a3 3 0 0 0-3-3h-3"/></svg></button>
61
- <button class="tool" type="button" id="notify" data-tip="Notify me" aria-label="Notify me"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 7H3s3 0 3-7"/><path d="M10 19a2 2 0 0 0 4 0"/></svg></button>
62
- <span class="sep" aria-hidden="true"></span>
63
- <button class="tool" type="button" id="compact" aria-pressed="true" data-tip="expand empty columns" aria-label="expand empty columns"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/></svg></button>
64
- <span class="sep" aria-hidden="true"></span>
65
- <button class="tool" type="button" id="open-settings" data-tip="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/></svg></button>
66
- </nav>
28
+ const spend = page.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(page.spend))}</span>` : `<span class="spend" id="spend"></span>`;
29
+ 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
+ const body = `<div class="shell">
31
+ <aside class="nav" aria-label="Kiln">
32
+ <div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><div><h1>Kiln</h1>${version}</div></div>
33
+ <div class="runstate" id="runstate">
34
+ <span class="live-dot${paused ? " paused" : ""}" id="run-dot" aria-hidden="true"></span>
35
+ <span id="run-label">${paused ? "Paused" : "Running"}</span>
36
+ <form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(page.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${paused ? "Resume" : "Pause"}</button></form>
37
+ </div>
38
+ ${spend}${capNote}
39
+ ${navHtml(model)}
40
+ <button class="add" type="button" id="add-job">Add job</button>
41
+ <button class="tool-link" type="button" id="notify">Notify me</button>
42
+ </aside>
43
+ <div class="workspace${model.section === "job" ? " with-rail" : ""}">
44
+ ${mainHtml(model, page)}
45
+ </div>
46
+ </div>
67
47
  <div class="scrim" data-close></div>
68
- <aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
69
- ${intakeSheet(intake, items)}
70
- ${settingsSheet()}
71
- ${items.map((item) => sheet(item, intake.csrf)).join("\n")}
48
+ <aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Add job">
49
+ ${intakeSheet(page, model.attention)}
72
50
  </aside>`;
51
+ const job = model.detail?.job;
73
52
  return document({
74
- css,
75
- title: `(${attention.needsYouCount}) Kiln`,
76
- needsYou: attention.needsYouCount,
77
- revision: attention.revision,
78
- csrf: intake.csrf,
53
+ css: model.css,
54
+ title: `(${model.board.inboxCount}) Kiln`,
55
+ needsYou: model.attention.needsYouCount,
56
+ revision: model.attention.revision,
57
+ csrf: page.csrf,
58
+ section: model.section,
59
+ ...(job ? { jobId: job.jobId, latest: job.latestEventAt } : {}),
79
60
  live: true,
80
61
  body,
81
62
  });
@@ -86,8 +67,9 @@ export function renderTextPage(css, title, text) {
86
67
  title,
87
68
  needsYou: 0,
88
69
  revision: "",
70
+ section: "job",
89
71
  live: false,
90
- body: `<a class="back" href="/">Console</a>
72
+ body: `<a class="back" href="/">Inbox</a>
91
73
  <div class="page"><h1>${escapeHtml(title)}</h1>
92
74
  <pre class="text">${escapeHtml(text)}</pre></div>`,
93
75
  });
@@ -98,53 +80,347 @@ export function renderMessagePage(css, title, message) {
98
80
  title,
99
81
  needsYou: 0,
100
82
  revision: "",
83
+ section: "job",
101
84
  live: false,
102
- body: `<a class="back" href="/">Console</a>
85
+ body: `<a class="back" href="/">Inbox</a>
103
86
  <div class="page"><h1>${escapeHtml(title)}</h1>
104
87
  <p class="empty">${escapeHtml(message)}</p></div>`,
105
88
  });
106
89
  }
107
- function column(stage, items) {
108
- const slim = items.length === 0;
109
- const tone = stage.tone ? ` ${stage.tone}` : "";
110
- const cards = items.map((item) => card(item, stage.id)).join("\n");
111
- const count = items.length ? String(items.length) : "";
112
- return `<section class="col${slim ? " slim" : ""}${tone}" id="col-${escapeHtml(stage.id)}" data-stage="${escapeHtml(stage.id)}" aria-label="${escapeHtml(stage.label)}, ${items.length}">
113
- <div class="slim-face"><span class="count">${items.length}</span><div class="v"><h2>${escapeHtml(stage.label)}</h2><span class="who">${escapeHtml(stage.who)}</span></div></div>
114
- <div class="full"><div class="col-head"><h2>${escapeHtml(stage.label)}</h2><span class="who">${escapeHtml(stage.who)}</span><span class="count">${escapeHtml(count)}</span></div>
115
- ${cards || ""}<div class="empty"${items.length > 0 ? " hidden" : ""}>none</div></div>
116
- </section>`;
90
+ function navHtml(model) {
91
+ const board = model.board;
92
+ const item = (href, id, label, section, badge) => {
93
+ const current = model.section === section ? ` aria-current="page"` : "";
94
+ const count = badge !== undefined && badge > 0 ? `<span class="badge">${badge}</span>` : "";
95
+ const extra = id === "open-settings" ? ` id="open-settings"` : "";
96
+ return `<a class="nav-link" href="${href}"${current}${extra}>${escapeHtml(label)}${count}</a>`;
97
+ };
98
+ const repos = board.repos
99
+ .map((group) => {
100
+ const current = model.section === "repos" && model.repo === group.repo ? ` aria-current="page"` : "";
101
+ return `<a class="nav-sub" href="/repos/${encodeURIComponent(group.repo)}"${current}>${escapeHtml(group.repo)}<span class="badge">${group.jobs.length}</span></a>`;
102
+ })
103
+ .join("");
104
+ return `<nav class="side" aria-label="Sections">
105
+ ${item("/inbox", "inbox", "Inbox", "inbox", board.inboxCount)}
106
+ <p class="unseen" id="inbox-unseen" hidden></p>
107
+ ${item("/hand", "hand", "Needs a hand", "hand", board.handCount)}
108
+ ${item("/repos", "repos", "Repos", "repos")}
109
+ ${repos}
110
+ ${item("/history", "history", "History", "history", board.history.length)}
111
+ ${item("/stats", "stats", "Stats", "stats")}
112
+ ${item("/report", "report", "Today", "report")}
113
+ ${item("/settings", "open-settings", "Settings", "settings")}
114
+ </nav>`;
115
+ }
116
+ function mainHtml(model, page) {
117
+ const error = `<p class="meta config-error" id="config-error" role="status"${page.configError ? "" : " hidden"}>${escapeHtml(page.configError ?? "")}</p>`;
118
+ if (model.section === "settings") {
119
+ return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm()}</section></main>`;
120
+ }
121
+ if (model.section === "stats" && model.stats) {
122
+ return `${error}<main class="main">${statsHtml(model.stats)}</main>`;
123
+ }
124
+ if (model.section === "report" && model.report) {
125
+ return `${error}<main class="main">${reportHtml(model.report)}</main>`;
126
+ }
127
+ if (model.section === "job" && model.detail) {
128
+ return `${error}<main class="main detail">${detailHtml(model.detail, model.tab ?? "overview", page.csrf)}</main>`;
129
+ }
130
+ return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
131
+ }
132
+ function listHtml(model, csrf) {
133
+ const board = model.board;
134
+ if (model.section === "inbox") {
135
+ 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)}`;
136
+ }
137
+ if (model.section === "hand") {
138
+ return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
139
+ }
140
+ if (model.section === "history") {
141
+ return `<header class="list-head"><h2>History</h2></header>${jobList(board.history, "history", "No finished jobs.", csrf)}`;
142
+ }
143
+ const groups = model.repo ? board.repos.filter((group) => group.repo === model.repo) : board.repos;
144
+ if (groups.length === 0)
145
+ return `<header class="list-head"><h2>Repos</h2></header><p class="empty">No jobs.</p>`;
146
+ return `<header class="list-head"><h2>${model.repo ? escapeHtml(model.repo) : "Repos"}</h2></header>${groups
147
+ .map((group) => `<section class="repo-block"><h3>${escapeHtml(group.repo)}</h3>${jobList(group.jobs, "repos", "No jobs.", csrf)}</section>`)
148
+ .join("")}`;
149
+ }
150
+ function jobList(jobs, section, empty, csrf) {
151
+ if (jobs.length === 0)
152
+ return `<p class="empty">${escapeHtml(empty)}</p>`;
153
+ return `<div class="jobs">${jobs.map((job) => jobRow(job, section, csrf)).join("\n")}</div>`;
117
154
  }
118
- function card(item, stage) {
119
- const job = item.job;
120
- const waiting = scheduledRetry(job);
121
- const classes = ["card"];
122
- if (waiting)
155
+ function jobRow(job, section, csrf) {
156
+ const id = encodeURIComponent(job.jobId);
157
+ const classes = ["job"];
158
+ if (job.scheduledRetry)
123
159
  classes.push("retrying");
124
- else if (stage === "needs-human")
160
+ if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
125
161
  classes.push("human");
126
- if (stage === "done" || stage === "merged")
127
- classes.push("merged");
128
- if (stage === "queued")
129
- classes.push("queued");
130
- const deps = job.dependsOn.map((dep) => dep.jobId).join(" ");
131
- const pulse = job.state === "running" ? '<span class="pulse" aria-label="running"></span>' : "";
132
- const flag = stage === "needs-human" && !waiting ? '<span class="flag">your call</span>' : "";
133
- const branch = branchOf(job);
134
- return `<div role="button" tabindex="0" class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-deps="${escapeHtml(deps)}" data-repo="${escapeHtml(job.repo)}" data-stage="${escapeHtml(stage)}">
135
- <div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
136
- <div class="branch repo">${escapeHtml(repoLabel(job))}</div>
137
- <p class="title">${escapeHtml(job.title || job.jobId)}</p>
138
- ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
139
- ${sandboxHtml(job)}
140
- ${prLink(job)}
141
- ${roundsHtml(job, stage)}
142
- ${attemptHtml(job, stage)}
143
- ${waiting ? "" : noteHtml(item)}
144
- ${dependsHtml(job)}
145
- </div>`;
146
- }
147
- function intakeSheet(intake, items) {
162
+ const flag = classes.includes("human") ? `<span class="flag">your call</span>` : "";
163
+ const repo = section === "repos" ? "" : `<span class="meta">${escapeHtml(job.repo)}</span>`;
164
+ const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
165
+ const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
166
+ const retrying = job.scheduledRetry && job.nextRetryAt
167
+ ? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
168
+ : "";
169
+ const attempt = !retrying && job.attemptLine && (section === "hand" || section === "inbox") ? `<div class="meta">${escapeHtml(job.attemptLine)}</div>` : "";
170
+ const history = job.historyLine ? `<div class="meta">${escapeHtml(job.historyLine)}</div>` : "";
171
+ const unsafe = job.prUrl && !safeUrl(job.prUrl) ? `<div class="branch">${escapeHtml(job.prUrl)}</div>` : "";
172
+ const deps = dependsHtml(job);
173
+ const evidence = section === "inbox" ? evidenceHtml(job) : "";
174
+ const feedback = feedbackHtml(job);
175
+ const tail = section !== "inbox" && job.logTail ? `<div class="note">${escapeHtml(clipTail(job.logTail))}</div>` : "";
176
+ const notes = job.notes.length ? `<div class="note">${job.notes.map((line) => escapeHtml(line)).join("<br>")}</div>` : "";
177
+ const unseen = `<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>`;
178
+ const links = fileLinks(job);
179
+ 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)}"` : ""}>
180
+ <div class="job-main">
181
+ <div class="job-top">${flag}<a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a></div>
182
+ <div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="meta">${escapeHtml(job.state)}</span>${sandbox}${pr}</div>
183
+ <p class="reason">${escapeHtml(job.reason)}</p>
184
+ ${history}${unsafe}${retrying}${attempt}${deps}${feedback}${evidence}${notes}${tail}${unseen}${links}
185
+ </div>
186
+ ${actionsHtml(job, section, csrf)}
187
+ </article>`;
188
+ }
189
+ function clipTail(text) {
190
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
191
+ const tail = lines.slice(-8);
192
+ return tail.join("\n");
193
+ }
194
+ function fileLinks(job) {
195
+ const id = encodeURIComponent(job.jobId);
196
+ const links = [];
197
+ if (job.spec)
198
+ links.push(`<a href="/jobs/${id}/spec">spec</a>`);
199
+ if (job.log)
200
+ links.push(`<a href="/jobs/${id}/log">log</a>`);
201
+ if (job.findings)
202
+ links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
203
+ if (links.length === 0)
204
+ return "";
205
+ return `<p class="links">${links.join(" ")}</p>`;
206
+ }
207
+ function evidenceHtml(job) {
208
+ if (job.evidence.length === 0)
209
+ return "";
210
+ const texts = job.evidence.map((text) => (text.length > 500 ? `${text.slice(0, 499)}…` : text));
211
+ return `<div class="evidence">${texts.map((text) => `<pre class="text">${escapeHtml(text)}</pre>`).join("")}</div>`;
212
+ }
213
+ function feedbackHtml(job) {
214
+ if (job.feedback.length === 0)
215
+ return "";
216
+ const rows = job.feedback
217
+ .map((line) => {
218
+ const href = line.url ? safeUrl(line.url) : undefined;
219
+ const body = href ? `<a href="${escapeHtml(href)}">${escapeHtml(line.label)}</a>` : escapeHtml(line.label);
220
+ return `<li>${body}</li>`;
221
+ })
222
+ .join("");
223
+ return `<ul class="feedback">${rows}</ul>`;
224
+ }
225
+ function dependsHtml(job) {
226
+ if (job.dependsOn.length === 0)
227
+ return "";
228
+ const links = job.dependsOn
229
+ .map((dep) => `<a href="/jobs/${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
230
+ .join(", ");
231
+ const detail = job.dependsOn
232
+ .map((dep) => dep.detail)
233
+ .filter((line) => Boolean(line))
234
+ .join("; ");
235
+ return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
236
+ }
237
+ function actionsHtml(job, section, csrf) {
238
+ const showGate = section === "inbox" || section === "job";
239
+ const showRetry = section === "hand" || section === "repos" || section === "job";
240
+ if (!showGate && !showRetry)
241
+ return "";
242
+ const id = encodeURIComponent(job.jobId);
243
+ const forms = [];
244
+ if (showGate && job.canApprove)
245
+ forms.push(postForm(`/api/jobs/${id}/approve`, "approve", "Approve", csrf));
246
+ if (showGate && job.canReject)
247
+ forms.push(postForm(`/api/jobs/${id}/reject`, "reject", "Reject", csrf));
248
+ if (showGate && job.canRevise) {
249
+ forms.push(`<form method="post" action="/api/jobs/${id}/revise" class="act revise"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}"><input name="note" type="text" required maxlength="8000" placeholder="Note for the next round" autocomplete="off"><button type="submit">Revise</button></form>`);
250
+ }
251
+ if (showRetry && job.retry)
252
+ forms.push(postForm(`/api/jobs/${id}/retry`, "retry", "Retry", csrf));
253
+ if (forms.length === 0)
254
+ return "";
255
+ return `<div class="d-actions">${forms.join("")}</div>`;
256
+ }
257
+ function postForm(action, kind, label, csrf) {
258
+ return `<form method="post" action="${action}" class="act ${kind}"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}"><button type="submit">${label}</button></form>`;
259
+ }
260
+ function detailHtml(detail, tab, _csrf) {
261
+ const job = detail.job;
262
+ const id = encodeURIComponent(job.jobId);
263
+ const tabs = ["overview", "changes", "timeline"]
264
+ .map((name) => {
265
+ const href = name === "overview" ? `/jobs/${id}` : `/jobs/${id}?tab=${name}`;
266
+ const current = name === tab ? ` aria-current="page"` : "";
267
+ const label = name[0]?.toUpperCase() + name.slice(1);
268
+ return `<a href="${href}"${current}>${label}</a>`;
269
+ })
270
+ .join("");
271
+ const panel = tab === "changes" ? changesHtml(detail) : tab === "timeline" ? timelineHtml(detail) : overviewHtml(detail);
272
+ return `<div class="detail-main">
273
+ <a class="back" href="/inbox">Inbox</a>
274
+ <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>
275
+ <nav class="tabs" aria-label="Job">${tabs}</nav>
276
+ ${actionsHtml(job, "job", _csrf)}
277
+ ${panel}
278
+ </div>
279
+ ${railHtml(detail)}`;
280
+ }
281
+ function overviewHtml(detail) {
282
+ const cards = detail.overview;
283
+ if (cards.length === 0)
284
+ return `<p class="empty">No phases in the event log yet.</p>`;
285
+ return `<ol class="phases">${cards.map((card) => phaseHtml(card)).join("")}</ol>`;
286
+ }
287
+ function phaseHtml(card) {
288
+ const bits = [];
289
+ if (card.verdict)
290
+ bits.push(`<p class="verdict">${escapeHtml(card.verdict)}</p>`);
291
+ if (card.detail)
292
+ bits.push(`<pre class="text">${escapeHtml(card.detail)}</pre>`);
293
+ if (card.testCommand || card.exitCode !== undefined) {
294
+ const exit = card.exitCode === undefined ? "" : ` · exit ${card.exitCode}`;
295
+ bits.push(`<p class="meta">${escapeHtml(`${card.testCommand ?? "test"}${exit}`)}</p>`);
296
+ }
297
+ if (card.failureClass || card.fingerprint) {
298
+ bits.push(`<p class="meta">${escapeHtml([card.failureClass, card.fingerprint].filter(Boolean).join(" · "))}</p>`);
299
+ }
300
+ if (card.note)
301
+ bits.push(`<blockquote class="note">${escapeHtml(card.note)}</blockquote>`);
302
+ const iteration = card.iteration !== undefined ? ` <span class="meta">#${card.iteration}</span>` : "";
303
+ 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>`;
304
+ }
305
+ function changesHtml(detail) {
306
+ const changes = detail.changes;
307
+ const branch = changes.branch ? `<p><span class="k">Branch</span> <span class="mono">${escapeHtml(changes.branch)}</span></p>` : `<p class="empty">No branch yet.</p>`;
308
+ const base = changes.base ? `<p><span class="k">Base</span> <span class="mono">${escapeHtml(changes.base)}</span></p>` : "";
309
+ const pr = prHtml(changes.prUrl, changes.prNumber);
310
+ const diff = changes.diffStat ? `<pre class="text">${escapeHtml(changes.diffStat)}</pre>` : "";
311
+ const commits = changes.commits.length === 0
312
+ ? `<p class="empty">No commits read for this branch.</p>`
313
+ : `<ul class="commits">${changes.commits
314
+ .map((commit) => {
315
+ const round = commit.round !== undefined ? `<span class="meta">Kiln-Round ${commit.round}</span>` : "";
316
+ return `<li><code>${escapeHtml(commit.sha.slice(0, 12))}</code> ${escapeHtml(commit.subject)} ${round}</li>`;
317
+ })
318
+ .join("")}</ul>`;
319
+ return `<section class="changes">${branch}${base}${pr}${diff}<h3>Commits</h3>${commits}</section>`;
320
+ }
321
+ function timelineHtml(detail) {
322
+ if (detail.timeline.length === 0)
323
+ return `<p class="empty">No events.</p>`;
324
+ const rows = detail.timeline
325
+ .map((event) => {
326
+ const step = event.step ? `<span class="meta">${escapeHtml(event.step)}</span>` : "";
327
+ 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>`;
328
+ })
329
+ .join("");
330
+ return `<ol class="timeline">${rows}</ol>`;
331
+ }
332
+ function railHtml(detail) {
333
+ const rail = detail.rail;
334
+ const rows = [];
335
+ const row = (label, value) => {
336
+ rows.push(`<div><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`);
337
+ };
338
+ row("Repo", escapeHtml(rail.repo));
339
+ if (rail.issue)
340
+ row("Issue", `<span class="mono">${escapeHtml(rail.issue)}</span>`);
341
+ if (rail.branch)
342
+ row("Branch", `<span class="mono">${escapeHtml(rail.branch)}</span>`);
343
+ if (rail.prUrl || rail.prNumber)
344
+ row("Pull request", prHtml(rail.prUrl, rail.prNumber) || escapeHtml(rail.prUrl ?? ""));
345
+ if (rail.ci)
346
+ row("CI", escapeHtml(rail.ci));
347
+ if (rail.risk)
348
+ row("Risk", escapeHtml(rail.risk));
349
+ row("Cost", rail.costUsd === null ? "no cost recorded" : escapeHtml(formatDollars(rail.costUsd)));
350
+ row("Attempts", escapeHtml(`${rail.attempts.used} / ${rail.attempts.cap}`));
351
+ row("Fix rounds", escapeHtml(`${rail.fixRounds.used} / ${rail.fixRounds.cap}`));
352
+ if (rail.failureClass || rail.fingerprint) {
353
+ row("Failure", escapeHtml([rail.failureClass, rail.fingerprint].filter(Boolean).join(" · ")));
354
+ }
355
+ return `<aside class="rail" aria-label="Job facts"><h3>${escapeHtml(detail.job.jobId)}</h3><dl>${rows.join("")}</dl></aside>`;
356
+ }
357
+ function prHtml(url, number) {
358
+ const safe = url ? safeUrl(url) : undefined;
359
+ const text = number ? `PR #${number}` : (safe ?? url ?? "");
360
+ if (!text)
361
+ return "";
362
+ if (safe)
363
+ return `<a href="${escapeHtml(safe)}">${escapeHtml(text)}</a>`;
364
+ return escapeHtml(url ?? text);
365
+ }
366
+ function statsHtml(stats) {
367
+ const windows = ["today", "7d", "30d"]
368
+ .map((window) => {
369
+ const current = stats.window === window ? ` aria-current="page"` : "";
370
+ const label = window === "today" ? "Today" : window;
371
+ return `<a href="/stats?window=${window}"${current}>${label}</a>`;
372
+ })
373
+ .join("");
374
+ const cap = stats.spend.cap === null ? "no cap" : formatDollars(stats.spend.cap);
375
+ const repoRows = tableRows(stats.costByRepo.map((row) => [row.repo, formatDollars(row.usd)]), "No recorded cost in this window.");
376
+ const phaseRows = tableRows(stats.costByPhase.map((row) => [row.phase, formatDollars(row.usd)]), "No recorded cost in this window.");
377
+ const classRows = tableRows(stats.failuresByClass.map((row) => [row.class, String(row.count)]), "No failures in this window.");
378
+ const interventions = stats.interventions.total === 0
379
+ ? `<p class="empty">0 interventions</p>`
380
+ : `<ul class="ledger">${stats.interventions.rows
381
+ .map((row) => {
382
+ const reasons = row.reasons.map((reason) => `<li>${escapeHtml(reason.reason)} ${reason.count}</li>`).join("");
383
+ return `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}<ul>${reasons}</ul></li>`;
384
+ })
385
+ .join("")}</ul><p class="meta">${stats.interventions.total} ${stats.interventions.total === 1 ? "intervention" : "interventions"}</p>`;
386
+ return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav></header>
387
+ <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>
388
+ <section class="stat"><h3>Cost by repo</h3>${repoRows}</section>
389
+ <section class="stat"><h3>Cost by phase</h3>${phaseRows}</section>
390
+ <section class="stat"><h3>Runs</h3><p>${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
391
+ <section class="stat"><h3>Interventions</h3>${interventions}</section>`;
392
+ }
393
+ function reportHtml(report) {
394
+ const cap = report.spend.cap === null ? "no cap" : formatDollars(report.spend.cap);
395
+ const runs = report.runs.length === 0
396
+ ? `<p class="empty">Nothing ran today.</p>`
397
+ : `<ul class="runs">${report.runs
398
+ .map((run) => {
399
+ const cost = run.usd === null ? "" : ` · ${escapeHtml(formatDollars(run.usd))}`;
400
+ const klass = run.failureClass ? ` · ${escapeHtml(run.failureClass)}` : "";
401
+ 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>`;
402
+ })
403
+ .join("")}</ul>`;
404
+ const interventions = report.interventions.total === 0
405
+ ? `<p class="empty">0 interventions</p>`
406
+ : `<ul class="ledger">${report.interventions.rows
407
+ .map((row) => `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}</li>`)
408
+ .join("")}</ul>`;
409
+ return `<header class="list-head"><h2>Today</h2><p class="meta">${escapeHtml(report.day)}</p></header>
410
+ <section class="stat"><h3>Spend</h3><p>${escapeHtml(formatDollars(report.spend.usd))} / ${escapeHtml(cap)}</p></section>
411
+ <section class="stat"><h3>Runs</h3>${runs}</section>
412
+ <section class="stat"><h3>Interventions</h3>${interventions}</section>`;
413
+ }
414
+ function tableRows(rows, empty) {
415
+ if (rows.length === 0)
416
+ return `<p class="empty">${escapeHtml(empty)}</p>`;
417
+ return `<table><tbody>${rows.map((row) => `<tr><td>${escapeHtml(row[0] ?? "")}</td><td class="num">${escapeHtml(row[1] ?? "")}</td></tr>`).join("")}</tbody></table>`;
418
+ }
419
+ function formatDollars(value) {
420
+ return `$${value.toFixed(2)}`;
421
+ }
422
+ function intakeSheet(intake, attention) {
423
+ const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
148
424
  return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
149
425
  <div class="d-head">
150
426
  <div class="num">kiln add</div>
@@ -169,21 +445,15 @@ function intakeSheet(intake, items) {
169
445
  <div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false"></label></div>
170
446
  <label class="field" id="issue-field"><span class="k">Issue</span><input id="job-issue" type="text" placeholder="ABC-123" autocomplete="off" spellcheck="false"></label>
171
447
  <label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
172
- <fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items)}</div></fieldset>
448
+ <fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items.map((item) => item.job))}</div></fieldset>
173
449
  <pre class="text preview" id="intake-preview" hidden></pre>
174
450
  <p class="form-error" id="intake-error" role="alert" hidden></p>
175
451
  <div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
176
452
  </form>
177
453
  </section>`;
178
454
  }
179
- function settingsSheet() {
180
- return `<section class="sheet intake" id="settings" aria-label="Settings" hidden>
181
- <div class="d-head">
182
- <div class="num">kiln.config.json</div>
183
- <h3>Settings</h3>
184
- <button type="button" class="close" data-close aria-label="Close">×</button>
185
- </div>
186
- <form id="settings-form" class="intake-form">
455
+ function settingsForm() {
456
+ return `<form id="settings-form" class="intake-form">
187
457
  <p class="meta" id="settings-path"></p>
188
458
  <p class="saved" id="settings-saved" role="status" hidden>Saved</p>
189
459
  <p class="form-error" id="settings-error" role="alert" hidden></p>
@@ -213,6 +483,12 @@ function settingsSheet() {
213
483
  <label class="field"><span class="k">Branch template</span><input id="repo-branch" type="text" autocomplete="off" spellcheck="false"></label>
214
484
  <p class="hint" id="hint-branch">Default {issue}-{slug}, which renders as ABC-123-slug.</p>
215
485
  <p class="field-error" data-error="branch" hidden></p>
486
+ <label class="field"><span class="k">Max attempts</span><input id="repo-max-attempts" type="number" min="1" step="1" placeholder="inherit"></label>
487
+ <p class="hint">Overrides the global attempt cap for this repo. Empty inherits.</p>
488
+ <p class="field-error" data-error="repoAttempts" hidden></p>
489
+ <label class="field"><span class="k">Max fix rounds</span><input id="repo-max-fix-rounds" type="number" min="1" step="1" placeholder="inherit"></label>
490
+ <p class="hint">Overrides the global fix-round cap for this repo. Empty inherits.</p>
491
+ <p class="field-error" data-error="repoRounds" hidden></p>
216
492
  <div class="d-actions"><button type="button" id="repo-done">Done</button></div>
217
493
  </div>
218
494
  <div id="repo-list"></div>
@@ -237,10 +513,15 @@ function settingsSheet() {
237
513
  <label class="field"><span class="k">Max fix rounds</span><input id="max-fix-rounds" type="number" min="1" step="1"></label>
238
514
  <p class="hint" id="hint-fix"></p>
239
515
  <p class="field-error" data-error="maxFixRounds" hidden></p>
516
+ <label class="field"><span class="k">Same fingerprint limit</span><input id="max-same-fingerprint" type="number" min="1" step="1"></label>
517
+ <p class="hint" id="hint-fingerprint"></p>
518
+ <p class="field-error" data-error="maxSameFingerprint" hidden></p>
519
+ <label class="field"><span class="k">Daily dollar cap</span><input id="daily-dollar-cap" type="number" min="0" step="0.01"></label>
520
+ <p class="hint" id="hint-cap"></p>
521
+ <p class="field-error" data-error="dailyDollarCap" hidden></p>
240
522
  </div>
241
523
  <div class="d-actions"><button type="submit">Save</button></div>
242
- </form>
243
- </section>`;
524
+ </form>`;
244
525
  }
245
526
  function repoButtons(repos) {
246
527
  if (repos.length === 0)
@@ -256,216 +537,13 @@ function workflowOptions(names) {
256
537
  .map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
257
538
  .join("");
258
539
  }
259
- function afterOptions(items) {
260
- const open = items.filter((item) => item.job.state !== "done");
540
+ function afterOptions(jobs) {
541
+ const open = jobs.filter((job) => job.state !== "done");
261
542
  if (open.length === 0)
262
543
  return `<p class="empty">No open jobs.</p>`;
263
544
  return open
264
- .map((item) => {
265
- const job = item.job;
266
- return `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`;
267
- })
268
- .join("");
269
- }
270
- function sheet(item, csrf) {
271
- const job = item.job;
272
- const files = displayFiles(job);
273
- const round = lastRound(job);
274
- const pr = safeUrl(job.prUrl);
275
- const num = `${job.repo}/${job.jobId} · ${job.workflow} · ${job.state}${round ? ` · round ${round}` : ""}`;
276
- const branch = branchOf(job);
277
- return `<section class="sheet" data-job="${escapeHtml(job.jobId)}"${pr ? ` data-pr="${escapeHtml(pr)}"` : ""} hidden>
278
- <div class="d-head">
279
- <div class="num">${escapeHtml(num)}</div>
280
- <h3>${escapeHtml(job.title || job.jobId)}</h3>
281
- ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
282
- ${sandboxHtml(job)}
283
- ${prLink(job)}
284
- ${actionsHtml(item, csrf)}
285
- <button type="button" class="close" data-close aria-label="Close">×</button>
286
- </div>
287
- <div class="d-body">${filesNav(files, job)}</div>
288
- </section>`;
289
- }
290
- function filesNav(files, job) {
291
- let group = "";
292
- const buttons = files
293
- .map((file, index) => {
294
- const head = file.group !== group ? `<div class="group">${escapeHtml(file.group)}</div>` : "";
295
- group = file.group;
296
- const dot = file.tone ? ` ${file.tone}` : "";
297
- return `${head}<button type="button" class="file${index === 0 ? " on" : ""}" data-file="${escapeHtml(file.id)}"><span class="dot${dot}"></span>${escapeHtml(file.label)}</button>`;
298
- })
299
- .join("");
300
- const sources = files
301
- .map((file) => `<pre class="src" data-file="${escapeHtml(file.id)}" hidden>${escapeHtml(file.text)}</pre>`)
545
+ .map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`)
302
546
  .join("");
303
- const empty = files.length === 0 ? '<div class="empty">Nothing written yet.</div>' : "";
304
- return `<nav class="files">${buttons}${routeLinks(job)}${empty}</nav><article class="doc"><div class="md" data-view></div>${sources}</article>`;
305
- }
306
- function routeLinks(job) {
307
- const id = encodeURIComponent(job.jobId);
308
- const links = [];
309
- if (job.specPath)
310
- links.push(`<a class="file" href="/jobs/${id}/spec">spec</a>`);
311
- if (job.logPath)
312
- links.push(`<a class="file" href="/jobs/${id}/log">log</a>`);
313
- if (job.findingsPath)
314
- links.push(`<a class="file" href="/jobs/${id}/findings">Findings</a>`);
315
- if (links.length === 0)
316
- return "";
317
- return `<div class="group">Open</div>${links.join("")}`;
318
- }
319
- function actionsHtml(item, csrf) {
320
- const id = encodeURIComponent(item.job.jobId);
321
- const token = `<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
322
- const forms = [];
323
- if (item.job.canApprove) {
324
- forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve">${token}<button type="submit">Approve</button></form>`);
325
- }
326
- if (item.job.canReject) {
327
- forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
328
- }
329
- if (item.retry) {
330
- forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
331
- }
332
- if (forms.length === 0)
333
- return "";
334
- return `<div class="d-actions">${forms.join("")}</div>`;
335
- }
336
- function sandboxHtml(job) {
337
- if (!job.sandboxed)
338
- return "";
339
- return `<div class="meta">sandbox · docker</div>`;
340
- }
341
- function roundsHtml(job, stage) {
342
- const rounds = job.rounds ?? [];
343
- const last = rounds[rounds.length - 1]?.n;
344
- const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
345
- for (const round of job.feedbackRounds ?? []) {
346
- const tone = round.outcome ? " PASS" : " CHANGES";
347
- rows.push(`<div class="round"><span class="r">R${round.round}</span><span class="chip${tone}" style="grid-column: 2 / -1">${escapeHtml(feedbackRoundLabel(round))}</span></div>`);
348
- }
349
- const inReview = job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl));
350
- if (inReview && (job.feedbackRounds?.length ?? 0) === 0) {
351
- const n = (last ?? 0) + 1;
352
- const tone = job.state === "done" ? " PASS" : "";
353
- rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
354
- }
355
- if (rows.length === 0)
356
- return "";
357
- return `<div class="rounds">${rows.join("")}</div>`;
358
- }
359
- function roundRow(round, waiting) {
360
- const cells = REVIEWERS.map(([key, label]) => {
361
- const verdict = round[key];
362
- const tone = verdict === "PASS" || verdict === "CHANGES" ? verdict : waiting && !verdict ? "wait" : "";
363
- return `<span class="chip${tone ? ` ${tone}` : ""}" title="${escapeHtml(`${key}: ${verdict || "pending"}`)}">${label}</span>`;
364
- }).join("");
365
- return `<div class="round"><span class="r">R${round.n}</span>${cells}</div>`;
366
- }
367
- /**
368
- * A scheduled retry is one quiet line in the stage column.
369
- * A dead or non-retryable failure in Needs human keeps the attempt budget.
370
- */
371
- function attemptHtml(job, stage) {
372
- if (scheduledRetry(job) && job.nextRetryAt) {
373
- const detail = [`attempt ${job.attemptCount}/${job.maxAttempts}`];
374
- if (job.lastFailureReason)
375
- detail.push(job.lastFailureReason);
376
- return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
377
- }
378
- if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead"))
379
- return "";
380
- if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
381
- return "";
382
- const parts = [];
383
- if (job.attemptCount > 0)
384
- parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
385
- if (job.lastFailureReason)
386
- parts.push(job.lastFailureReason);
387
- const line = parts.length > 0 ? `<div class="meta">${escapeHtml(parts.join(" · "))}</div>` : "";
388
- const when = job.nextRetryAt
389
- ? `<div class="meta">next retry <time datetime="${escapeHtml(job.nextRetryAt)}">${escapeHtml(job.nextRetryAt)}</time></div>`
390
- : "";
391
- return `${line}${when}`;
392
- }
393
- function noteHtml(item) {
394
- const lines = [noteText(item)];
395
- if (item.errorTail) {
396
- for (const line of item.errorTail.split("\n")) {
397
- const trimmed = line.trim();
398
- if (trimmed && !lines.some((existing) => existing.includes(trimmed)))
399
- lines.push(trimmed);
400
- }
401
- }
402
- return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
403
- }
404
- function repoLabel(job) {
405
- if (job.deliver === "local" || job.deliver === "pr")
406
- return `${job.repo} · ${job.deliver}`;
407
- return job.repo;
408
- }
409
- function noteText(item) {
410
- const job = item.job;
411
- if (job.state === "done") {
412
- if (job.prNumber)
413
- return `merged · PR #${job.prNumber}`;
414
- if (safeUrl(job.prUrl))
415
- return "merged · PR";
416
- const branch = branchOf(job);
417
- return branch ? `landed locally · ${branch}` : "landed locally";
418
- }
419
- return item.reason;
420
- }
421
- function dependsHtml(job) {
422
- if (job.dependsOn.length === 0)
423
- return "";
424
- const links = job.dependsOn
425
- .map((dep) => `<a href="#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
426
- .join(", ");
427
- return `<p class="deps">depends on ${links}</p>`;
428
- }
429
- function prLink(job) {
430
- const safe = safeUrl(job.prUrl);
431
- if (safe) {
432
- const text = job.prNumber ? `PR #${job.prNumber}` : safe;
433
- return `<div class="branch"><a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(text)}</a></div>`;
434
- }
435
- if (job.prUrl)
436
- return `<div class="branch">${escapeHtml(job.prUrl)}</div>`;
437
- return "";
438
- }
439
- function branchOf(job) {
440
- if (job.branch)
441
- return job.branch;
442
- if (job.runId)
443
- return `yak/${job.runId}`;
444
- return "";
445
- }
446
- function lastRound(job) {
447
- const rounds = job.rounds ?? [];
448
- return rounds.length > 0 ? rounds[rounds.length - 1]?.n : undefined;
449
- }
450
- function displayFiles(job) {
451
- if (job.files && job.files.length > 0)
452
- return job.files;
453
- return readBoardFiles({
454
- ...(job.diffSummary && job.workflow !== INVESTIGATION_WORKFLOW ? { diffSummary: job.diffSummary } : {}),
455
- ...(job.logTail ? { logTail: job.logTail } : {}),
456
- ...(job.workflow ? { workflow: job.workflow } : {}),
457
- gates: job.gates.map((gate) => ({
458
- stepId: gate.stepId,
459
- prompt: gate.prompt,
460
- ...(gate.rendered ? { rendered: gate.rendered } : {}),
461
- choices: gate.choices,
462
- artifacts: gate.artifacts,
463
- reviews: gate.reviews,
464
- })),
465
- });
466
- }
467
- function chip(id, label, count, pressed) {
468
- return `<button type="button" class="repo-f" aria-pressed="${pressed ? "true" : "false"}" data-repo="${escapeHtml(id)}">${escapeHtml(label)}<span class="n">${count}</span></button>`;
469
547
  }
470
548
  function safeUrl(url) {
471
549
  if (!url)
@@ -481,6 +559,8 @@ function safeUrl(url) {
481
559
  }
482
560
  }
483
561
  function document(options) {
562
+ const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
563
+ const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
484
564
  return `<!DOCTYPE html>
485
565
  <html lang="en">
486
566
  <head>
@@ -492,7 +572,7 @@ function document(options) {
492
572
  ${options.css}
493
573
  </style>
494
574
  </head>
495
- <body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}">
575
+ <body data-section="${escapeHtml(options.section)}" data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}"${job}${latest}>
496
576
  ${options.body}
497
577
  ${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
498
578
  </body>