@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.
- package/README.md +5 -5
- package/assets/serve.css +195 -472
- package/assets/serve.js +121 -6
- package/dist/attention.js +13 -4
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- package/dist/cli.js +63 -6
- package/dist/config.js +25 -1
- package/dist/driver.js +117 -16
- package/dist/events.js +40 -1
- package/dist/failure.js +140 -0
- package/dist/fix-commit.js +73 -0
- package/dist/ledger.js +11 -3
- package/dist/project.js +20 -4
- package/dist/rails.js +164 -0
- package/dist/reconcile.js +29 -3
- package/dist/reliability.js +53 -25
- package/dist/report.js +19 -5
- package/dist/schemas.js +35 -5
- package/dist/serve-page.js +396 -316
- package/dist/serve.js +252 -13
- package/dist/settings.js +9 -1
- package/package.json +4 -3
- package/workflows/fix.yaml +1 -1
package/dist/serve-page.js
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
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
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
</
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<button class="
|
|
60
|
-
<button class="tool" type="button"
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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="
|
|
69
|
-
${intakeSheet(
|
|
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: `(${
|
|
76
|
-
needsYou: attention.needsYouCount,
|
|
77
|
-
revision: attention.revision,
|
|
78
|
-
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="/">
|
|
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="/">
|
|
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
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
-
|
|
160
|
+
if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
|
|
125
161
|
classes.push("human");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
${
|
|
142
|
-
|
|
143
|
-
${
|
|
144
|
-
|
|
145
|
-
</div
|
|
146
|
-
}
|
|
147
|
-
|
|
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
|
|
180
|
-
return `<
|
|
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(
|
|
260
|
-
const open =
|
|
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((
|
|
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>
|