@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.
@@ -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 { 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
- });
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
- 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>`);
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 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
+ 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: "Kiln board",
37
- refresh: true,
38
- current: "board",
39
- main: titled,
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
- 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>`,
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
- 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>`,
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 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)}">
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
- <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 "";
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 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>`;
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 resourceLinks(job) {
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">Spec</a>`);
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">Log</a>`);
147
- if (job.workflow === INVESTIGATION_WORKFLOW)
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 `<p class="links">${links.join(" ")}</p>`;
182
+ return `<div class="group">Open</div>${links.join("")}`;
152
183
  }
153
- function actionForms(job) {
184
+ function actionsHtml(item) {
185
+ const id = encodeURIComponent(item.job.jobId);
154
186
  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>`);
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 (job.canReject) {
160
- forms.push(`<form method="post" action="/api/jobs/${id}/reject"><button class="reject" type="submit">Reject</button></form>`);
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 document(options) {
167
- const refresh = options.refresh
168
- ? `<meta http-equiv="refresh" content="${REFRESH_SECONDS}">`
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
- <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>
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('"', "&quot;")
204
351
  .replaceAll("'", "&#39;");
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 { renderBoard, renderInbox, renderMessagePage, renderTextPage } from "./serve-page.js";
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, renderInbox(css, readStatus(config)), secrets);
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);