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