@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.
@@ -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
- const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
165
- if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry")) {
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.9.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.8.0",
37
+ "@lchase/yak": "0.9.0",
38
38
  "zod": "^4.6.7"
39
39
  },
40
40
  "devDependencies": {
@@ -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"'