@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.
@@ -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 { defaultHost } from "./host.js";
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
- const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
164
- 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")) {
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.8.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"'