@lchase/kiln 0.9.0 → 0.10.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 +5 -5
- package/assets/serve.css +13 -0
- package/assets/serve.js +49 -3
- package/dist/attention.js +13 -4
- package/dist/cli.js +63 -6
- package/dist/config.js +25 -1
- package/dist/driver.js +117 -16
- package/dist/events.js +40 -1
- package/dist/failure.js +140 -0
- package/dist/fix-commit.js +73 -0
- package/dist/ledger.js +11 -3
- package/dist/project.js +20 -4
- package/dist/rails.js +164 -0
- package/dist/reconcile.js +29 -3
- package/dist/reliability.js +53 -25
- package/dist/report.js +19 -5
- package/dist/schemas.js +35 -5
- package/dist/serve-page.js +21 -1
- package/dist/serve.js +70 -3
- package/dist/settings.js +9 -1
- package/package.json +2 -2
- package/workflows/fix.yaml +1 -1
package/dist/serve-page.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { formatSpend } from "./rails.js";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { scheduledRetry } from "./attention.js";
|
|
4
5
|
import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
@@ -44,8 +45,12 @@ export function renderConsole(css, attention, page) {
|
|
|
44
45
|
...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
|
|
45
46
|
].join("");
|
|
46
47
|
const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
|
|
48
|
+
const paused = intake.paused === true;
|
|
49
|
+
const spend = intake.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(intake.spend))}</span>` : "";
|
|
47
50
|
const body = `<header>
|
|
48
51
|
<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>
|
|
52
|
+
${spend}
|
|
53
|
+
<form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(intake.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${paused ? "Resume" : "Pause"}</button></form>
|
|
49
54
|
<div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
|
|
50
55
|
</header>
|
|
51
56
|
<p class="meta config-error" id="config-error" role="status"${intake.configError ? "" : " hidden"}>${escapeHtml(intake.configError ?? "")}</p>
|
|
@@ -213,6 +218,12 @@ function settingsSheet() {
|
|
|
213
218
|
<label class="field"><span class="k">Branch template</span><input id="repo-branch" type="text" autocomplete="off" spellcheck="false"></label>
|
|
214
219
|
<p class="hint" id="hint-branch">Default {issue}-{slug}, which renders as ABC-123-slug.</p>
|
|
215
220
|
<p class="field-error" data-error="branch" hidden></p>
|
|
221
|
+
<label class="field"><span class="k">Max attempts</span><input id="repo-max-attempts" type="number" min="1" step="1" placeholder="inherit"></label>
|
|
222
|
+
<p class="hint">Overrides the global attempt cap for this repo. Empty inherits.</p>
|
|
223
|
+
<p class="field-error" data-error="repoAttempts" hidden></p>
|
|
224
|
+
<label class="field"><span class="k">Max fix rounds</span><input id="repo-max-fix-rounds" type="number" min="1" step="1" placeholder="inherit"></label>
|
|
225
|
+
<p class="hint">Overrides the global fix-round cap for this repo. Empty inherits.</p>
|
|
226
|
+
<p class="field-error" data-error="repoRounds" hidden></p>
|
|
216
227
|
<div class="d-actions"><button type="button" id="repo-done">Done</button></div>
|
|
217
228
|
</div>
|
|
218
229
|
<div id="repo-list"></div>
|
|
@@ -237,6 +248,12 @@ function settingsSheet() {
|
|
|
237
248
|
<label class="field"><span class="k">Max fix rounds</span><input id="max-fix-rounds" type="number" min="1" step="1"></label>
|
|
238
249
|
<p class="hint" id="hint-fix"></p>
|
|
239
250
|
<p class="field-error" data-error="maxFixRounds" hidden></p>
|
|
251
|
+
<label class="field"><span class="k">Same fingerprint limit</span><input id="max-same-fingerprint" type="number" min="1" step="1"></label>
|
|
252
|
+
<p class="hint" id="hint-fingerprint"></p>
|
|
253
|
+
<p class="field-error" data-error="maxSameFingerprint" hidden></p>
|
|
254
|
+
<label class="field"><span class="k">Daily dollar cap</span><input id="daily-dollar-cap" type="number" min="0" step="0.01"></label>
|
|
255
|
+
<p class="hint" id="hint-cap"></p>
|
|
256
|
+
<p class="field-error" data-error="dailyDollarCap" hidden></p>
|
|
240
257
|
</div>
|
|
241
258
|
<div class="d-actions"><button type="submit">Save</button></div>
|
|
242
259
|
</form>
|
|
@@ -326,6 +343,9 @@ function actionsHtml(item, csrf) {
|
|
|
326
343
|
if (item.job.canReject) {
|
|
327
344
|
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
|
|
328
345
|
}
|
|
346
|
+
if (item.job.canRevise) {
|
|
347
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/revise" class="act revise">${token}<input name="note" type="text" required maxlength="8000" placeholder="Note for the next round" autocomplete="off"><button type="submit">Revise</button></form>`);
|
|
348
|
+
}
|
|
329
349
|
if (item.retry) {
|
|
330
350
|
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
|
|
331
351
|
}
|
|
@@ -375,7 +395,7 @@ function attemptHtml(job, stage) {
|
|
|
375
395
|
detail.push(job.lastFailureReason);
|
|
376
396
|
return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
|
|
377
397
|
}
|
|
378
|
-
if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead"))
|
|
398
|
+
if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead" && job.state !== "needs-human"))
|
|
379
399
|
return "";
|
|
380
400
|
if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
|
|
381
401
|
return "";
|
package/dist/serve.js
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve, sep } from "node:path";
|
|
6
6
|
import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
|
|
7
7
|
import { loadKilnConfig } from "./config.js";
|
|
8
|
-
import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./driver.js";
|
|
8
|
+
import { answerJob, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
|
|
9
9
|
import { browse } from "./fs-browse.js";
|
|
10
10
|
import { appendEvent, eventsForJob } from "./events.js";
|
|
11
11
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
@@ -14,6 +14,7 @@ import { intakeJobs } from "./intake-http.js";
|
|
|
14
14
|
import { packageRoot } from "./paths.js";
|
|
15
15
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
16
16
|
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
17
|
+
import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
|
|
17
18
|
import { readSettings, saveSettings } from "./settings.js";
|
|
18
19
|
import { listWorkflowNames } from "./workflow.js";
|
|
19
20
|
const BODY_LIMIT = 65_536;
|
|
@@ -161,8 +162,18 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
161
162
|
await createJob(req, res, state, raw);
|
|
162
163
|
return;
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
if (path === "/api/pause" || path === "/api/resume") {
|
|
166
|
+
const pausing = path === "/api/pause";
|
|
167
|
+
const changed = pausing ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
|
|
168
|
+
if (wantsJson(req)) {
|
|
169
|
+
sendJson(res, 200, { ok: true, paused: pausing, changed }, secrets);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
redirect(res, redirectTarget(req));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry|revise)$/.exec(path);
|
|
176
|
+
if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry" && action[2] !== "revise")) {
|
|
166
177
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
167
178
|
return;
|
|
168
179
|
}
|
|
@@ -175,6 +186,14 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
175
186
|
await retry(req, res, config, css, secrets, jobId);
|
|
176
187
|
return;
|
|
177
188
|
}
|
|
189
|
+
if (action[2] === "revise") {
|
|
190
|
+
await revise(req, res, config, css, secrets, jobId, raw);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (action[2] !== "approve" && action[2] !== "reject") {
|
|
194
|
+
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
178
197
|
await answer(req, res, config, css, secrets, jobId, action[2]);
|
|
179
198
|
return;
|
|
180
199
|
}
|
|
@@ -261,6 +280,48 @@ async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
|
261
280
|
}
|
|
262
281
|
redirect(res, redirectTarget(req));
|
|
263
282
|
}
|
|
283
|
+
async function revise(req, res, config, css, secrets, jobId, raw) {
|
|
284
|
+
const note = noteFromBody(req, raw);
|
|
285
|
+
if (!note) {
|
|
286
|
+
const message = "A revise note is required.";
|
|
287
|
+
if (wantsJson(req))
|
|
288
|
+
sendJson(res, 400, { error: message }, secrets);
|
|
289
|
+
else
|
|
290
|
+
sendHtml(res, 400, renderMessagePage(css, "Could not revise", message), secrets);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
reviseJob(config, jobId, note);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
const status = message.startsWith("No job ") ? 404 : 409;
|
|
299
|
+
if (wantsJson(req))
|
|
300
|
+
sendJson(res, status, { error: message }, secrets);
|
|
301
|
+
else
|
|
302
|
+
sendHtml(res, status, renderMessagePage(css, "Could not revise", message), secrets);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const job = findJob(config, jobId);
|
|
306
|
+
if (wantsJson(req)) {
|
|
307
|
+
sendJson(res, 200, { ok: true, job }, secrets);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
redirect(res, redirectTarget(req));
|
|
311
|
+
}
|
|
312
|
+
function noteFromBody(req, raw) {
|
|
313
|
+
const type = headerValue(req.headers["content-type"]);
|
|
314
|
+
if (type.includes("application/json")) {
|
|
315
|
+
try {
|
|
316
|
+
const parsed = JSON.parse(raw);
|
|
317
|
+
return typeof parsed.note === "string" ? parsed.note : undefined;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return new URLSearchParams(raw).get("note") ?? undefined;
|
|
324
|
+
}
|
|
264
325
|
async function retry(req, res, config, css, secrets, jobId) {
|
|
265
326
|
try {
|
|
266
327
|
retryJob(config, jobId);
|
|
@@ -561,18 +622,24 @@ function repoList(config) {
|
|
|
561
622
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
562
623
|
}
|
|
563
624
|
function pageOptions(state, csrf) {
|
|
625
|
+
const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
|
|
564
626
|
return {
|
|
565
627
|
csrf,
|
|
566
628
|
repos: repoList(state.config),
|
|
567
629
|
workflows: listWorkflowNames(),
|
|
630
|
+
paused: isPaused(state.config.kilnDir),
|
|
631
|
+
spend,
|
|
568
632
|
...(state.configError ? { configError: state.configError } : {}),
|
|
569
633
|
};
|
|
570
634
|
}
|
|
571
635
|
function statusDocument(state) {
|
|
636
|
+
const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
|
|
572
637
|
return {
|
|
573
638
|
...readStatus(state.config),
|
|
574
639
|
repos: repoList(state.config),
|
|
575
640
|
configError: state.configError ?? null,
|
|
641
|
+
paused: isPaused(state.config.kilnDir),
|
|
642
|
+
spend: { day: spend.day, usd: spend.usd, cap: spend.cap },
|
|
576
643
|
};
|
|
577
644
|
}
|
|
578
645
|
function statusCode(error) {
|
package/dist/settings.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { assertBranchName, assertCheckoutDirectory } from "./config.js";
|
|
6
6
|
import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
|
|
7
|
-
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, } from "./schemas.js";
|
|
7
|
+
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, } from "./schemas.js";
|
|
8
8
|
const DEFAULTS = {
|
|
9
9
|
deliver: "local",
|
|
10
10
|
prTitle: DEFAULT_PR_TITLE,
|
|
@@ -12,6 +12,8 @@ const DEFAULTS = {
|
|
|
12
12
|
maxAttempts: DEFAULT_MAX_ATTEMPTS,
|
|
13
13
|
stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
|
|
14
14
|
maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
|
|
15
|
+
maxSameFingerprint: DEFAULT_MAX_SAME_FINGERPRINT,
|
|
16
|
+
dailyDollarCap: null,
|
|
15
17
|
sandboxMode: "off",
|
|
16
18
|
};
|
|
17
19
|
export function readSettings(configPath) {
|
|
@@ -69,6 +71,8 @@ function project(path, revision, file) {
|
|
|
69
71
|
...(file.stallTimeoutMs !== undefined ? { stallTimeoutMs: file.stallTimeoutMs } : {}),
|
|
70
72
|
...(file.maxAttempts !== undefined ? { maxAttempts: file.maxAttempts } : {}),
|
|
71
73
|
...(file.maxFixRounds !== undefined ? { maxFixRounds: file.maxFixRounds } : {}),
|
|
74
|
+
...(file.maxSameFingerprint !== undefined ? { maxSameFingerprint: file.maxSameFingerprint } : {}),
|
|
75
|
+
...(file.dailyDollarCap !== undefined ? { dailyDollarCap: file.dailyDollarCap } : {}),
|
|
72
76
|
...(sandbox ? { sandbox } : {}),
|
|
73
77
|
defaults: DEFAULTS,
|
|
74
78
|
};
|
|
@@ -97,12 +101,16 @@ function mergeSettings(raw, settings) {
|
|
|
97
101
|
setOptional(prior, "base", entry.base);
|
|
98
102
|
setOptional(prior, "prTitle", entry.prTitle);
|
|
99
103
|
setOptional(prior, "branch", entry.branch);
|
|
104
|
+
setOptional(prior, "maxAttempts", entry.maxAttempts);
|
|
105
|
+
setOptional(prior, "maxFixRounds", entry.maxFixRounds);
|
|
100
106
|
repos[name] = prior;
|
|
101
107
|
}
|
|
102
108
|
next.repos = repos;
|
|
103
109
|
setOptional(next, "stallTimeoutMs", settings.stallTimeoutMs);
|
|
104
110
|
setOptional(next, "maxAttempts", settings.maxAttempts);
|
|
105
111
|
setOptional(next, "maxFixRounds", settings.maxFixRounds);
|
|
112
|
+
setOptional(next, "maxSameFingerprint", settings.maxSameFingerprint);
|
|
113
|
+
setOptional(next, "dailyDollarCap", settings.dailyDollarCap);
|
|
106
114
|
if (settings.sandbox) {
|
|
107
115
|
const prior = isRecord(raw.sandbox) ? { ...raw.sandbox } : {};
|
|
108
116
|
setOptional(prior, "mode", settings.sandbox.mode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lchase/kiln",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Run a workflow on any registered repo. A markdown spec is the input.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=22"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@lchase/yak": "0.
|
|
37
|
+
"@lchase/yak": "0.9.0",
|
|
38
38
|
"zod": "^4.6.7"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/workflows/fix.yaml
CHANGED
|
@@ -45,4 +45,4 @@ steps:
|
|
|
45
45
|
needs: [input, build]
|
|
46
46
|
skipIf: 'build.status != "READY"'
|
|
47
47
|
command:
|
|
48
|
-
run: 'git add -A && git diff --cached --quiet || git commit -m "kiln: $KILN_TASK_TITLE"'
|
|
48
|
+
run: 'git add -A && git diff --cached --quiet || git commit -m "kiln: $KILN_TASK_TITLE" --trailer "Kiln-Job: $KILN_JOB" --trailer "Kiln-Round: $KILN_ROUND" --trailer "Kiln-Prompts-Hash: $KILN_PROMPTS_HASH"'
|