@lchase/kiln 0.10.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
@@ -7,13 +7,14 @@ import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
7
7
  import { loadKilnConfig } from "./config.js";
8
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";
17
18
  import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
18
19
  import { readSettings, saveSettings } from "./settings.js";
19
20
  import { listWorkflowNames } from "./workflow.js";
@@ -201,14 +202,31 @@ async function handle(req, res, state, boundPort, css, csrf) {
201
202
  sendJson(res, 405, { error: "Method not allowed." }, secrets);
202
203
  return;
203
204
  }
204
- if (path === "/" || path === "" || path === "/board") {
205
- sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
206
- return;
207
- }
208
205
  if (path === "/api/jobs" || path === "/api/status") {
209
206
  sendJson(res, 200, statusDocument(state), secrets);
210
207
  return;
211
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
+ }
212
230
  if (path === "/api/events") {
213
231
  const jobId = url.searchParams.get("job_id");
214
232
  if (!jobId || !JOB_ID.test(jobId)) {
@@ -218,6 +236,17 @@ async function handle(req, res, state, boundPort, css, csrf) {
218
236
  sendJson(res, 200, { job_id: jobId, events: eventsForJob(config.kilnDir, jobId) }, secrets);
219
237
  return;
220
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
+ }
221
250
  const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
222
251
  if (apiJob?.[1]) {
223
252
  const jobId = parseJobId(apiJob[1]);
@@ -229,6 +258,54 @@ async function handle(req, res, state, boundPort, css, csrf) {
229
258
  sendJson(res, 200, job, secrets);
230
259
  return;
231
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
+ }
232
309
  const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
233
310
  if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
234
311
  const jobId = parseJobId(page[1]);
@@ -402,13 +479,13 @@ function redirectTarget(req) {
402
479
  return "/";
403
480
  try {
404
481
  const url = new URL(referer);
405
- if (url.pathname === "/board")
406
- return "/board";
482
+ if (!url.pathname.startsWith("/") || url.pathname.startsWith("//"))
483
+ return "/";
484
+ return `${url.pathname}${url.search}`;
407
485
  }
408
486
  catch {
409
487
  return "/";
410
488
  }
411
- return "/";
412
489
  }
413
490
  function wantsJson(req) {
414
491
  const accept = headerValue(req.headers.accept);
@@ -639,9 +716,104 @@ function statusDocument(state) {
639
716
  repos: repoList(state.config),
640
717
  configError: state.configError ?? null,
641
718
  paused: isPaused(state.config.kilnDir),
642
- spend: { day: spend.day, usd: spend.usd, cap: spend.cap },
719
+ spend: { day: spend.day, usd: spend.usd, cap: spend.cap, blocked: spend.blocked },
643
720
  };
644
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,
765
+ };
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
+ }
645
817
  function statusCode(error) {
646
818
  if (typeof error === "object" && error !== null && "statusCode" in error) {
647
819
  const code = error.statusCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.10.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"