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