@lchase/kiln 0.10.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,18 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { formatSpend } from "./rails.js";
3
2
  import { join } from "node:path";
4
- import { scheduledRetry } from "./attention.js";
5
- import { readBoardFiles, stageOf, stagesFor } from "./board.js";
6
- import { feedbackRoundLabel } from "./feedback.js";
7
- import { INVESTIGATION_WORKFLOW } from "./findings.js";
3
+ import { buildBoard, } from "./board-view.js";
8
4
  import { packageRoot } from "./paths.js";
5
+ import { formatSpend } from "./rails.js";
9
6
  import { listWorkflowNames } from "./workflow.js";
10
- const REVIEWERS = [
11
- ["security", "sec"],
12
- ["ux", "ux"],
13
- ["ui", "ui"],
14
- ["code", "code"],
15
- ];
16
7
  const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
17
8
  function kilnVersion() {
18
9
  try {
@@ -25,62 +16,47 @@ function kilnVersion() {
25
16
  }
26
17
  const VERSION = kilnVersion();
27
18
  export function renderConsole(css, attention, page) {
28
- const intake = page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
29
- const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
30
- const stages = stagesFor(items.map((item) => item.job));
31
- const grouped = new Map();
32
- for (const item of items) {
33
- const stage = stageOf(item.job);
34
- const list = grouped.get(stage) ?? [];
35
- list.push(item);
36
- grouped.set(stage, list);
37
- }
38
- const tracks = stages
39
- .map((stage) => ((grouped.get(stage.id)?.length ?? 0) === 0 ? "40px" : "minmax(0, 1fr)"))
40
- .join(" ");
41
- const columns = stages.map((stage) => column(stage, grouped.get(stage.id) ?? [])).join("\n");
42
- const repos = [...new Set(items.map((item) => item.job.repo).filter((repo) => repo.length > 0))].sort();
43
- const chips = [
44
- chip("", "all", items.length, true),
45
- ...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
46
- ].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;
47
27
  const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
48
- const paused = intake.paused === true;
49
- const spend = intake.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(intake.spend))}</span>` : "";
50
- const body = `<header>
51
- <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>
52
- ${spend}
53
- <form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(intake.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${paused ? "Resume" : "Pause"}</button></form>
54
- <div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
55
- </header>
56
- <p class="meta config-error" id="config-error" role="status"${intake.configError ? "" : " hidden"}>${escapeHtml(intake.configError ?? "")}</p>
57
- <nav class="repos" aria-label="Filter by repo">${chips}</nav>
58
- <main class="board" id="board" style="--cols: ${tracks}">
59
- ${columns}
60
- </main>
61
- <nav class="toolbar" aria-label="Kiln commands">
62
- <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>
63
- <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>
64
- <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>
65
- <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>
66
- <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>
67
- <span class="sep" aria-hidden="true"></span>
68
- <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>
69
- <span class="sep" aria-hidden="true"></span>
70
- <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>
71
- </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>
72
47
  <div class="scrim" data-close></div>
73
- <aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
74
- ${intakeSheet(intake, items)}
75
- ${settingsSheet()}
76
- ${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)}
77
50
  </aside>`;
51
+ const job = model.detail?.job;
78
52
  return document({
79
- css,
80
- title: `(${attention.needsYouCount}) Kiln`,
81
- needsYou: attention.needsYouCount,
82
- revision: attention.revision,
83
- 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 } : {}),
84
60
  live: true,
85
61
  body,
86
62
  });
@@ -91,8 +67,9 @@ export function renderTextPage(css, title, text) {
91
67
  title,
92
68
  needsYou: 0,
93
69
  revision: "",
70
+ section: "job",
94
71
  live: false,
95
- body: `<a class="back" href="/">Console</a>
72
+ body: `<a class="back" href="/">Inbox</a>
96
73
  <div class="page"><h1>${escapeHtml(title)}</h1>
97
74
  <pre class="text">${escapeHtml(text)}</pre></div>`,
98
75
  });
