@lchase/kiln 0.9.0 → 0.11.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/dist/serve.js CHANGED
@@ -5,15 +5,17 @@ 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 { appendEvent, eventsForJob } from "./events.js";
10
+ import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
11
+ import { appendEvent, eventsForJob, readEvents } from "./events.js";
11
12
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
12
13
  import { defaultHost } from "./host.js";
13
14
  import { intakeJobs } from "./intake-http.js";
14
- import { packageRoot } from "./paths.js";
15
+ import { jobBasename, packageRoot } from "./paths.js";
15
16
  import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
16
- import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
17
+ import { renderApp, renderMessagePage, renderTextPage } from "./serve-page.js";
18
+ import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
17
19
  import { readSettings, saveSettings } from "./settings.js";
18
20
  import { listWorkflowNames } from "./workflow.js";
19
21
  const BODY_LIMIT = 65_536;
@@ -161,8 +163,18 @@ async function handle(req, res, state, boundPort, css, csrf) {
161
163
  await createJob(req, res, state, raw);
162
164
  return;
163
165
  }
164
- const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry)$/.exec(path);
165
- if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry")) {
166
+ if (path === "/api/pause" || path === "/api/resume") {
167
+ const pausing = path === "/api/pause";
168
+ const changed = pausing ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
169
+ if (wantsJson(req)) {
170
+ sendJson(res, 200, { ok: true, paused: pausing, changed }, secrets);
171
+ return;
172
+ }
173
+ redirect(res, redirectTarget(req));
174
+ return;
175
+ }
176
+ const action = /^\/api\/jobs\/([^/]+)\/(approve|reject|retry|revise)$/.exec(path);
177
+ if (!action?.[1] || (action[2] !== "approve" && action[2] !== "reject" && action[2] !== "retry" && action[2] !== "revise")) {
166
178
  sendJson(res, 404, { error: "Not found." }, secrets);
167
179
  return;
168
180
  }
@@ -175,6 +187,14 @@ async function handle(req, res, state, boundPort, css, csrf) {
175
187
  await retry(req, res, config, css, secrets, jobId);
176
188
  return;
177
189
  }
190
+ if (action[2] === "revise") {
191
+ await revise(req, res, config, css, secrets, jobId, raw);
192
+ return;
193
+ }
194
+ if (action[2] !== "approve" && action[2] !== "reject") {
195
+ sendJson(res, 404, { error: "Not found." }, secrets);
196
+ return;
197
+ }
178
198
  await answer(req, res, config, css, secrets, jobId, action[2]);
179
199
  return;
180
200
  }
@@ -182,14 +202,31 @@ async function handle(req, res, state, boundPort, css, csrf) {
182
202
  sendJson(res, 405, { error: "Method not allowed." }, secrets);
183
203
  return;
184
204
  }
185
- if (path === "/" || path === "" || path === "/board") {
186
- sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
187
- return;
188
- }
189
205
  if (path === "/api/jobs" || path === "/api/status") {
190
206
  sendJson(res, 200, statusDocument(state), secrets);
191
207
  return;
192
208
  }
