@lchase/kiln 0.10.0 → 0.12.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,15 +7,17 @@ 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
- import { defaultHost } from "./host.js";
13
+ import { productionHost } 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";
20
+ import { triggerRows } from "./triggers.js";
19
21
  import { listWorkflowNames } from "./workflow.js";
20
22
  const BODY_LIMIT = 65_536;
21
23
  const TEXT_LIMIT = 100_000;
@@ -42,6 +44,7 @@ export function startServe(options) {
42
44
  let stopped = false;
43
45
  let timer;
44
46
  let firstTick;
47
+ let triggerStartup = true;
45
48
  const shutdown = () => {
46
49
  if (stopped)
47
50
  return;
@@ -67,8 +70,10 @@ export function startServe(options) {
67
70
  const tick = () => {
68
71
  if (stopped)
69
72
  return;
73
+ const startup = triggerStartup;
74
+ triggerStartup = false;
70
75
  refreshLiveConfig(state);
71
- safeTick(state.config, state.secrets, false);
76
+ safeTick(state.config, state.secrets, startup);
72
77
  };
73
78
  firstTick = setTimeout(tick, 0);
74
79
  timer = setInterval(tick, options.tickSeconds * 1000);
@@ -82,9 +87,9 @@ export function loadServeCss() {
82
87
  }
83
88
  return readFileSync(path, "utf8");
84
89
  }
85
- function safeTick(config, secrets, startup) {
90
+ function safeTick(config, secrets, triggerStartup) {
86
91
  try {
87
- const report = runTick(config, defaultHost(), { startup });
92
+ const report = runTick(config, productionHost(), { startup: false, triggerStartup });
88
93
  if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
89
94
  const tickAt = report.indexOf("\ntick ");
90
95
  const head = tickAt === -1 ? report : report.slice(0, tickAt);
@@ -201,14 +206,31 @@ async function handle(req, res, state, boundPort, css, csrf) {
201
206
  sendJson(res, 405, { error: "Method not allowed." }, secrets);
202
207
  return;
203
208
  }
204
- if (path === "/" || path === "" || path === "/board") {
205
- sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
206
- return;
207
- }
208
209
  if (path === "/api/jobs" || path === "/api/status") {
209
210
  sendJson(res, 200, statusDocument(state), secrets);
210
211
  return;
211
212
  }
213
+ if (path === "/api/board") {
214
+ sendJson(res, 200, boardPayload(state), secrets);
215
+ return;
216
+ }
217
+ if (path === "/api/stats") {
218
+ const window = parseStatsWindow(url.searchParams.get("window"));
219
+ if (!window) {
220
+ sendJson(res, 400, { error: "window must be today, 7d, or 30d." }, secrets);
221
+ return;
222
+ }
223
+ sendJson(res, 200, statsDocument(state, window), secrets);
224
+ return;
225
+ }
226
+ if (path === "/api/report") {
227
+ sendJson(res, 200, reportDocument(state), secrets);
228
+ return;
229
+ }
230
+ if (path === "/api/unseen") {
231
+ sendJson(res, 200, unseenDocument(state, url.searchParams.getAll("seen")), secrets);
232
+ return;
233
+ }
212
234
  if (path === "/api/events") {
213
235
  const jobId = url.searchParams.get("job_id");
214
236
  if (!jobId || !JOB_ID.test(jobId)) {
@@ -218,6 +240,17 @@ async function handle(req, res, state, boundPort, css, csrf) {
218
240
  sendJson(res, 200, { job_id: jobId, events: eventsForJob(config.kilnDir, jobId) }, secrets);
219
241
  return;
220
242
  }
243
+ const apiDetail = /^\/api\/jobs\/([^/]+)\/detail$/.exec(path);
244
+ if (apiDetail?.[1]) {
245
+ const jobId = parseJobId(apiDetail[1]);
246
+ const job = jobId ? findJob(config, jobId) : undefined;
247
+ if (!job) {
248
+ sendJson(res, 404, { error: jobId ? `No job ${jobId}.` : "Not found." }, secrets);
249
+ return;
250
+ }
251
+ sendJson(res, 200, detailDocument(state, job), secrets);
252
+ return;
253
+ }
221
254
  const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
222
255
  if (apiJob?.[1]) {
223
256
  const jobId = parseJobId(apiJob[1]);
@@ -229,6 +262,54 @@ async function handle(req, res, state, boundPort, css, csrf) {
229
262
  sendJson(res, 200, job, secrets);
230
263
  return;
231
264
  }
265
+ const listed = listSection(path);
266
+ if (listed) {
267
+ if (listed === "stats") {
268
+ const window = parseStatsWindow(url.searchParams.get("window"));
269
+ if (!window) {
270
+ sendHtml(res, 400, renderMessagePage(css, "Stats", "window must be today, 7d, or 30d."), secrets);
271
+ return;
272
+ }
273
+ sendHtml(res, 200, renderShell(state, css, csrf, "stats", { window }), secrets);
274
+ return;
275
+ }
276
+ sendHtml(res, 200, renderShell(state, css, csrf, listed), secrets);
277
+ return;
278
+ }
279
+ const repoPage = /^\/repos\/([^/]+)$/.exec(path);
280
+ if (repoPage?.[1]) {
281
+ let name;
282
+ try {
283
+ name = decodeURIComponent(repoPage[1]);
284
+ }
285
+ catch {
286
+ sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
287
+ return;
288
+ }
289
+ if (!state.config.repos[name]) {
290
+ sendHtml(res, 404, renderMessagePage(css, "Not found", `No repo ${name}.`), secrets);
291
+ return;
292
+ }
293
+ sendHtml(res, 200, renderShell(state, css, csrf, "repos", { repo: name }), secrets);
294
+ return;
295
+ }
296
+ const jobPage = /^\/jobs\/([^/]+)$/.exec(path);
297
+ if (jobPage?.[1]) {
298
+ const jobId = parseJobId(jobPage[1]);
299
+ const job = jobId ? findJob(config, jobId) : undefined;
300
+ if (!job) {
301
+ sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
302
+ return;
303
+ }
304
+ const tabRaw = url.searchParams.get("tab");
305
+ if (tabRaw && tabRaw !== "overview" && tabRaw !== "changes" && tabRaw !== "timeline") {
306
+ sendHtml(res, 400, renderMessagePage(css, "Job", "tab must be overview, changes, or timeline."), secrets);
307
+ return;
308
+ }
309
+ const tab = tabRaw === "changes" || tabRaw === "timeline" ? tabRaw : "overview";
310
+ sendHtml(res, 200, renderShell(state, css, csrf, "job", { detail: detailDocument(state, job), tab }), secrets);
311
+ return;
312
+ }
232
313
  const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
233
314
  if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
234
315
  const jobId = parseJobId(page[1]);
@@ -402,13 +483,13 @@ function redirectTarget(req) {
402
483
  return "/";
403
484
  try {
404
485
  const url = new URL(referer);
405
- if (url.pathname === "/board")
406
- return "/board";
486
+ if (!url.pathname.startsWith("/") || url.pathname.startsWith("//"))
487
+ return "/";
488
+ return `${url.pathname}${url.search}`;
407
489
  }
408
490
  catch {
409
491
  return "/";
410
492
  }
411
- return "/";
412
493
  }
413
494
  function wantsJson(req) {
414
495
  const accept = headerValue(req.headers.accept);
@@ -639,9 +720,109 @@ function statusDocument(state) {
639
720
  repos: repoList(state.config),
640
721
  configError: state.configError ?? null,
641
722
  paused: isPaused(state.config.kilnDir),
642
- spend: { day: spend.day, usd: spend.usd, cap: spend.cap },
723
+ spend: { day: spend.day, usd: spend.usd, cap: spend.cap, blocked: spend.blocked },
643
724
  };
644
725
  }
726
+ function listSection(path) {
727
+ if (path === "/" || path === "" || path === "/board" || path === "/inbox")
728
+ return "inbox";
729
+ if (path === "/hand")
730
+ return "hand";
731
+ if (path === "/repos")
732
+ return "repos";
733
+ if (path === "/history")
734
+ return "history";
735
+ if (path === "/stats")
736
+ return "stats";
737
+ if (path === "/report")
738
+ return "report";
739
+ if (path === "/settings")
740
+ return "settings";
741
+ if (path === "/triggers")
742
+ return "triggers";
743
+ return undefined;
744
+ }
745
+ function renderShell(state, css, csrf, section, extra = {}) {
746
+ const status = readStatus(state.config);
747
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
748
+ const board = buildBoard(status.jobs, events);
749
+ const page = pageOptions(state, csrf);
750
+ return renderApp({
751
+ css,
752
+ section,
753
+ board,
754
+ attention: status.attention,
755
+ page: extra.repo ? { ...page, repo: extra.repo } : page,
756
+ ...(extra.repo ? { repo: extra.repo } : {}),
757
+ ...(section === "stats" && extra.window ? { stats: statsDocument(state, extra.window) } : {}),
758
+ ...(section === "report" ? { report: reportDocument(state) } : {}),
759
+ ...(extra.detail ? { detail: extra.detail, tab: extra.tab ?? "overview" } : {}),
760
+ ...(section === "triggers"
761
+ ? { triggers: triggerRows(state.config, readEvents(state.config.kilnDir, { warn: () => { } })) }
762
+ : {}),
763
+ });
764
+ }
765
+ function boardPayload(state) {
766
+ const status = readStatus(state.config);
767
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
768
+ const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
769
+ return {
770
+ ...buildBoard(status.jobs, events),
771
+ paused: isPaused(state.config.kilnDir),
772
+ spend,
773
+ configError: state.configError ?? null,
774
+ };
775
+ }
776
+ function statsDocument(state, window) {
777
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
778
+ return statsFor(events, {
779
+ window,
780
+ ...(state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {}),
781
+ });
782
+ }
783
+ function reportDocument(state) {
784
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
785
+ return dayReport(events, state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {});
786
+ }
787
+ function unseenDocument(state, seenValues) {
788
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
789
+ const board = buildBoard(readStatus(state.config).jobs, events);
790
+ return unseenChanges(events, parseSeenQuery(seenValues), new Set(board.inbox.map((job) => job.jobId)));
791
+ }
792
+ function detailDocument(state, job) {
793
+ const events = readEvents(state.config.kilnDir, { warn: () => { } });
794
+ const repo = state.config.repos[job.repo];
795
+ const branch = branchOf(job, events);
796
+ const base = prBase(events, job.jobId);
797
+ const commits = repo && branch ? readBranchCommits(repo.path, branch) : [];
798
+ const diff = repo && branch && base ? readDiffStat(repo.path, base, branch) : undefined;
799
+ const issue = jobIssue(state.config, job.jobId);
800
+ return jobDetail({
801
+ job,
802
+ events,
803
+ maxFixRounds: repo?.maxFixRounds ?? state.config.maxFixRounds,
804
+ commits,
805
+ ...(issue ? { issue } : {}),
806
+ ...(diff ? { diffStat: diff } : {}),
807
+ });
808
+ }
809
+ function prBase(events, jobId) {
810
+ let base;
811
+ for (const event of events) {
812
+ if (event.job_id === jobId && event.event === "pr.opened")
813
+ base = event.properties.base;
814
+ }
815
+ return base;
816
+ }
817
+ function jobIssue(config, jobId) {
818
+ try {
819
+ const raw = JSON.parse(readFileSync(join(config.jobsDir, `${jobBasename(jobId)}.json`), "utf8"));
820
+ return typeof raw.issue === "string" ? raw.issue : undefined;
821
+ }
822
+ catch {
823
+ return undefined;
824
+ }
825
+ }
645
826
  function statusCode(error) {
646
827
  if (typeof error === "object" && error !== null && "statusCode" in error) {
647
828
  const code = error.statusCode;
package/dist/settings.js CHANGED
@@ -4,7 +4,8 @@ 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_MAX_SAME_FINGERPRINT, 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, TriggerConfigSchema, } from "./schemas.js";
8
+ import { applyTriggerToggles, effectiveTriggers, knownTrigger } from "./triggers.js";
8
9
  const DEFAULTS = {
9
10
  deliver: "local",
10
11
  prTitle: DEFAULT_PR_TITLE,
@@ -48,6 +49,8 @@ export function saveSettings(configPath, body, home = homedir()) {
48
49
  return fail(400, "Settings are invalid.", fieldErrors(parsed.error));
49
50
  const { revision: _revision, ...editable } = parsed.data;
50
51
  const fields = checkoutFields(dirname(configPath), editable, home);
52
+ const triggerFields = triggerToggleFields(read.raw, editable);
53
+ Object.assign(fields, triggerFields);
51
54
  if (Object.keys(fields).length > 0)
52
55
  return fail(400, "Settings are invalid.", fields);
53
56
  const next = mergeSettings(read.raw, editable);
@@ -74,6 +77,7 @@ function project(path, revision, file) {
74
77
  ...(file.maxSameFingerprint !== undefined ? { maxSameFingerprint: file.maxSameFingerprint } : {}),
75
78
  ...(file.dailyDollarCap !== undefined ? { dailyDollarCap: file.dailyDollarCap } : {}),
76
79
  ...(sandbox ? { sandbox } : {}),
80
+ triggers: effectiveTriggers(file.triggers).map(triggerView),
77
81
  defaults: DEFAULTS,
78
82
  };
79
83
  }
@@ -111,6 +115,8 @@ function mergeSettings(raw, settings) {
111
115
  setOptional(next, "maxFixRounds", settings.maxFixRounds);
112
116
  setOptional(next, "maxSameFingerprint", settings.maxSameFingerprint);
113
117
  setOptional(next, "dailyDollarCap", settings.dailyDollarCap);
118
+ if (settings.triggers)
119
+ next.triggers = applyTriggerToggles(storedTriggers(raw.triggers), settings.triggers);
114
120
  if (settings.sandbox) {
115
121
  const prior = isRecord(raw.sandbox) ? { ...raw.sandbox } : {};
116
122
  setOptional(prior, "mode", settings.sandbox.mode);
@@ -122,6 +128,39 @@ function mergeSettings(raw, settings) {
122
128
  }
123
129
  return next;
124
130
  }
131
+ function triggerView(trigger) {
132
+ return {
133
+ id: trigger.id,
134
+ kind: trigger.kind,
135
+ enabled: trigger.enabled !== false,
136
+ ...(trigger.cron ? { cron: trigger.cron } : {}),
137
+ ...(trigger.every ? { every: trigger.every } : {}),
138
+ ...(trigger.repo ? { repo: trigger.repo } : {}),
139
+ ...(trigger.jql ? { jql: trigger.jql } : {}),
140
+ ...(trigger.label ? { label: trigger.label } : {}),
141
+ ...(trigger.status ? { status: trigger.status } : {}),
142
+ };
143
+ }
144
+ function storedTriggers(value) {
145
+ if (!Array.isArray(value))
146
+ return [];
147
+ const out = [];
148
+ for (const item of value) {
149
+ const parsed = TriggerConfigSchema.safeParse(item);
150
+ if (parsed.success)
151
+ out.push(parsed.data);
152
+ }
153
+ return out;
154
+ }
155
+ function triggerToggleFields(raw, settings) {
156
+ const fields = {};
157
+ const file = storedTriggers(raw.triggers);
158
+ for (const toggle of settings.triggers ?? []) {
159
+ if (!knownTrigger(toggle.id, file))
160
+ fields[`triggers.${toggle.id}`] = `Unknown trigger ${toggle.id}.`;
161
+ }
162
+ return fields;
163
+ }
125
164
  function checkoutFields(configDir, settings, home) {
126
165
  const fields = {};
127
166
  for (const [name, entry] of Object.entries(settings.repos)) {