@@ -103,53 +80,347 @@ export function renderMessagePage(css, title, message) {
103
80
  title,
104
81
  needsYou: 0,
105
82
  revision: "",
83
+ section: "job",
106
84
  live: false,
107
- body: `<a class="back" href="/">Console</a>
85
+ body: `<a class="back" href="/">Inbox</a>
108
86
  <div class="page"><h1>${escapeHtml(title)}</h1>
109
87
  <p class="empty">${escapeHtml(message)}</p></div>`,
110
88
  });
111
89
  }
112
- function column(stage, items) {
113
- const slim = items.length === 0;
114
- const tone = stage.tone ? ` ${stage.tone}` : "";
115
- const cards = items.map((item) => card(item, stage.id)).join("\n");
116
- const count = items.length ? String(items.length) : "";
117
- return `<section class="col${slim ? " slim" : ""}${tone}" id="col-${escapeHtml(stage.id)}" data-stage="${escapeHtml(stage.id)}" aria-label="${escapeHtml(stage.label)}, ${items.length}">
118
- <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>
119
- <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>
120
- ${cards || ""}<div class="empty"${items.length > 0 ? " hidden" : ""}>none</div></div>
121
- </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>`;
122
131
  }
123
- function card(item, stage) {
124
- const job = item.job;
125
- const waiting = scheduledRetry(job);
126
- const classes = ["card"];
127
- if (waiting)
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>`;
154
+ }
155
+ function jobRow(job, section, csrf) {
156
+ const id = encodeURIComponent(job.jobId);
157
+ const classes = ["job"];
158
+ if (job.scheduledRetry)
128
159
  classes.push("retrying");
129
- else if (stage === "needs-human")
160
+ if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
130
161
  classes.push("human");
131
- if (stage === "done" || stage === "merged")
132
- classes.push("merged");
133
- if (stage === "queued")
134
- classes.push("queued");
135
- const deps = job.dependsOn.map((dep) => dep.jobId).join(" ");
136
- const pulse = job.state === "running" ? '<span class="pulse" aria-label="running"></span>' : "";
137
- const flag = stage === "needs-human" && !waiting ? '<span class="flag">your call</span>' : "";
138
- const branch = branchOf(job);
139
- 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)}">
140
- <div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
141
- <div class="branch repo">${escapeHtml(repoLabel(job))}</div>
142
- <p class="title">${escapeHtml(job.title || job.jobId)}</p>
143
- ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
144
- ${sandboxHtml(job)}
145
- ${prLink(job)}
146
- ${roundsHtml(job, stage)}
147
- ${attemptHtml(job, stage)}
148
- ${waiting ? "" : noteHtml(item)}
149
- ${dependsHtml(job)}
150
- </div>`;
151
- }
152
- 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];
153
424
  return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
154
425
  <div class="d-head">
155
426
  <div class="num">kiln add</div>
@@ -174,21 +445,15 @@ function intakeSheet(intake, items) {
174
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>
175
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>
176
447
  <label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
177
- <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>
178
449
  <pre class="text preview" id="intake-preview" hidden></pre>
179
450
  <p class="form-error" id="intake-error" role="alert" hidden></p>
180
451
  <div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
181
452
  </form>
182
453
  </section>`;
183
454
  }
