@lchase/kiln 0.8.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 +6 -4
- package/assets/serve.css +13 -0
- package/assets/serve.js +49 -3
- package/dist/attention.js +13 -4
- package/dist/cli.js +112 -8
- package/dist/config.js +25 -1
- package/dist/delivery.js +18 -0
- package/dist/driver.js +213 -14
- package/dist/events.js +393 -0
- package/dist/evidence.js +140 -0
- package/dist/failure.js +140 -0
- package/dist/fix-commit.js +73 -0
- package/dist/jobs.js +12 -0
- package/dist/ledger.js +152 -0
- package/dist/project.js +394 -0
- package/dist/rails.js +164 -0
- package/dist/reconcile.js +29 -3
- package/dist/redact.js +63 -0
- package/dist/reliability.js +75 -26
- package/dist/report.js +19 -5
- package/dist/schemas.js +35 -5
- package/dist/serve-page.js +21 -1
- package/dist/serve.js +86 -4
- 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,14 +5,16 @@ 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
|
-
import {
|
|
10
|
+
import { appendEvent, eventsForJob } from "./events.js";
|
|
11
11
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
12
|
+
import { defaultHost } from "./host.js";
|
|
12
13
|
import { intakeJobs } from "./intake-http.js";
|
|
13
14
|
import { packageRoot } from "./paths.js";
|
|
14
15
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
15
16
|
import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
17
|
+
import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
|
|
16
18
|
import { readSettings, saveSettings } from "./settings.js";
|
|
17
19
|
import { listWorkflowNames } from "./workflow.js";
|
|
18
20
|
const BODY_LIMIT = 65_536;
|
|
@@ -160,8 +162,18 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
160
162
|
await createJob(req, res, state, raw);
|
|
161
163
|
return;
|
|
162
164
|
}
|
|
163
|
-
|
|
164
|
-
|
|
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")) {
|
|
165
177
|
sendJson(res, 404, { error: "Not found." }, secrets);
|
|
166
178
|
return;
|
|
167
179
|
}
|
|
@@ -174,6 +186,14 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
174
186
|
await retry(req, res, config, css, secrets, jobId);
|
|
175
187
|
return;
|
|
176
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
|
+
}
|
|
177
197
|
await answer(req, res, config, css, secrets, jobId, action[2]);
|
|
178
198
|
return;
|
|
179
199
|
}
|
|
@@ -189,6 +209,15 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
189
209
|
sendJson(res, 200, statusDocument(state), secrets);
|
|
190
210
|
return;
|
|
191
211
|
}
|
|
212
|
+
if (path === "/api/events") {
|
|
213
|
+
const jobId = url.searchParams.get("job_id");
|
|
214
|
+
if (!jobId || !JOB_ID.test(jobId)) {
|
|
215
|
+
sendJson(res, 400, { error: "job_id is required." }, secrets);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
sendJson(res, 200, { job_id: jobId, events: eventsForJob(config.kilnDir, jobId) }, secrets);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
192
221
|
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
193
222
|
if (apiJob?.[1]) {
|
|
194
223
|
const jobId = parseJobId(apiJob[1]);
|
|
@@ -251,6 +280,48 @@ async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
|
251
280
|
}
|
|
252
281
|
redirect(res, redirectTarget(req));
|
|
253
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
|
+
}
|
|
254
325
|
async function retry(req, res, config, css, secrets, jobId) {
|
|
255
326
|
try {
|
|
256
327
|
retryJob(config, jobId);
|
|
@@ -503,6 +574,11 @@ function refreshLiveConfig(state) {
|
|
|
503
574
|
state.configError = undefined;
|
|
504
575
|
state.mtimeMs = file.mtimeMs;
|
|
505
576
|
state.size = file.size;
|
|
577
|
+
appendEvent(next.kilnDir, {
|
|
578
|
+
job_id: null,
|
|
579
|
+
event: "config.reloaded",
|
|
580
|
+
properties: {},
|
|
581
|
+
});
|
|
506
582
|
}
|
|
507
583
|
catch (error) {
|
|
508
584
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -546,18 +622,24 @@ function repoList(config) {
|
|
|
546
622
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
547
623
|
}
|
|
548
624
|
function pageOptions(state, csrf) {
|
|
625
|
+
const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
|
|
549
626
|
return {
|
|
550
627
|
csrf,
|
|
551
628
|
repos: repoList(state.config),
|
|
552
629
|
workflows: listWorkflowNames(),
|
|
630
|
+
paused: isPaused(state.config.kilnDir),
|
|
631
|
+
spend,
|
|
553
632
|
...(state.configError ? { configError: state.configError } : {}),
|
|
554
633
|
};
|
|
555
634
|
}
|
|
556
635
|
function statusDocument(state) {
|
|
636
|
+
const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
|
|
557
637
|
return {
|
|
558
638
|
...readStatus(state.config),
|
|
559
639
|
repos: repoList(state.config),
|
|
560
640
|
configError: state.configError ?? null,
|
|
641
|
+
paused: isPaused(state.config.kilnDir),
|
|
642
|
+
spend: { day: spend.day, usd: spend.usd, cap: spend.cap },
|
|
561
643
|
};
|
|
562
644
|
}
|
|
563
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"'
|