@lchase/kiln 0.0.0-stage → 0.3.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 +263 -2
- package/assets/serve.css +233 -0
- package/dist/cli.js +409 -0
- package/dist/config.js +255 -0
- package/dist/delivery.js +245 -0
- package/dist/driver.js +825 -0
- package/dist/errors.js +6 -0
- package/dist/findings.js +79 -0
- package/dist/host.js +223 -0
- package/dist/intake/file.js +29 -0
- package/dist/intake/jira.js +585 -0
- package/dist/intake/prompt.js +47 -0
- package/dist/intake/tickets.js +448 -0
- package/dist/jobs.js +154 -0
- package/dist/paths.js +34 -0
- package/dist/reconcile.js +141 -0
- package/dist/report.js +254 -0
- package/dist/schemas.js +95 -0
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/truncate-output.js +122 -0
- package/dist/workflow.js +131 -0
- package/package.json +41 -4
- package/prompts/factory/approver.md +49 -0
- package/prompts/factory/builder.md +76 -0
- package/prompts/factory/review-code.md +55 -0
- package/prompts/factory/review-security.md +50 -0
- package/prompts/factory/review-ui.md +50 -0
- package/prompts/factory/review-ux.md +48 -0
- package/prompts/factory/spec-writer.md +67 -0
- package/prompts/kiln/approver.md +33 -0
- package/prompts/kiln/build.md +38 -0
- package/prompts/kiln/code.md +26 -0
- package/prompts/kiln/investigation.md +31 -0
- package/prompts/kiln/security.md +26 -0
- package/prompts/kiln/spec.md +18 -0
- package/prompts/kiln/ui.md +26 -0
- package/prompts/kiln/ux.md +26 -0
- package/workflows/feature.yaml +239 -0
- package/workflows/investigation.yaml +67 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
+
import { statusDetails } from "./report.js";
|
|
3
|
+
import { JOB_STATES } from "./schemas.js";
|
|
4
|
+
const REFRESH_SECONDS = 3;
|
|
5
|
+
export function renderInbox(css, view) {
|
|
6
|
+
const waiting = view.jobs.filter((job) => job.state === "needs-human");
|
|
7
|
+
const body = waiting.length === 0 ? emptyInbox() : waiting.map(renderInboxCard).join("\n");
|
|
8
|
+
return document({
|
|
9
|
+
css,
|
|
10
|
+
title: "Kiln inbox",
|
|
11
|
+
refresh: true,
|
|
12
|
+
current: "inbox",
|
|
13
|
+
main: `<h1>Inbox</h1>
|
|
14
|
+
<p class="lede">Jobs waiting on a person. Approve and reject use the same gate as the CLI.</p>
|
|
15
|
+
${body}`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function renderBoard(css, view) {
|
|
19
|
+
const groups = [];
|
|
20
|
+
const states = [...JOB_STATES, "invalid"];
|
|
21
|
+
for (const state of states) {
|
|
22
|
+
const jobs = view.jobs.filter((job) => job.state === state);
|
|
23
|
+
if (jobs.length === 0)
|
|
24
|
+
continue;
|
|
25
|
+
groups.push(`<section class="group" id="state-${escapeHtml(state)}">
|
|
26
|
+
<h2>${escapeHtml(state)} <span class="count">${jobs.length}</span></h2>
|
|
27
|
+
${jobs.map(renderBoardJob).join("\n")}
|
|
28
|
+
</section>`);
|
|
29
|
+
}
|
|
30
|
+
const main = groups.length > 0
|
|
31
|
+
? groups.join("\n")
|
|
32
|
+
: `<h1>Board</h1><p class="empty">No jobs.</p>`;
|
|
33
|
+
const titled = groups.length > 0 ? `<h1>Board</h1>\n${main}` : main;
|
|
34
|
+
return document({
|
|
35
|
+
css,
|
|
36
|
+
title: "Kiln board",
|
|
37
|
+
refresh: true,
|
|
38
|
+
current: "board",
|
|
39
|
+
main: titled,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function renderTextPage(css, title, text) {
|
|
43
|
+
return document({
|
|
44
|
+
css,
|
|
45
|
+
title,
|
|
46
|
+
refresh: false,
|
|
47
|
+
current: "inbox",
|
|
48
|
+
main: `<a class="back" href="/">Inbox</a>
|
|
49
|
+
<h1>${escapeHtml(title)}</h1>
|
|
50
|
+
<pre class="text">${escapeHtml(text)}</pre>`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function renderMessagePage(css, title, message) {
|
|
54
|
+
return document({
|
|
55
|
+
css,
|
|
56
|
+
title,
|
|
57
|
+
refresh: false,
|
|
58
|
+
current: "inbox",
|
|
59
|
+
main: `<a class="back" href="/">Inbox</a>
|
|
60
|
+
<h1>${escapeHtml(title)}</h1>
|
|
61
|
+
<p class="empty">${escapeHtml(message)}</p>`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function emptyInbox() {
|
|
65
|
+
return `<p class="empty">Nothing is waiting on a person. The <a href="/board">board</a> lists every job.</p>`;
|
|
66
|
+
}
|
|
67
|
+
function renderInboxCard(job) {
|
|
68
|
+
const gates = job.gates
|
|
69
|
+
.map((gate) => {
|
|
70
|
+
const asking = gate.rendered && gate.rendered !== gate.prompt ? gate.rendered : gate.prompt;
|
|
71
|
+
const artifacts = gate.artifacts.length === 0
|
|
72
|
+
? ""
|
|
73
|
+
: `<ul class="facts">${gate.artifacts
|
|
74
|
+
.map((artifact) => `<li><span class="name">${escapeHtml(artifact.name)}</span> ${escapeHtml(artifact.summary)}</li>`)
|
|
75
|
+
.join("")}</ul>`;
|
|
76
|
+
const reviews = gate.reviews.length === 0
|
|
77
|
+
? ""
|
|
78
|
+
: `<h4>Reviews</h4><ul class="facts">${gate.reviews
|
|
79
|
+
.map((review) => `<li><span class="name">${escapeHtml(review.name)}</span> ${escapeHtml(review.summary)}</li>`)
|
|
80
|
+
.join("")}</ul>`;
|
|
81
|
+
return `<p class="prompt">${escapeHtml(asking)}</p>${artifacts}${reviews}`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
return `<article class="card" id="job-${escapeHtml(job.jobId)}">
|
|
85
|
+
<h2>${escapeHtml(job.title || job.jobId)}</h2>
|
|
86
|
+
<p class="meta">${escapeHtml(job.repo)} · ${escapeHtml(job.workflow)} · <code>${escapeHtml(job.jobId)}</code>${job.stack ? ' · <span class="chip">stacked</span>' : ""}</p>
|
|
87
|
+
${gates}
|
|
88
|
+
${resourceLinks(job)}
|
|
89
|
+
${actionForms(job)}
|
|
90
|
+
</article>`;
|
|
91
|
+
}
|
|
92
|
+
function renderBoardJob(job) {
|
|
93
|
+
const deps = job.dependsOn.length === 0
|
|
94
|
+
? ""
|
|
95
|
+
: `<p class="deps">depends on ${job.dependsOn
|
|
96
|
+
.map((dep) => {
|
|
97
|
+
const why = dep.blockedDetail ? ` <span class="why">${escapeHtml(dep.blockedDetail)}</span>` : "";
|
|
98
|
+
return `<a href="/board#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a> <span class="chip">${escapeHtml(dep.state)}</span>${why}`;
|
|
99
|
+
})
|
|
100
|
+
.join(", ")}</p>`;
|
|
101
|
+
const pr = job.prUrl && (job.state === "in-review" || job.state === "done" || job.state === "failed")
|
|
102
|
+
? renderPr(job.prUrl)
|
|
103
|
+
: "";
|
|
104
|
+
const details = statusDetails(job);
|
|
105
|
+
const detailList = details.length === 0
|
|
106
|
+
? ""
|
|
107
|
+
: `<ul class="details">${details
|
|
108
|
+
.map((line) => `<li class="${detailClass(job.state, line)}">${escapeHtml(line)}</li>`)
|
|
109
|
+
.join("")}</ul>`;
|
|
110
|
+
return `<article class="job" id="job-${escapeHtml(job.jobId)}">
|
|
111
|
+
<h3>${escapeHtml(job.title || job.jobId)}</h3>
|
|
112
|
+
<p class="meta">${escapeHtml(job.repo)} · ${escapeHtml(job.workflow)} · <code>${escapeHtml(job.jobId)}</code></p>
|
|
113
|
+
<p class="chips"><span class="chip state-${escapeHtml(job.state)}">${escapeHtml(job.state)}</span>${job.stack ? '<span class="chip">stacked</span>' : ""}</p>
|
|
114
|
+
${deps}
|
|
115
|
+
${pr}
|
|
116
|
+
${detailList}
|
|
117
|
+
${resourceLinks(job)}
|
|
118
|
+
</article>`;
|
|
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 "";
|
|
133
|
+
}
|
|
134
|
+
function renderPr(url) {
|
|
135
|
+
const safe = httpUrl(url);
|
|
136
|
+
if (!safe)
|
|
137
|
+
return `<p class="links">${escapeHtml(url)}</p>`;
|
|
138
|
+
return `<p class="links"><a href="${escapeHtml(safe)}">Pull request</a></p>`;
|
|
139
|
+
}
|
|
140
|
+
function resourceLinks(job) {
|
|
141
|
+
const links = [];
|
|
142
|
+
const id = encodeURIComponent(job.jobId);
|
|
143
|
+
if (job.specPath)
|
|
144
|
+
links.push(`<a href="/jobs/${id}/spec">Spec</a>`);
|
|
145
|
+
if (job.logPath)
|
|
146
|
+
links.push(`<a href="/jobs/${id}/log">Log</a>`);
|
|
147
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
148
|
+
links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
|
|
149
|
+
if (links.length === 0)
|
|
150
|
+
return "";
|
|
151
|
+
return `<p class="links">${links.join(" ")}</p>`;
|
|
152
|
+
}
|
|
153
|
+
function actionForms(job) {
|
|
154
|
+
const forms = [];
|
|
155
|
+
const id = encodeURIComponent(job.jobId);
|
|
156
|
+
if (job.canApprove) {
|
|
157
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/approve"><button class="approve" type="submit">Approve</button></form>`);
|
|
158
|
+
}
|
|
159
|
+
if (job.canReject) {
|
|
160
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/reject"><button class="reject" type="submit">Reject</button></form>`);
|
|
161
|
+
}
|
|
162
|
+
if (forms.length === 0)
|
|
163
|
+
return "";
|
|
164
|
+
return `<div class="actions">${forms.join("")}</div>`;
|
|
165
|
+
}
|
|
166
|
+
function document(options) {
|
|
167
|
+
const refresh = options.refresh
|
|
168
|
+
? `<meta http-equiv="refresh" content="${REFRESH_SECONDS}">`
|
|
169
|
+
: "";
|
|
170
|
+
return `<!DOCTYPE html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="utf-8">
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<meta name="referrer" content="same-origin">
|
|
176
|
+
${refresh}
|
|
177
|
+
<title>${escapeHtml(options.title)}</title>
|
|
178
|
+
<style>
|
|
179
|
+
${options.css}
|
|
180
|
+
</style>
|
|
181
|
+
</head>
|
|
182
|
+
<body>
|
|
183
|
+
<header class="top">
|
|
184
|
+
<a class="kiln-mark" href="/">Kiln</a>
|
|
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>
|
|
194
|
+
</body>
|
|
195
|
+
</html>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
export function escapeHtml(value) {
|
|
199
|
+
return value
|
|
200
|
+
.replaceAll("&", "&")
|
|
201
|
+
.replaceAll("<", "<")
|
|
202
|
+
.replaceAll(">", ">")
|
|
203
|
+
.replaceAll('"', """)
|
|
204
|
+
.replaceAll("'", "'");
|
|
205
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { answerJob, readStatus, runTick } from "./driver.js";
|
|
5
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
|
+
import { packageRoot } from "./paths.js";
|
|
7
|
+
import { renderBoard, renderInbox, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
8
|
+
const BODY_LIMIT = 65_536;
|
|
9
|
+
const TEXT_LIMIT = 100_000;
|
|
10
|
+
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* Local UI over the same derived job view as `kiln status`.
|
|
13
|
+
* Listens on 127.0.0.1 only. Resolves when the process is signalled.
|
|
14
|
+
*/
|
|
15
|
+
export function startServe(options) {
|
|
16
|
+
const css = loadServeCss();
|
|
17
|
+
const secrets = secretValues(options.config);
|
|
18
|
+
let port = options.port;
|
|
19
|
+
return new Promise((resolvePromise, reject) => {
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
void handle(req, res, options.config, () => port, css, secrets).catch((error) => {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
const status = statusCode(error) ?? 500;
|
|
24
|
+
sendJson(res, status, { error: redact(message, secrets) }, secrets);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
server.on("error", reject);
|
|
28
|
+
let stopped = false;
|
|
29
|
+
let timer;
|
|
30
|
+
let firstTick;
|
|
31
|
+
const shutdown = () => {
|
|
32
|
+
if (stopped)
|
|
33
|
+
return;
|
|
34
|
+
stopped = true;
|
|
35
|
+
if (timer)
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
if (firstTick)
|
|
38
|
+
clearTimeout(firstTick);
|
|
39
|
+
server.close(() => resolvePromise());
|
|
40
|
+
};
|
|
41
|
+
process.once("SIGINT", shutdown);
|
|
42
|
+
process.once("SIGTERM", shutdown);
|
|
43
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
44
|
+
const address = server.address();
|
|
45
|
+
if (!address || typeof address === "string") {
|
|
46
|
+
reject(new Error("kiln serve did not bind a TCP port."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
port = address.port;
|
|
50
|
+
process.stdout.write(`listening http://${address.address}:${address.port}\n`);
|
|
51
|
+
if (options.tickSeconds === undefined)
|
|
52
|
+
return;
|
|
53
|
+
const tick = () => {
|
|
54
|
+
if (stopped)
|
|
55
|
+
return;
|
|
56
|
+
safeTick(options.config, secrets);
|
|
57
|
+
};
|
|
58
|
+
firstTick = setTimeout(tick, 0);
|
|
59
|
+
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function loadServeCss() {
|
|
64
|
+
const path = join(packageRoot(), "assets", "serve.css");
|
|
65
|
+
if (!existsSync(path)) {
|
|
66
|
+
throw new Error(`Kiln serve stylesheet is missing at ${path}.`);
|
|
67
|
+
}
|
|
68
|
+
return readFileSync(path, "utf8");
|
|
69
|
+
}
|
|
70
|
+
function safeTick(config, secrets) {
|
|
71
|
+
try {
|
|
72
|
+
runTick(config);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
process.stderr.write(`tick: ${redact(message, secrets)}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function handle(req, res, config, boundPort, css, secrets) {
|
|
80
|
+
const method = req.method ?? "GET";
|
|
81
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
82
|
+
const path = url.pathname;
|
|
83
|
+
if (method === "GET" && path === "/favicon.ico") {
|
|
84
|
+
res.writeHead(204, { Connection: "close", "Cache-Control": "no-store" });
|
|
85
|
+
res.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (method === "POST") {
|
|
89
|
+
await drain(req);
|
|
90
|
+
if (!originAllowed(req, boundPort())) {
|
|
91
|
+
sendJson(res, 403, { error: "Cross-origin request rejected." }, secrets);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject)$/.exec(path);
|
|
95
|
+
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject")) {
|
|
96
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const jobId = parseJobId(action[1]);
|
|
100
|
+
if (!jobId) {
|
|
101
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await answer(req, res, config, css, secrets, jobId, action[2]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
108
|
+
sendJson(res, 405, { error: "Method not allowed." }, secrets);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (path === "/" || path === "") {
|
|
112
|
+
sendHtml(res, 200, renderInbox(css, readStatus(config)), secrets);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (path === "/board") {
|
|
116
|
+
sendHtml(res, 200, renderBoard(css, readStatus(config)), secrets);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (path === "/api/jobs") {
|
|
120
|
+
sendJson(res, 200, readStatus(config), secrets);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
124
|
+
if (apiJob?.[1]) {
|
|
125
|
+
const jobId = parseJobId(apiJob[1]);
|
|
126
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
127
|
+
if (!job) {
|
|
128
|
+
sendJson(res, 404, { error: jobId ? `No job ${jobId}.` : "Not found." }, secrets);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
sendJson(res, 200, job, secrets);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
|
|
135
|
+
if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
|
|
136
|
+
const jobId = parseJobId(page[1]);
|
|
137
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
138
|
+
if (!job) {
|
|
139
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (page[2] === "findings") {
|
|
143
|
+
sendFindings(res, config, css, secrets, job);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const target = page[2] === "spec" ? job.specPath : job.logPath;
|
|
147
|
+
if (!target) {
|
|
148
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", page[2] === "log" ? "No log yet." : "No spec."), secrets);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const text = readInside(config.kilnDir, target);
|
|
152
|
+
if (text === undefined) {
|
|
153
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const title = page[2] === "spec" ? `${job.title} spec` : `${job.title} log`;
|
|
157
|
+
sendHtml(res, 200, renderTextPage(css, title, text), secrets);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (path.startsWith("/api/"))
|
|
161
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
162
|
+
else
|
|
163
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
164
|
+
}
|
|
165
|
+
async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
166
|
+
try {
|
|
167
|
+
answerJob(config, jobId, choice);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
const status = message.startsWith("No job ") ? 404 : 409;
|
|
172
|
+
if (wantsJson(req))
|
|
173
|
+
sendJson(res, status, { error: message }, secrets);
|
|
174
|
+
else
|
|
175
|
+
sendHtml(res, status, renderMessagePage(css, "Could not answer", message), secrets);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const job = findJob(config, jobId);
|
|
179
|
+
if (wantsJson(req)) {
|
|
180
|
+
sendJson(res, 200, { ok: true, job }, secrets);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
redirect(res, redirectTarget(req));
|
|
184
|
+
}
|
|
185
|
+
function sendFindings(res, config, css, secrets, job) {
|
|
186
|
+
if (job.workflow !== INVESTIGATION_WORKFLOW) {
|
|
187
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!job.findingsPath) {
|
|
191
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "No findings yet."), secrets);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const text = readInside(config.kilnDir, job.findingsPath);
|
|
195
|
+
if (text === undefined) {
|
|
196
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "That file is not in the kiln directory."), secrets);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
sendHtml(res, 200, renderTextPage(css, `${job.title} findings`, text), secrets);
|
|
200
|
+
}
|
|
201
|
+
function findJob(config, jobId) {
|
|
202
|
+
try {
|
|
203
|
+
return readStatus(config, jobId).jobs[0];
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
if (message === `No job ${jobId}.`)
|
|
208
|
+
return undefined;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Browsers send Origin on POST. A cross-site page cannot use this server:
|
|
214
|
+
* the header has to be exactly `http://127.0.0.1:<bound port>`.
|
|
215
|
+
* Curl and other non-browser clients omit Origin and are allowed.
|
|
216
|
+
* `Sec-Fetch-Site: cross-site` is rejected even when Origin is absent.
|
|
217
|
+
*/
|
|
218
|
+
export function originAllowed(req, port) {
|
|
219
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
220
|
+
if (fetchSite === "cross-site")
|
|
221
|
+
return false;
|
|
222
|
+
const origin = req.headers.origin;
|
|
223
|
+
if (Array.isArray(origin))
|
|
224
|
+
return false;
|
|
225
|
+
if (origin === undefined)
|
|
226
|
+
return true;
|
|
227
|
+
return origin === `http://127.0.0.1:${port}`;
|
|
228
|
+
}
|
|
229
|
+
function redirectTarget(req) {
|
|
230
|
+
const referer = req.headers.referer;
|
|
231
|
+
if (typeof referer !== "string")
|
|
232
|
+
return "/";
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(referer);
|
|
235
|
+
if (url.pathname === "/board")
|
|
236
|
+
return "/board";
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return "/";
|
|
240
|
+
}
|
|
241
|
+
return "/";
|
|
242
|
+
}
|
|
243
|
+
function wantsJson(req) {
|
|
244
|
+
const accept = headerValue(req.headers.accept);
|
|
245
|
+
if (accept.includes("application/json"))
|
|
246
|
+
return true;
|
|
247
|
+
return headerValue(req.headers["content-type"]).includes("application/json");
|
|
248
|
+
}
|
|
249
|
+
function headerValue(value) {
|
|
250
|
+
if (Array.isArray(value))
|
|
251
|
+
return value.join(",");
|
|
252
|
+
return value ?? "";
|
|
253
|
+
}
|
|
254
|
+
function parseJobId(raw) {
|
|
255
|
+
let id;
|
|
256
|
+
try {
|
|
257
|
+
id = decodeURIComponent(raw);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (!JOB_ID.test(id))
|
|
263
|
+
return undefined;
|
|
264
|
+
return id;
|
|
265
|
+
}
|
|
266
|
+
function readInside(kilnDir, target) {
|
|
267
|
+
let root;
|
|
268
|
+
try {
|
|
269
|
+
root = realpathSync(resolve(kilnDir));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const resolved = resolve(target);
|
|
275
|
+
if (resolved !== root && !resolved.startsWith(root + sep))
|
|
276
|
+
return undefined;
|
|
277
|
+
let real;
|
|
278
|
+
try {
|
|
279
|
+
real = realpathSync(resolved);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
if (real !== root && !real.startsWith(root + sep))
|
|
285
|
+
return undefined;
|
|
286
|
+
const text = readFileSync(real, "utf8");
|
|
287
|
+
if (text.length <= TEXT_LIMIT)
|
|
288
|
+
return text;
|
|
289
|
+
return `showing the last ${TEXT_LIMIT} characters\n\n${text.slice(text.length - TEXT_LIMIT)}`;
|
|
290
|
+
}
|
|
291
|
+
async function drain(req) {
|
|
292
|
+
let size = 0;
|
|
293
|
+
for await (const chunk of req) {
|
|
294
|
+
size += chunk.length;
|
|
295
|
+
if (size > BODY_LIMIT) {
|
|
296
|
+
const error = new Error("Request body is too large.");
|
|
297
|
+
error.statusCode = 413;
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function statusCode(error) {
|
|
303
|
+
if (typeof error === "object" && error !== null && "statusCode" in error) {
|
|
304
|
+
const code = error.statusCode;
|
|
305
|
+
if (typeof code === "number")
|
|
306
|
+
return code;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
function sendHtml(res, status, html, secrets) {
|
|
311
|
+
const body = redact(html, secrets);
|
|
312
|
+
res.writeHead(status, {
|
|
313
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
314
|
+
Connection: "close",
|
|
315
|
+
"Cache-Control": "no-store",
|
|
316
|
+
"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'",
|
|
318
|
+
"Referrer-Policy": "same-origin",
|
|
319
|
+
});
|
|
320
|
+
res.end(body);
|
|
321
|
+
}
|
|
322
|
+
function sendJson(res, status, value, secrets) {
|
|
323
|
+
const body = redact(`${JSON.stringify(value, null, 2)}\n`, secrets);
|
|
324
|
+
res.writeHead(status, {
|
|
325
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
326
|
+
Connection: "close",
|
|
327
|
+
"Cache-Control": "no-store",
|
|
328
|
+
"X-Content-Type-Options": "nosniff",
|
|
329
|
+
});
|
|
330
|
+
res.end(body);
|
|
331
|
+
}
|
|
332
|
+
function redirect(res, location) {
|
|
333
|
+
res.writeHead(303, { Location: location, Connection: "close", "Cache-Control": "no-store" });
|
|
334
|
+
res.end();
|
|
335
|
+
}
|
|
336
|
+
function secretValues(config) {
|
|
337
|
+
const values = [config.jira?.apiToken, process.env.JIRA_API_TOKEN];
|
|
338
|
+
const found = [];
|
|
339
|
+
for (const value of values) {
|
|
340
|
+
const trimmed = value?.trim();
|
|
341
|
+
if (trimmed && trimmed.length >= 8 && !found.includes(trimmed))
|
|
342
|
+
found.push(trimmed);
|
|
343
|
+
}
|
|
344
|
+
return found;
|
|
345
|
+
}
|
|
346
|
+
function redact(text, secrets) {
|
|
347
|
+
let out = text;
|
|
348
|
+
for (const secret of secrets)
|
|
349
|
+
out = out.split(secret).join("[redacted]");
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { writeSync } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
/** Last lines of verify output kept for later prompts. */
|
|
5
|
+
export const VERIFY_MAX_LINES = 200;
|
|
6
|
+
/** Last bytes of verify output kept for later prompts, after the line cut. */
|
|
7
|
+
export const VERIFY_MAX_BYTES = 20 * 1024;
|
|
8
|
+
const RAW_CAP = 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Keep the tail of a verify stream so a long test log cannot land whole in
|
|
11
|
+
* the next prompt. A cut is marked on the first line.
|
|
12
|
+
*/
|
|
13
|
+
export function truncateCaptured(text, stream) {
|
|
14
|
+
const lined = lastLines(text, VERIFY_MAX_LINES);
|
|
15
|
+
const tailed = lastBytes(lined.text, VERIFY_MAX_BYTES);
|
|
16
|
+
if (!lined.cut && !tailed.cut)
|
|
17
|
+
return text;
|
|
18
|
+
const note = `[truncated: showing the last ${VERIFY_MAX_LINES} lines or ${VERIFY_MAX_BYTES} bytes of ${stream}]\n`;
|
|
19
|
+
return note + tailed.text;
|
|
20
|
+
}
|
|
21
|
+
function lastLines(text, maxLines) {
|
|
22
|
+
if (text.length === 0)
|
|
23
|
+
return { text, cut: false };
|
|
24
|
+
const trailingNewline = text.endsWith("\n");
|
|
25
|
+
const parts = text.split("\n");
|
|
26
|
+
if (trailingNewline)
|
|
27
|
+
parts.pop();
|
|
28
|
+
if (parts.length <= maxLines)
|
|
29
|
+
return { text, cut: false };
|
|
30
|
+
const kept = parts.slice(-maxLines);
|
|
31
|
+
return { text: kept.join("\n") + (trailingNewline ? "\n" : ""), cut: true };
|
|
32
|
+
}
|
|
33
|
+
function lastBytes(text, maxBytes) {
|
|
34
|
+
const buf = Buffer.from(text, "utf8");
|
|
35
|
+
if (buf.length <= maxBytes)
|
|
36
|
+
return { text, cut: false };
|
|
37
|
+
let start = buf.length - maxBytes;
|
|
38
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80)
|
|
39
|
+
start += 1;
|
|
40
|
+
return { text: buf.subarray(start).toString("utf8"), cut: true };
|
|
41
|
+
}
|
|
42
|
+
function capTail(current, chunk, cap) {
|
|
43
|
+
let piece = chunk;
|
|
44
|
+
let cut = false;
|
|
45
|
+
if (Buffer.byteLength(piece, "utf8") > cap) {
|
|
46
|
+
piece = lastBytes(piece, cap).text;
|
|
47
|
+
cut = true;
|
|
48
|
+
current = "";
|
|
49
|
+
}
|
|
50
|
+
const next = current + piece;
|
|
51
|
+
if (Buffer.byteLength(next, "utf8") <= cap)
|
|
52
|
+
return { text: next, cut };
|
|
53
|
+
return { text: lastBytes(next, cap).text, cut: true };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `process.stdout.write` queues behind an in-flight write. `process.exit`
|
|
57
|
+
* leaves that queue in the process and the parent's `close` handler never
|
|
58
|
+
* sees it. A blocking write puts the bytes in the pipe first.
|
|
59
|
+
*/
|
|
60
|
+
function writeAll(fd, text) {
|
|
61
|
+
if (text.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
const buf = Buffer.from(text);
|
|
64
|
+
let offset = 0;
|
|
65
|
+
while (offset < buf.length) {
|
|
66
|
+
let wrote = 0;
|
|
67
|
+
try {
|
|
68
|
+
wrote = writeSync(fd, buf, offset);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
72
|
+
if (code === "EAGAIN" || code === "EWOULDBLOCK")
|
|
73
|
+
continue;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (wrote <= 0)
|
|
77
|
+
return;
|
|
78
|
+
offset += wrote;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isDirectRun() {
|
|
82
|
+
const entry = process.argv[1];
|
|
83
|
+
if (!entry)
|
|
84
|
+
return false;
|
|
85
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
86
|
+
}
|
|
87
|
+
function main() {
|
|
88
|
+
const command = process.env.KILN_TEST;
|
|
89
|
+
if (!command?.trim()) {
|
|
90
|
+
writeAll(2, "KILN_TEST is empty\n");
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const child = spawn(command, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
94
|
+
let stdout = "";
|
|
95
|
+
let stderr = "";
|
|
96
|
+
let settled = false;
|
|
97
|
+
const finish = (code) => {
|
|
98
|
+
if (settled)
|
|
99
|
+
return;
|
|
100
|
+
settled = true;
|
|
101
|
+
writeAll(1, truncateCaptured(stdout, "stdout"));
|
|
102
|
+
writeAll(2, truncateCaptured(stderr, "stderr"));
|
|
103
|
+
process.exit(code);
|
|
104
|
+
};
|
|
105
|
+
child.stdout?.setEncoding("utf8");
|
|
106
|
+
child.stderr?.setEncoding("utf8");
|
|
107
|
+
child.stdout?.on("data", (chunk) => {
|
|
108
|
+
stdout = capTail(stdout, chunk, RAW_CAP).text;
|
|
109
|
+
});
|
|
110
|
+
child.stderr?.on("data", (chunk) => {
|
|
111
|
+
stderr = capTail(stderr, chunk, RAW_CAP).text;
|
|
112
|
+
});
|
|
113
|
+
child.on("error", (error) => {
|
|
114
|
+
stderr += `${error.message}\n`;
|
|
115
|
+
finish(1);
|
|
116
|
+
});
|
|
117
|
+
child.on("close", (code) => {
|
|
118
|
+
finish(code ?? 1);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (isDirectRun())
|
|
122
|
+
main();
|