184
- function settingsSheet() {
185
- return `<section class="sheet intake" id="settings" aria-label="Settings" hidden>
186
- <div class="d-head">
187
- <div class="num">kiln.config.json</div>
188
- <h3>Settings</h3>
189
- <button type="button" class="close" data-close aria-label="Close">×</button>
190
- </div>
191
- <form id="settings-form" class="intake-form">
455
+ function settingsForm() {
456
+ return `<form id="settings-form" class="intake-form">
192
457
  <p class="meta" id="settings-path"></p>
193
458
  <p class="saved" id="settings-saved" role="status" hidden>Saved</p>
194
459
  <p class="form-error" id="settings-error" role="alert" hidden></p>
@@ -256,8 +521,7 @@ function settingsSheet() {
256
521
  <p class="field-error" data-error="dailyDollarCap" hidden></p>
257
522
  </div>
258
523
  <div class="d-actions"><button type="submit">Save</button></div>
259
- </form>
260
- </section>`;
524
+ </form>`;
261
525
  }
262
526
  function repoButtons(repos) {
263
527
  if (repos.length === 0)
@@ -273,219 +537,13 @@ function workflowOptions(names) {
273
537
  .map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
274
538
  .join("");
275
539
  }
276
- function afterOptions(items) {
277
- const open = items.filter((item) => item.job.state !== "done");
540
+ function afterOptions(jobs) {
541
+ const open = jobs.filter((job) => job.state !== "done");
278
542
  if (open.length === 0)
279
543
  return `<p class="empty">No open jobs.</p>`;
280
544
  return open
281
- .map((item) => {
282
- const job = item.job;
283
- 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>`;
284
- })
285
- .join("");
286
- }
287
- function sheet(item, csrf) {
288
- const job = item.job;
289
- const files = displayFiles(job);
290
- const round = lastRound(job);
291
- const pr = safeUrl(job.prUrl);
292
- const num = `${job.repo}/${job.jobId} · ${job.workflow} · ${job.state}${round ? ` · round ${round}` : ""}`;
293
- const branch = branchOf(job);
294
- return `<section class="sheet" data-job="${escapeHtml(job.jobId)}"${pr ? ` data-pr="${escapeHtml(pr)}"` : ""} hidden>
295
- <div class="d-head">
296
- <div class="num">${escapeHtml(num)}</div>
297
- <h3>${escapeHtml(job.title || job.jobId)}</h3>
298
- ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
299
- ${sandboxHtml(job)}
300
- ${prLink(job)}
301
- ${actionsHtml(item, csrf)}
302
- <button type="button" class="close" data-close aria-label="Close">×</button>
303
- </div>
304
- <div class="d-body">${filesNav(files, job)}</div>
305
- </section>`;
306
- }
307
- function filesNav(files, job) {
308
- let group = "";
309
- const buttons = files
310
- .map((file, index) => {
311
- const head = file.group !== group ? `<div class="group">${escapeHtml(file.group)}</div>` : "";
312
- group = file.group;
313
- const dot = file.tone ? ` ${file.tone}` : "";
314
- return `${head}<button type="button" class="file${index === 0 ? " on" : ""}" data-file="${escapeHtml(file.id)}"><span class="dot${dot}"></span>${escapeHtml(file.label)}</button>`;
315
- })
316
- .join("");
317
- const sources = files
318
- .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>`)
319
546
  .join("");
320
- const empty = files.length === 0 ? '<div class="empty">Nothing written yet.</div>' : "";
321
- return `<nav class="files">${buttons}${routeLinks(job)}${empty}</nav><article class="doc"><div class="md" data-view></div>${sources}</article>`;
322
- }
323
- function routeLinks(job) {
324
- const id = encodeURIComponent(job.jobId);
325
- const links = [];
326
- if (job.specPath)
327
- links.push(`<a class="file" href="/jobs/${id}/spec">spec</a>`);
328
- if (job.logPath)
329
- links.push(`<a class="file" href="/jobs/${id}/log">log</a>`);
330
- if (job.findingsPath)
331
- links.push(`<a class="file" href="/jobs/${id}/findings">Findings</a>`);
332
- if (links.length === 0)
333
- return "";
334
- return `<div class="group">Open</div>${links.join("")}`;
335
- }
336
- function actionsHtml(item, csrf) {
337
- const id = encodeURIComponent(item.job.jobId);
338
- const token = `<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
339
- const forms = [];
340
- if (item.job.canApprove) {
341
- forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve">${token}<button type="submit">Approve</button></form>`);
342
- }
343
- if (item.job.canReject) {
344
- forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
345
- }
346
- if (item.job.canRevise) {
347
- forms.push(`<form method="post" action="/api/jobs/${id}/revise" class="act revise">${token}<input name="note" type="text" required maxlength="8000" placeholder="Note for the next round" autocomplete="off"><button type="submit">Revise</button></form>`);
348
- }
349
- if (item.retry) {
350
- forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
351
- }
352
- if (forms.length === 0)
353
- return "";
354
- return `<div class="d-actions">${forms.join("")}</div>`;
355
- }
356
- function sandboxHtml(job) {
357
- if (!job.sandboxed)
358
- return "";
359
- return `<div class="meta">sandbox · docker</div>`;
360
- }
361
- function roundsHtml(job, stage) {
362
- const rounds = job.rounds ?? [];
363
- const last = rounds[rounds.length - 1]?.n;
364
- const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
365
- for (const round of job.feedbackRounds ?? []) {
366
- const tone = round.outcome ? " PASS" : " CHANGES";
367
- 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>`);
368
- }
369
- const inReview = job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl));
370
- if (inReview && (job.feedbackRounds?.length ?? 0) === 0) {
371
- const n = (last ?? 0) + 1;
372
- const tone = job.state === "done" ? " PASS" : "";
373
- rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
374
- }
375
- if (rows.length === 0)
376
- return "";
377
- return `<div class="rounds">${rows.join("")}</div>`;
378
- }
379
- function roundRow(round, waiting) {
380
- const cells = REVIEWERS.map(([key, label]) => {
381
- const verdict = round[key];
382
- const tone = verdict === "PASS" || verdict === "CHANGES" ? verdict : waiting && !verdict ? "wait" : "";
383
- return `<span class="chip${tone ? ` ${tone}` : ""}" title="${escapeHtml(`${key}: ${verdict || "pending"}`)}">${label}</span>`;
384
- }).join("");
385
- return `<div class="round"><span class="r">R${round.n}</span>${cells}</div>`;
386
- }
387
- /**
388
- * A scheduled retry is one quiet line in the stage column.
389
- * A dead or non-retryable failure in Needs human keeps the attempt budget.
390
- */
391
- function attemptHtml(job, stage) {
392
- if (scheduledRetry(job) && job.nextRetryAt) {
393
- const detail = [`attempt ${job.attemptCount}/${job.maxAttempts}`];
394
- if (job.lastFailureReason)
395
- detail.push(job.lastFailureReason);
396
- return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
397
- }
398
- if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead" && job.state !== "needs-human"))
399
- return "";
400
- if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
401
- return "";
402
- const parts = [];
403
- if (job.attemptCount > 0)
404
- parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
405
- if (job.lastFailureReason)
406
- parts.push(job.lastFailureReason);
407
- const line = parts.length > 0 ? `<div class="meta">${escapeHtml(parts.join(" · "))}</div>` : "";
408
- const when = job.nextRetryAt
409
- ? `<div class="meta">next retry <time datetime="${escapeHtml(job.nextRetryAt)}">${escapeHtml(job.nextRetryAt)}</time></div>`
410
- : "";
411
- return `${line}${when}`;
412
- }
413
- function noteHtml(item) {
414
- const lines = [noteText(item)];
415
- if (item.errorTail) {
416
- for (const line of item.errorTail.split("\n")) {
417
- const trimmed = line.trim();
418
- if (trimmed && !lines.some((existing) => existing.includes(trimmed)))
419
- lines.push(trimmed);
420
- }
421
- }
422
- return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
423
- }
424
- function repoLabel(job) {
425
- if (job.deliver === "local" || job.deliver === "pr")
426
- return `${job.repo} · ${job.deliver}`;
427
- return job.repo;
428
- }
429
- function noteText(item) {
430
- const job = item.job;
431
- if (job.state === "done") {
432
- if (job.prNumber)
433
- return `merged · PR #${job.prNumber}`;
434
- if (safeUrl(job.prUrl))
435
- return "merged · PR";
436
- const branch = branchOf(job);
437
- return branch ? `landed locally · ${branch}` : "landed locally";
438
- }
439
- return item.reason;
440
- }
441
- function dependsHtml(job) {
442
- if (job.dependsOn.length === 0)
443
- return "";
444
- const links = job.dependsOn
445
- .map((dep) => `<a href="#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
446
- .join(", ");
447
- return `<p class="deps">depends on ${links}</p>`;
448
- }
449
- function prLink(job) {
450
- const safe = safeUrl(job.prUrl);
451
- if (safe) {
452
- const text = job.prNumber ? `PR #${job.prNumber}` : safe;
453
- return `<div class="branch"><a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(text)}</a></div>`;
454
- }
455
- if (job.prUrl)
456
- return `<div class="branch">${escapeHtml(job.prUrl)}</div>`;
457
- return "";
458
- }
459
- function branchOf(job) {
460
- if (job.branch)
461
- return job.branch;
462
- if (job.runId)
463
- return `yak/${job.runId}`;
464
- return "";
465
- }
466
- function lastRound(job) {
467
- const rounds = job.rounds ?? [];
468
- return rounds.length > 0 ? rounds[rounds.length - 1]?.n : undefined;
469
- }
470
- function displayFiles(job) {
471
- if (job.files && job.files.length > 0)
472
- return job.files;
473
- return readBoardFiles({
474
- ...(job.diffSummary && job.workflow !== INVESTIGATION_WORKFLOW ? { diffSummary: job.diffSummary } : {}),
475
- ...(job.logTail ? { logTail: job.logTail } : {}),
476
- ...(job.workflow ? { workflow: job.workflow } : {}),
477
- gates: job.gates.map((gate) => ({
478
- stepId: gate.stepId,
479
- prompt: gate.prompt,
480
- ...(gate.rendered ? { rendered: gate.rendered } : {}),
481
- choices: gate.choices,
482
- artifacts: gate.artifacts,
483
- reviews: gate.reviews,
484
- })),
485
- });
486
- }
487
- function chip(id, label, count, pressed) {
488
- return `<button type="button" class="repo-f" aria-pressed="${pressed ? "true" : "false"}" data-repo="${escapeHtml(id)}">${escapeHtml(label)}<span class="n">${count}</span></button>`;
489
547
  }
490
548
  function safeUrl(url) {
491
549
  if (!url)
@@ -501,6 +559,8 @@ function safeUrl(url) {
501
559
  }
502
560
  }
503
561
  function document(options) {
562
+ const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
563
+ const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
504
564
  return `<!DOCTYPE html>
505
565
  <html lang="en">
506
566
  <head>
@@ -512,7 +572,7 @@ function document(options) {
512
572
  ${options.css}
513
573
  </style>
514
574
  </head>
515
- <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}>
516
576
  ${options.body}
517
577
  ${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
518
578
  </body>