209
+ if (path === "/api/board") {
210
+ sendJson(res, 200, boardPayload(state), secrets);
211
+ return;
212
+ }
213
+ if (path === "/api/stats") {
214
+ const window = parseStatsWindow(url.searchParams.get("window"));
215
+ if (!window) {
216
+ sendJson(res, 400, { error: "window must be today, 7d, or 30d." }, secrets);
217
+ return;
218
+ }
219
+ sendJson(res, 200, statsDocument(state, window), secrets);
220
+ return;
221
+ }
222
+ if (path === "/api/report") {
223
+ sendJson(res, 200, reportDocument(state), secrets);
224
+ return;
225
+ }
226
+ if (path === "/api/unseen") {
227
+ sendJson(res, 200, unseenDocument(state, url.searchParams.getAll("seen")), secrets);
228
+ return;
229
+ }
193
230
  if (path === "/api/events") {
194
231
  const jobId = url.searchParams.get("job_id");
195
232
  if (!jobId || !JOB_ID.test(jobId)) {
@@ -199,6 +236,17 @@ async function handle(req, res, state, boundPort, css, csrf) {
199
236
  sendJson(res, 200, { job_id: jobId, events: eventsForJob(config.kilnDir, jobId) }, secrets);
200
237
  return;
201
238
  }
239
+ const apiDetail = /^\/api\/jobs\/([^/]+)\/detail$/.exec(path);
240
+ if (apiDetail?.[1]) {
241
+ const jobId = parseJobId(apiDetail[1]);
242
+ const job = jobId ? findJob(config, jobId) : undefined;
243
+ if (!job) {
244
+ sendJson(res, 404, { error: jobId ? `No job ${jobId}.` : "Not found." }, secrets);
245
+ return;
246
+ }
247
+ sendJson(res, 200, detailDocument(state, job), secrets);
248
+ return;
249
+ }
202
250
  const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
203
251
  if (apiJob?.[1]) {
204
252
  const jobId = parseJobId(apiJob[1]);
@@ -210,6 +258,54 @@ async function handle(req, res, state, boundPort, css, csrf) {
210
258
  sendJson(res, 200, job, secrets);
211
259
  return;
212
260
  }
261
+ const listed = listSection(path);
262
+ if (listed) {
263
+ if (listed === "stats") {
264
+ const window = parseStatsWindow(url.searchParams.get("window"));
265
+ if (!window) {
266
+ sendHtml(res, 400, renderMessagePage(css, "Stats", "window must be today, 7d, or 30d."), secrets);
267
+ return;
268
+ }
269
+ sendHtml(res, 200, renderShell(state, css, csrf, "stats", { window }), secrets);
270
+ return;
271
+ }
272
+ sendHtml(res, 200, renderShell(state, css, csrf, listed), secrets);
273
+ return;
274
+ }
275
+ const repoPage = /^\/repos\/([^/]+)$/.exec(path);
276
+ if (repoPage?.[1]) {
277
+ let name;
278
+ try {
279
+ name = decodeURIComponent(repoPage[1]);
280
+ }
281
+ catch {
282
+ sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
283
+ return;
284
+ }
285
+ if (!state.config.repos[name]) {
286
+ sendHtml(res, 404, renderMessagePage(css, "Not found", `No repo ${name}.`), secrets);
287
+ return;
288
+ }
289
+ sendHtml(res, 200, renderShell(state, css, csrf, "repos", { repo: name }), secrets);
290
+ return;
291
+ }
292
+ const jobPage = /^\/jobs\/([^/]+)$/.exec(path);
293
+ if (jobPage?.[1]) {
294
+ const jobId = parseJobId(jobPage[1]);
295
+ const job = jobId ? findJob(config, jobId) : undefined;
296
+ if (!job) {
297
+ sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
298
+ return;
299
+ }
300
+ const tabRaw = url.searchParams.get("tab");
301
+ if (tabRaw && tabRaw !== "overview" && tabRaw !== "changes" && tabRaw !== "timeline") {
302
+ sendHtml(res, 400, renderMessagePage(css, "Job", "tab must be overview, changes, or timeline."), secrets);
303
+ return;
304
+ }
305
+ const tab = tabRaw === "changes" || tabRaw === "timeline" ? tabRaw : "overview";
306
+ sendHtml(res, 200, renderShell(state, css, csrf, "job", { detail: detailDocument(state, job), tab }), secrets);
307
+ return;
308
+ }
213
309
  const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
214
310
  if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
215
311
  const jobId = parseJobId(page[1]);
@@ -261,6 +357,48 @@ async function answer(req, res, config, css, secrets, jobId, choice) {
261
357
  }
262
358
  redirect(res, redirectTarget(req));
263
359
  }
360
+ async function revise(req, res, config, css, secrets, jobId, raw) {
361
+ const note = noteFromBody(req, raw);
362
+ if (!note) {
363
+ const message = "A revise note is required.";
364
+ if (wantsJson(req))
365
+ sendJson(res, 400, { error: message }, secrets);
366
+ else
367
+ sendHtml(res, 400, renderMessagePage(css, "Could not revise", message), secrets);
368
+ return;
369
+ }
370
+ try {
371
+ reviseJob(config, jobId, note);
372
+ }
373
+ catch (error) {
374
+ const message = error instanceof Error ? error.message : String(error);
375
+ const status = message.startsWith("No job ") ? 404 : 409;
376
+ if (wantsJson(req))
377
+ sendJson(res, status, { error: message }, secrets);
378
+ else
379
+ sendHtml(res, status, renderMessagePage(css, "Could not revise", message), secrets);
380
+ return;
381
+ }
382
+ const job = findJob(config, jobId);
383
+ if (wantsJson(req)) {
384
+ sendJson(res, 200, { ok: true, job }, secrets);
385
+ return;
386
+ }
387
+ redirect(res, redirectTarget(req));
388
+ }
389
+ function noteFromBody(req, raw) {
390
+ const type = headerValue(req.headers["content-type"]);
391
+ if (type.includes("application/json")) {
392
+ try {
393
+ const parsed = JSON.parse(raw);
394
+ return typeof parsed.note === "string" ? parsed.note : undefined;
395
+ }
396
+ catch {
397
+ return undefined;
398
+ }
399
+ }
400
+ return new URLSearchParams(raw).get("note") ?? undefined;
401
+ }
264
402
  async function retry(req, res, config, css, secrets, jobId) {
265
403
  try {
266
404
  retryJob(config, jobId);
@@ -341,13 +479,13 @@ function redirectTarget(req) {
341
479
  return "/";
342
480
  try {
343
481
  const url = new URL(referer);
344
- if (url.pathname === "/board")
345
- return "/board";
482
+ if (!url.pathname.startsWith("/") || url.pathname.startsWith("//"))
483
+ return "/";
484
+ return `${url.pathname}${url.search}`;
346
485
  }
347
486
  catch {
348
487
  return "/";
349
488
  }
350
- return "/";
351
489
  }
352
490
  function wantsJson(req) {
353
491
  const accept = headerValue(req.headers.accept);
@@ -561,20 +699,121 @@ function repoList(config) {
561
699
  .sort((left, right) => left.name.localeCompare(right.name));
562
700
  }
563
701
  function pageOptions(state, csrf) {
702
+ const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
564
703
  return {
565
704
  csrf,
566
705
  repos: repoList(state.config),
567
706
  workflows: listWorkflowNames(),
707
+ paused: isPaused(state.config.kilnDir),
708
+ spend,
568
709
  ...(state.configError ? { configError: state.configError } : {}),
569
710
  };
570
711
  }
571
712
  function statusDocument(state) {
713
+ const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
572
714
  return {
573
715
  ...readStatus(state.config),
574
716
  repos: repoList(state.config),
575
717
  configError: state.configError ?? null,
718
+ paused: isPaused(state.config.kilnDir),
719
+ spend: { day: spend.day, usd: spend.usd, cap: spend.cap, blocked: spend.blocked },
720
+ };
721
+ }
722
+ function listSection(path) {
723
+ if (path === "/" || path === "" || path === "/board" || path === "/inbox")
724
+ return "inbox";
725
+ if (path === "/hand")
726
+ return "hand";
727
+ if (path === "/repos")
728
+ return "repos";
729
+ if (path === "/history")
730
+ return "history";
731
+ if (path === "/stats")
732
+ return "stats";
733
+ if (path === "/report")
734
+ return "report";
735
+ if (path === "/settings")
736
+ return "settings";
737
+ return undefined;
738
+ }
739
+ function renderShell(state, css, csrf, section, extra = {}) {
740
+ const status = readStatus(state.config);
741
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
742
+ const board = buildBoard(status.jobs, events);
743
+ const page = pageOptions(state, csrf);
744
+ return renderApp({
745
+ css,
746
+ section,
747
+ board,
748
+ attention: status.attention,
749
+ page: extra.repo ? { ...page, repo: extra.repo } : page,
750
+ ...(extra.repo ? { repo: extra.repo } : {}),
751
+ ...(section === "stats" && extra.window ? { stats: statsDocument(state, extra.window) } : {}),
752
+ ...(section === "report" ? { report: reportDocument(state) } : {}),
753
+ ...(extra.detail ? { detail: extra.detail, tab: extra.tab ?? "overview" } : {}),
754
+ });
755
+ }
756
+ function boardPayload(state) {
757
+ const status = readStatus(state.config);
758
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
759
+ const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
760
+ return {
761
+ ...buildBoard(status.jobs, events),
762
+ paused: isPaused(state.config.kilnDir),
763
+ spend,
764
+ configError: state.configError ?? null,
576
765
  };
577
766
  }
767
+ function statsDocument(state, window) {
768
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
769
+ return statsFor(events, {
770
+ window,
771
+ ...(state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {}),
772
+ });
773
+ }
774
+ function reportDocument(state) {
775
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
776
+ return dayReport(events, state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {});
777
+ }
778
+ function unseenDocument(state, seenValues) {
779
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
780
+ const board = buildBoard(readStatus(state.config).jobs, events);
781
+ return unseenChanges(events, parseSeenQuery(seenValues), new Set(board.inbox.map((job) => job.jobId)));
782
+ }
783
+ function detailDocument(state, job) {
784
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
785
+ const repo = state.config.repos[job.repo];
786
+ const branch = branchOf(job, events);
787
+ const base = prBase(events, job.jobId);
788
+ const commits = repo && branch ? readBranchCommits(repo.path, branch) : [];
789
+ const diff = repo && branch && base ? readDiffStat(repo.path, base, branch) : undefined;
790
+ const issue = jobIssue(state.config, job.jobId);
791
+ return jobDetail({
792
+ job,
793
+ events,
794
+ maxFixRounds: repo?.maxFixRounds ?? state.config.maxFixRounds,
795
+ commits,
796
+ ...(issue ? { issue } : {}),
797
+ ...(diff ? { diffStat: diff } : {}),
798
+ });
799
+ }
800
+ function prBase(events, jobId) {
801
+ let base;
802
+ for (const event of events) {
803
+ if (event.job_id === jobId && event.event === "pr.opened")
804
+ base = event.properties.base;
805
+ }
806
+ return base;
807
+ }
808
+ function jobIssue(config, jobId) {
809
+ try {
810
+ const raw = JSON.parse(readFileSync(join(config.jobsDir, `${jobBasename(jobId)}.json`), "utf8"));
811
+ return typeof raw.issue === "string" ? raw.issue : undefined;
812
+ }
813
+ catch {
814
+ return undefined;
815
+ }
816
+ }
578
817
  function statusCode(error) {
579
818
  if (typeof error === "object" && error !== null && "statusCode" in error) {
580
819
  const code = error.statusCode;
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.11.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",
@@ -16,7 +16,8 @@
16
16
  ],
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/lchase/kiln.git"
19
+ "url": "git+https://github.com/lchase/kiln.git",
20
+ "directory": "packages/kiln"
20
21
  },
21
22
  "publishConfig": {
22
23
  "access": "public"
@@ -34,7 +35,7 @@
34
35
  "node": ">=22"
35
36
  },
36
37
  "dependencies": {
37
- "@lchase/yak": "0.8.0",
38
+ "@lchase/yak": "0.9.0",
38
39
  "zod": "^4.6.7"
39
40
  },
40
41
  "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"'