@lchase/kiln 0.3.0 → 0.4.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 +10 -6
- package/assets/serve.css +404 -185
- package/assets/serve.js +361 -0
- package/dist/attention.js +254 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +30 -6
- package/dist/config.js +3 -1
- package/dist/driver.js +299 -25
- package/dist/reconcile.js +59 -20
- package/dist/reliability.js +441 -0
- package/dist/report.js +108 -4
- package/dist/schemas.js +14 -1
- package/dist/serve-page.js +295 -159
- package/dist/serve.js +36 -14
- package/dist/workflow.js +67 -0
- package/package.json +1 -1
package/dist/serve-page.js
CHANGED
|
@@ -1,196 +1,343 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { scheduledRetry } from "./attention.js";
|
|
4
|
+
import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
1
5
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
import { packageRoot } from "./paths.js";
|
|
7
|
+
const REVIEWERS = [
|
|
8
|
+
["security", "sec"],
|
|
9
|
+
["ux", "ux"],
|
|
10
|
+
["ui", "ui"],
|
|
11
|
+
["code", "code"],
|
|
12
|
+
];
|
|
13
|
+
const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
|
|
14
|
+
function kilnVersion() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
17
|
+
return typeof parsed.version === "string" ? parsed.version : "";
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
17
22
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</section>`);
|
|
23
|
+
const VERSION = kilnVersion();
|
|
24
|
+
export function renderConsole(css, attention) {
|
|
25
|
+
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
26
|
+
const stages = stagesFor(items.map((item) => item.job));
|
|
27
|
+
const grouped = new Map();
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
const stage = stageOf(item.job);
|
|
30
|
+
const list = grouped.get(stage) ?? [];
|
|
31
|
+
list.push(item);
|
|
32
|
+
grouped.set(stage, list);
|
|
29
33
|
}
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
+
const tracks = stages
|
|
35
|
+
.map((stage) => ((grouped.get(stage.id)?.length ?? 0) === 0 ? "40px" : "minmax(0, 1fr)"))
|
|
36
|
+
.join(" ");
|
|
37
|
+
const columns = stages.map((stage) => column(stage, grouped.get(stage.id) ?? [])).join("\n");
|
|
38
|
+
const repos = [...new Set(items.map((item) => item.job.repo).filter((repo) => repo.length > 0))].sort();
|
|
39
|
+
const chips = [
|
|
40
|
+
chip("", "all", items.length, true),
|
|
41
|
+
...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
|
|
42
|
+
].join("");
|
|
43
|
+
const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
|
|
44
|
+
const body = `<header>
|
|
45
|
+
<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>
|
|
46
|
+
<div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
|
|
47
|
+
</header>
|
|
48
|
+
<nav class="repos" aria-label="Filter by repo">${chips}</nav>
|
|
49
|
+
<main class="board" id="board" style="--cols: ${tracks}">
|
|
50
|
+
${columns}
|
|
51
|
+
</main>
|
|
52
|
+
<nav class="toolbar" aria-label="Kiln commands">
|
|
53
|
+
<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>
|
|
54
|
+
<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>
|
|
55
|
+
<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>
|
|
56
|
+
<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>
|
|
57
|
+
<span class="sep" aria-hidden="true"></span>
|
|
58
|
+
<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>
|
|
59
|
+
</nav>
|
|
60
|
+
<div class="scrim" data-close></div>
|
|
61
|
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
|
|
62
|
+
${items.map((item) => sheet(item)).join("\n")}
|
|
63
|
+
</aside>`;
|
|
34
64
|
return document({
|
|
35
65
|
css,
|
|
36
|
-
title:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
title: `(${attention.needsYouCount}) Kiln`,
|
|
67
|
+
needsYou: attention.needsYouCount,
|
|
68
|
+
revision: attention.revision,
|
|
69
|
+
live: true,
|
|
70
|
+
body,
|
|
40
71
|
});
|
|
41
72
|
}
|
|
42
73
|
export function renderTextPage(css, title, text) {
|
|
43
74
|
return document({
|
|
44
75
|
css,
|
|
45
76
|
title,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<
|
|
77
|
+
needsYou: 0,
|
|
78
|
+
revision: "",
|
|
79
|
+
live: false,
|
|
80
|
+
body: `<a class="back" href="/">Console</a>
|
|
81
|
+
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
82
|
+
<pre class="text">${escapeHtml(text)}</pre></div>`,
|
|
51
83
|
});
|
|
52
84
|
}
|
|
53
85
|
export function renderMessagePage(css, title, message) {
|
|
54
86
|
return document({
|
|
55
87
|
css,
|
|
56
88
|
title,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
89
|
+
needsYou: 0,
|
|
90
|
+
revision: "",
|
|
91
|
+
live: false,
|
|
92
|
+
body: `<a class="back" href="/">Console</a>
|
|
93
|
+
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
94
|
+
<p class="empty">${escapeHtml(message)}</p></div>`,
|
|
62
95
|
});
|
|
63
96
|
}
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return `<
|
|
97
|
+
function column(stage, items) {
|
|
98
|
+
const slim = items.length === 0;
|
|
99
|
+
const tone = stage.tone ? ` ${stage.tone}` : "";
|
|
100
|
+
const cards = items.map((item) => card(item, stage.id)).join("\n");
|
|
101
|
+
const count = items.length ? String(items.length) : "";
|
|
102
|
+
return `<section class="col${slim ? " slim" : ""}${tone}" id="col-${escapeHtml(stage.id)}" data-stage="${escapeHtml(stage.id)}" aria-label="${escapeHtml(stage.label)}, ${items.length}">
|
|
103
|
+
<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>
|
|
104
|
+
<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>
|
|
105
|
+
${cards || ""}<div class="empty"${items.length > 0 ? " hidden" : ""}>none</div></div>
|
|
106
|
+
</section>`;
|
|
107
|
+
}
|
|
108
|
+
function card(item, stage) {
|
|
109
|
+
const job = item.job;
|
|
110
|
+
const waiting = scheduledRetry(job);
|
|
111
|
+
const classes = ["card"];
|
|
112
|
+
if (waiting)
|
|
113
|
+
classes.push("retrying");
|
|
114
|
+
else if (stage === "needs-human")
|
|
115
|
+
classes.push("human");
|
|
116
|
+
if (stage === "merged")
|
|
117
|
+
classes.push("merged");
|
|
118
|
+
if (stage === "queued")
|
|
119
|
+
classes.push("queued");
|
|
120
|
+
const deps = job.dependsOn.map((dep) => dep.jobId).join(" ");
|
|
121
|
+
const pulse = job.state === "running" ? '<span class="pulse" aria-label="running"></span>' : "";
|
|
122
|
+
const flag = stage === "needs-human" && !waiting ? '<span class="flag">your call</span>' : "";
|
|
123
|
+
const branch = branchOf(job);
|
|
124
|
+
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)}">
|
|
125
|
+
<div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
|
|
126
|
+
<div class="branch repo">${escapeHtml(job.repo)}</div>
|
|
127
|
+
<p class="title">${escapeHtml(job.title || job.jobId)}</p>
|
|
128
|
+
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
129
|
+
${prLink(job)}
|
|
130
|
+
${roundsHtml(job, stage)}
|
|
131
|
+
${attemptHtml(job, stage)}
|
|
132
|
+
${waiting ? "" : noteHtml(item)}
|
|
133
|
+
${dependsHtml(job)}
|
|
134
|
+
</div>`;
|
|
135
|
+
}
|
|
136
|
+
function sheet(item) {
|
|
137
|
+
const job = item.job;
|
|
138
|
+
const files = displayFiles(job);
|
|
139
|
+
const round = lastRound(job);
|
|
140
|
+
const pr = safeUrl(job.prUrl);
|
|
141
|
+
const num = `${job.repo}/${job.jobId} · ${job.workflow} · ${job.state}${round ? ` · round ${round}` : ""}`;
|
|
142
|
+
const branch = branchOf(job);
|
|
143
|
+
return `<section class="sheet" data-job="${escapeHtml(job.jobId)}"${pr ? ` data-pr="${escapeHtml(pr)}"` : ""} hidden>
|
|
144
|
+
<div class="d-head">
|
|
145
|
+
<div class="num">${escapeHtml(num)}</div>
|
|
111
146
|
<h3>${escapeHtml(job.title || job.jobId)}</h3>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
${
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</
|
|
119
|
-
}
|
|
120
|
-
function detailClass(state, line) {
|
|
121
|
-
if (line.startsWith("source ") ||
|
|
122
|
-
line.startsWith("gate ") ||
|
|
123
|
-
line.startsWith("approve:") ||
|
|
124
|
-
line.startsWith("reject:") ||
|
|
125
|
-
line.startsWith("waiting ") ||
|
|
126
|
-
line.startsWith("blocked on ") ||
|
|
127
|
-
line.startsWith("fix:")) {
|
|
128
|
-
return "";
|
|
129
|
-
}
|
|
130
|
-
if (state === "failed" || state === "dead")
|
|
131
|
-
return "bad";
|
|
132
|
-
return "";
|
|
147
|
+
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
148
|
+
${prLink(job)}
|
|
149
|
+
${actionsHtml(item)}
|
|
150
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="d-body">${filesNav(files, job)}</div>
|
|
153
|
+
</section>`;
|
|
133
154
|
}
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
function filesNav(files, job) {
|
|
156
|
+
let group = "";
|
|
157
|
+
const buttons = files
|
|
158
|
+
.map((file, index) => {
|
|
159
|
+
const head = file.group !== group ? `<div class="group">${escapeHtml(file.group)}</div>` : "";
|
|
160
|
+
group = file.group;
|
|
161
|
+
const dot = file.tone ? ` ${file.tone}` : "";
|
|
162
|
+
return `${head}<button type="button" class="file${index === 0 ? " on" : ""}" data-file="${escapeHtml(file.id)}"><span class="dot${dot}"></span>${escapeHtml(file.label)}</button>`;
|
|
163
|
+
})
|
|
164
|
+
.join("");
|
|
165
|
+
const sources = files
|
|
166
|
+
.map((file) => `<pre class="src" data-file="${escapeHtml(file.id)}" hidden>${escapeHtml(file.text)}</pre>`)
|
|
167
|
+
.join("");
|
|
168
|
+
const empty = files.length === 0 ? '<div class="empty">Nothing written yet.</div>' : "";
|
|
169
|
+
return `<nav class="files">${buttons}${routeLinks(job)}${empty}</nav><article class="doc"><div class="md" data-view></div>${sources}</article>`;
|
|
139
170
|
}
|
|
140
|
-
function
|
|
141
|
-
const links = [];
|
|
171
|
+
function routeLinks(job) {
|
|
142
172
|
const id = encodeURIComponent(job.jobId);
|
|
173
|
+
const links = [];
|
|
143
174
|
if (job.specPath)
|
|
144
|
-
links.push(`<a href="/jobs/${id}/spec">
|
|
175
|
+
links.push(`<a class="file" href="/jobs/${id}/spec">spec</a>`);
|
|
145
176
|
if (job.logPath)
|
|
146
|
-
links.push(`<a href="/jobs/${id}/log">
|
|
147
|
-
if (job.
|
|
148
|
-
links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
|
|
177
|
+
links.push(`<a class="file" href="/jobs/${id}/log">log</a>`);
|
|
178
|
+
if (job.findingsPath)
|
|
179
|
+
links.push(`<a class="file" href="/jobs/${id}/findings">Findings</a>`);
|
|
149
180
|
if (links.length === 0)
|
|
150
181
|
return "";
|
|
151
|
-
return `<
|
|
182
|
+
return `<div class="group">Open</div>${links.join("")}`;
|
|
152
183
|
}
|
|
153
|
-
function
|
|
184
|
+
function actionsHtml(item) {
|
|
185
|
+
const id = encodeURIComponent(item.job.jobId);
|
|
154
186
|
const forms = [];
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
if (item.job.canApprove) {
|
|
188
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve"><button type="submit">Approve</button></form>`);
|
|
189
|
+
}
|
|
190
|
+
if (item.job.canReject) {
|
|
191
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject"><button type="submit">Reject</button></form>`);
|
|
158
192
|
}
|
|
159
|
-
if (
|
|
160
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/
|
|
193
|
+
if (item.retry) {
|
|
194
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry"><button type="submit">Retry</button></form>`);
|
|
161
195
|
}
|
|
162
196
|
if (forms.length === 0)
|
|
163
197
|
return "";
|
|
164
|
-
return `<div class="actions">${forms.join("")}</div>`;
|
|
198
|
+
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
165
199
|
}
|
|
166
|
-
function
|
|
167
|
-
const
|
|
168
|
-
|
|
200
|
+
function roundsHtml(job, stage) {
|
|
201
|
+
const rounds = job.rounds ?? [];
|
|
202
|
+
const last = rounds[rounds.length - 1]?.n;
|
|
203
|
+
const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
|
|
204
|
+
if (job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl))) {
|
|
205
|
+
const n = (last ?? 0) + 1;
|
|
206
|
+
const tone = job.state === "done" ? " PASS" : "";
|
|
207
|
+
rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
|
|
208
|
+
}
|
|
209
|
+
if (rows.length === 0)
|
|
210
|
+
return "";
|
|
211
|
+
return `<div class="rounds">${rows.join("")}</div>`;
|
|
212
|
+
}
|
|
213
|
+
function roundRow(round, waiting) {
|
|
214
|
+
const cells = REVIEWERS.map(([key, label]) => {
|
|
215
|
+
const verdict = round[key];
|
|
216
|
+
const tone = verdict === "PASS" || verdict === "CHANGES" ? verdict : waiting && !verdict ? "wait" : "";
|
|
217
|
+
return `<span class="chip${tone ? ` ${tone}` : ""}" title="${escapeHtml(`${key}: ${verdict || "pending"}`)}">${label}</span>`;
|
|
218
|
+
}).join("");
|
|
219
|
+
return `<div class="round"><span class="r">R${round.n}</span>${cells}</div>`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A scheduled retry is one quiet line in the stage column.
|
|
223
|
+
* A dead or non-retryable failure in Needs human keeps the attempt budget.
|
|
224
|
+
*/
|
|
225
|
+
function attemptHtml(job, stage) {
|
|
226
|
+
if (scheduledRetry(job) && job.nextRetryAt) {
|
|
227
|
+
const detail = [`attempt ${job.attemptCount}/${job.maxAttempts}`];
|
|
228
|
+
if (job.lastFailureReason)
|
|
229
|
+
detail.push(job.lastFailureReason);
|
|
230
|
+
return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
|
|
231
|
+
}
|
|
232
|
+
if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead"))
|
|
233
|
+
return "";
|
|
234
|
+
if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
|
|
235
|
+
return "";
|
|
236
|
+
const parts = [];
|
|
237
|
+
if (job.attemptCount > 0)
|
|
238
|
+
parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
|
|
239
|
+
if (job.lastFailureReason)
|
|
240
|
+
parts.push(job.lastFailureReason);
|
|
241
|
+
const line = parts.length > 0 ? `<div class="meta">${escapeHtml(parts.join(" · "))}</div>` : "";
|
|
242
|
+
const when = job.nextRetryAt
|
|
243
|
+
? `<div class="meta">next retry <time datetime="${escapeHtml(job.nextRetryAt)}">${escapeHtml(job.nextRetryAt)}</time></div>`
|
|
169
244
|
: "";
|
|
245
|
+
return `${line}${when}`;
|
|
246
|
+
}
|
|
247
|
+
function noteHtml(item) {
|
|
248
|
+
const lines = [noteText(item)];
|
|
249
|
+
if (item.errorTail) {
|
|
250
|
+
for (const line of item.errorTail.split("\n")) {
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
if (trimmed && !lines.some((existing) => existing.includes(trimmed)))
|
|
253
|
+
lines.push(trimmed);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
257
|
+
}
|
|
258
|
+
function noteText(item) {
|
|
259
|
+
const job = item.job;
|
|
260
|
+
if (job.state === "done")
|
|
261
|
+
return safeUrl(job.prUrl) ? "merged via PR" : "landed locally";
|
|
262
|
+
return item.reason;
|
|
263
|
+
}
|
|
264
|
+
function dependsHtml(job) {
|
|
265
|
+
if (job.dependsOn.length === 0)
|
|
266
|
+
return "";
|
|
267
|
+
const links = job.dependsOn
|
|
268
|
+
.map((dep) => `<a href="#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
|
|
269
|
+
.join(", ");
|
|
270
|
+
return `<p class="deps">depends on ${links}</p>`;
|
|
271
|
+
}
|
|
272
|
+
function prLink(job) {
|
|
273
|
+
const safe = safeUrl(job.prUrl);
|
|
274
|
+
if (safe) {
|
|
275
|
+
const text = job.prNumber ? `PR #${job.prNumber}` : safe;
|
|
276
|
+
return `<div class="branch"><a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(text)}</a></div>`;
|
|
277
|
+
}
|
|
278
|
+
if (job.prUrl)
|
|
279
|
+
return `<div class="branch">${escapeHtml(job.prUrl)}</div>`;
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
function branchOf(job) {
|
|
283
|
+
if (job.branch)
|
|
284
|
+
return job.branch;
|
|
285
|
+
if (job.runId)
|
|
286
|
+
return `yak/${job.runId}`;
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
function lastRound(job) {
|
|
290
|
+
const rounds = job.rounds ?? [];
|
|
291
|
+
return rounds.length > 0 ? rounds[rounds.length - 1]?.n : undefined;
|
|
292
|
+
}
|
|
293
|
+
function displayFiles(job) {
|
|
294
|
+
if (job.files && job.files.length > 0)
|
|
295
|
+
return job.files;
|
|
296
|
+
return readBoardFiles({
|
|
297
|
+
...(job.diffSummary && job.workflow !== INVESTIGATION_WORKFLOW ? { diffSummary: job.diffSummary } : {}),
|
|
298
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
299
|
+
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
300
|
+
gates: job.gates.map((gate) => ({
|
|
301
|
+
stepId: gate.stepId,
|
|
302
|
+
prompt: gate.prompt,
|
|
303
|
+
...(gate.rendered ? { rendered: gate.rendered } : {}),
|
|
304
|
+
choices: gate.choices,
|
|
305
|
+
artifacts: gate.artifacts,
|
|
306
|
+
reviews: gate.reviews,
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function chip(id, label, count, pressed) {
|
|
311
|
+
return `<button type="button" class="repo-f" aria-pressed="${pressed ? "true" : "false"}" data-repo="${escapeHtml(id)}">${escapeHtml(label)}<span class="n">${count}</span></button>`;
|
|
312
|
+
}
|
|
313
|
+
function safeUrl(url) {
|
|
314
|
+
if (!url)
|
|
315
|
+
return undefined;
|
|
316
|
+
try {
|
|
317
|
+
const parsed = new URL(url);
|
|
318
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
319
|
+
return undefined;
|
|
320
|
+
return url;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function document(options) {
|
|
170
327
|
return `<!DOCTYPE html>
|
|
171
328
|
<html lang="en">
|
|
172
329
|
<head>
|
|
173
330
|
<meta charset="utf-8">
|
|
174
331
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
-
<meta name="referrer" content="same-origin">
|
|
176
|
-
${refresh}
|
|
177
332
|
<title>${escapeHtml(options.title)}</title>
|
|
333
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400;500&display=swap">
|
|
178
334
|
<style>
|
|
179
335
|
${options.css}
|
|
180
336
|
</style>
|
|
181
337
|
</head>
|
|
182
|
-
<body>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<nav>
|
|
186
|
-
<a href="/" ${options.current === "inbox" ? 'aria-current="page"' : ""}>Inbox</a>
|
|
187
|
-
<a href="/board" ${options.current === "board" ? 'aria-current="page"' : ""}>Board</a>
|
|
188
|
-
</nav>
|
|
189
|
-
${options.refresh ? `<p class="refresh">Refreshes every ${REFRESH_SECONDS}s</p>` : ""}
|
|
190
|
-
</header>
|
|
191
|
-
<main>
|
|
192
|
-
${options.main}
|
|
193
|
-
</main>
|
|
338
|
+
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}">
|
|
339
|
+
${options.body}
|
|
340
|
+
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
194
341
|
</body>
|
|
195
342
|
</html>
|
|
196
343
|
`;
|
|
@@ -203,14 +350,3 @@ export function escapeHtml(value) {
|
|
|
203
350
|
.replaceAll('"', """)
|
|
204
351
|
.replaceAll("'", "'");
|
|
205
352
|
}
|
|
206
|
-
function httpUrl(value) {
|
|
207
|
-
try {
|
|
208
|
-
const url = new URL(value);
|
|
209
|
-
if (url.protocol === "http:" || url.protocol === "https:")
|
|
210
|
-
return url.href;
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return undefined;
|
|
214
|
-
}
|
|
215
|
-
return undefined;
|
|
216
|
-
}
|
package/dist/serve.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, resolve, sep } from "node:path";
|
|
4
|
-
import { answerJob, readStatus, runTick } from "./driver.js";
|
|
4
|
+
import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./driver.js";
|
|
5
|
+
import { defaultHost } from "./host.js";
|
|
5
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
7
|
import { packageRoot } from "./paths.js";
|
|
7
|
-
import {
|
|
8
|
+
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
8
9
|
const BODY_LIMIT = 65_536;
|
|
9
10
|
const TEXT_LIMIT = 100_000;
|
|
10
11
|
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -13,6 +14,7 @@ const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
13
14
|
* Listens on 127.0.0.1 only. Resolves when the process is signalled.
|
|
14
15
|
*/
|
|
15
16
|
export function startServe(options) {
|
|
17
|
+
startupRecovery(options.config);
|
|
16
18
|
const css = loadServeCss();
|
|
17
19
|
const secrets = secretValues(options.config);
|
|
18
20
|
let port = options.port;
|
|
@@ -53,7 +55,7 @@ export function startServe(options) {
|
|
|
53
55
|
const tick = () => {
|
|
54
56
|
if (stopped)
|
|
55
57
|
return;
|
|
56
|
-
safeTick(options.config, secrets);
|
|
58
|
+
safeTick(options.config, secrets, false);
|
|
57
59
|
};
|
|
58
60
|
firstTick = setTimeout(tick, 0);
|
|
59
61
|
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
@@ -67,9 +69,9 @@ export function loadServeCss() {
|
|
|
67
69
|
}
|
|
68
70
|
return readFileSync(path, "utf8");
|
|
69
71
|
}
|
|
70
|
-
function safeTick(config, secrets) {
|
|
72
|
+
function safeTick(config, secrets, startup) {
|
|
71
73
|
try {
|
|
72
|
-
runTick(config);
|
|
74
|
+
runTick(config, defaultHost(), { startup });
|
|
73
75
|
}
|
|
74
76
|
catch (error) {
|
|
75
77
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -91,8 +93,8 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
91
93
|
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
92
94
|
return;
|
|
93
95
|
}
|
|
94
|
-
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject)$/.exec(path);
|
|
95
|
-
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject")) {
|
|
96
|
+
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
|
|
97
|
+
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry")) {
|
|
96
98
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
97
99
|
return;
|
|
98
100
|
}
|
|
@@ -101,6 +103,10 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
101
103
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
102
104
|
return;
|
|
103
105
|
}
|
|
106
|
+
if (action[2] === "retry") {
|
|
107
|
+
await retry(req, res, config, css, secrets, jobId);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
104
110
|
await answer(req, res, config, css, secrets, jobId, action[2]);
|
|
105
111
|
return;
|
|
106
112
|
}
|
|
@@ -108,12 +114,8 @@ async function handle(req, res, config, boundPort, css, secrets) {
|
|
|
108
114
|
sendJson(res, 405, { error: "Method not allowed." }, secrets);
|
|
109
115
|
return;
|
|
110
116
|
}
|
|
111
|
-
if (path === "/" || path === "") {
|
|
112
|
-
sendHtml(res, 200,
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (path === "/board") {
|
|
116
|
-
sendHtml(res, 200, renderBoard(css, readStatus(config)), secrets);
|
|
117
|
+
if (path === "/" || path === "" || path === "/board") {
|
|
118
|
+
sendHtml(res, 200, renderConsole(css, readStatus(config).attention), secrets);
|
|
117
119
|
return;
|
|
118
120
|
}
|
|
119
121
|
if (path === "/api/jobs") {
|
|
@@ -182,6 +184,26 @@ async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
|
182
184
|
}
|
|
183
185
|
redirect(res, redirectTarget(req));
|
|
184
186
|
}
|
|
187
|
+
async function retry(req, res, config, css, secrets, jobId) {
|
|
188
|
+
try {
|
|
189
|
+
retryJob(config, jobId);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
const status = message.startsWith("No job ") ? 404 : 409;
|
|
194
|
+
if (wantsJson(req))
|
|
195
|
+
sendJson(res, status, { error: message }, secrets);
|
|
196
|
+
else
|
|
197
|
+
sendHtml(res, status, renderMessagePage(css, "Could not retry", message), secrets);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const job = findJob(config, jobId);
|
|
201
|
+
if (wantsJson(req)) {
|
|
202
|
+
sendJson(res, 200, { ok: true, job }, secrets);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
redirect(res, redirectTarget(req));
|
|
206
|
+
}
|
|
185
207
|
function sendFindings(res, config, css, secrets, job) {
|
|
186
208
|
if (job.workflow !== INVESTIGATION_WORKFLOW) {
|
|
187
209
|
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
@@ -314,7 +336,7 @@ function sendHtml(res, status, html, secrets) {
|
|
|
314
336
|
Connection: "close",
|
|
315
337
|
"Cache-Control": "no-store",
|
|
316
338
|
"X-Content-Type-Options": "nosniff",
|
|
317
|
-
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
339
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
318
340
|
"Referrer-Policy": "same-origin",
|
|
319
341
|
});
|
|
320
342
|
res.end(body);
|