@lchase/kiln 0.12.0 → 0.14.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/driver.js CHANGED
@@ -13,16 +13,17 @@ import { ensureFixTrailers } from "./fix-commit.js";
13
13
  import { holdDetail, pausedFrom, recordDailyCap, reviseNotesSinceRun, spendSnapshot, stopReasonFromEvents, withReviseNotes, } from "./rails.js";
14
14
  import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
15
15
  import { defaultHost } from "./host.js";
16
- import { readJobSpec, resolveJobSpec } from "./jobs.js";
16
+ import { markJobCancelled, readJobSpec, resolveJobSpec } from "./jobs.js";
17
17
  import { jobBasename, packageRoot } from "./paths.js";
18
18
  import { decide, isLanded, jobState, } from "./reconcile.js";
19
19
  import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
20
20
  import { projectRunJournal, promptsHash } from "./project.js";
21
+ import { postApprovedReview, reviewPostForJob } from "./review-post.js";
21
22
  import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
22
23
  import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
23
24
  import { JobSchema } from "./schemas.js";
24
25
  import { ensureReviewHead, evaluateTriggers, recordOwnedPullRequest, reviewHeadOf, } from "./triggers.js";
25
- import { leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
26
+ import { assertWorkflowName, leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
26
27
  const TAG_PREFIX = "kiln:";
27
28
  export function jobTag(jobId) {
28
29
  return `${TAG_PREFIX}${jobId}`;
@@ -97,6 +98,19 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
97
98
  if (!gate) {
98
99
  throw new Error(`Job ${jobId} has no open gate that accepts ${choice}.`);
99
100
  }
101
+ if (choice === "approve" && job.workflow === REVIEW_WORKFLOW) {
102
+ postApprovedReview({
103
+ kilnDir: config.kilnDir,
104
+ jobId,
105
+ runId: job.run.runId,
106
+ repo: job.repo,
107
+ sourceRef: job.source?.kind === "trigger" ? job.source.ref : undefined,
108
+ checkout: job.run.checkout,
109
+ host,
110
+ paused: world.observation.paused === true,
111
+ spendBlocked: world.observation.spendBlocked === true,
112
+ });
113
+ }
100
114
  const command = gateAnswerCommand(job.run.checkout, job.run.runId, gate, choice, job.run.title ?? job.title);
101
115
  const result = spawnSync("sh", ["-c", command], { encoding: "utf8" });
102
116
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
@@ -123,14 +137,48 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
123
137
  return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
124
138
  }
125
139
  /**
126
- * Run a failed or dead job again. The board Retry button and `kiln retry`
127
- * both call this.
128
- * A landed job whose only failure is opening the pull request is handed to
129
- * the same deliver path tick uses. Everything else is a new `yak run`, the
130
- * same launch tick uses. The attempt ledger is cleared either way, and the
131
- * current run id is ignored so a dead job is not stuck on the old journal.
132
- * A dead journal is cancelled first.
140
+ * Cancel queued jobs that have not started.
141
+ * A running, blocked, or finished job is left alone. Calling again is a no-op.
142
+ * The event log records `job.cancelled`. Branches are not deleted.
133
143
  */
144
+ export function cancelQueuedJobs(config, options = {}, host = defaultHost()) {
145
+ return withTickLock(config.kilnDir, () => {
146
+ if (options.repo && !config.repos[options.repo]) {
147
+ throw new Error(`Unknown repo ${options.repo}.`);
148
+ }
149
+ if (options.workflow)
150
+ assertWorkflowName(options.workflow);
151
+ const now = options.now ?? new Date();
152
+ const world = observe(config, host, now);
153
+ const cancelled = [];
154
+ for (const observed of world.observation.jobs) {
155
+ if (observed.error)
156
+ continue;
157
+ if (options.repo && observed.repo !== options.repo)
158
+ continue;
159
+ if (options.workflow && observed.workflow !== options.workflow)
160
+ continue;
161
+ if (jobState(observed, world.observation) !== "queued")
162
+ continue;
163
+ const job = world.jobs.get(observed.jobId);
164
+ if (!job || job.cancelledAt)
165
+ continue;
166
+ markJobCancelled(config, job, now);
167
+ appendEvent(config.kilnDir, {
168
+ job_id: job.id,
169
+ event: "job.cancelled",
170
+ ts: now.toISOString(),
171
+ properties: {
172
+ repo: job.repo,
173
+ workflow: job.workflow,
174
+ once: "job.cancelled",
175
+ },
176
+ });
177
+ cancelled.push(job.id);
178
+ }
179
+ return { cancelled };
180
+ });
181
+ }
134
182
  export function retryJob(config, jobId, host = defaultHost()) {
135
183
  return withTickLock(config.kilnDir, () => {
136
184
  const world = observe(config, host);
@@ -321,6 +369,9 @@ function observe(config, host, now = new Date()) {
321
369
  const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
322
370
  ? existingFindingsPath(config.kilnDir, entry.job.id)
323
371
  : undefined;
372
+ const reviewPost = entry.job
373
+ ? reviewPostForJob(events, { jobId: entry.job.id, repo: entry.job.repo, source: entry.job.source })
374
+ : {};
324
375
  const logTail = readLogTail(logPath);
325
376
  const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
326
377
  const diffSummary = run &&
@@ -340,6 +391,8 @@ function observe(config, host, now = new Date()) {
340
391
  ...(entry.job?.stack ? { stack: true } : {}),
341
392
  ...(delivery ? { delivery } : {}),
342
393
  ...(findingsPath ? { findingsPath } : {}),
394
+ ...(reviewPost.error ? { reviewPostError: reviewPost.error } : {}),
395
+ ...(reviewPost.url ? { reviewUrl: reviewPost.url } : {}),
343
396
  launching,
344
397
  launchFailed,
345
398
  ...(launchError ? { launchError } : {}),
@@ -347,6 +400,7 @@ function observe(config, host, now = new Date()) {
347
400
  ...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
348
401
  ...(logPath ? { logPath } : {}),
349
402
  ...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
403
+ ...(entry.job?.cancelledAt ? { cancelled: true } : {}),
350
404
  ...(logTail ? { logTail } : {}),
351
405
  ...(diffSummary ? { diffSummary } : {}),
352
406
  ...(stepPlan && stepPlan.length > 0 ? { stepPlan } : {}),
package/dist/events.js CHANGED
@@ -14,6 +14,7 @@ import { redactDeep } from "./redact.js";
14
14
  */
15
15
  export const EVENT_NAMES = [
16
16
  "job.added",
17
+ "job.cancelled",
17
18
  "job.retried",
18
19
  "run.started",
19
20
  "phase.started",
@@ -35,6 +36,8 @@ export const EVENT_NAMES = [
35
36
  "trigger.routed",
36
37
  "trigger.failed",
37
38
  "trigger.skipped",
39
+ "review.posted",
40
+ "review.failed",
38
41
  ];
39
42
  const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
40
43
  const once = z.string().min(1).optional();
@@ -50,6 +53,11 @@ const JobRetried = z.object({
50
53
  repo: z.string().min(1),
51
54
  once,
52
55
  });
56
+ const JobCancelled = z.object({
57
+ repo: z.string().min(1),
58
+ workflow: z.string().min(1),
59
+ once,
60
+ });
53
61
  const RunStarted = z.object({
54
62
  kiln_version: z.string().min(1),
55
63
  yak_version: z.string().min(1),
@@ -161,7 +169,31 @@ const TriggerFailed = z.object({
161
169
  });
162
170
  const TriggerSkipped = z.object({
163
171
  trigger_id: z.string().min(1),
164
- reason: z.enum(["paused", "capped"]),
172
+ reason: z.enum(["paused", "capped", "burst-cap", "queued-cap"]),
173
+ /** Pull request or issue the skip applies to, when the fire skipped one item. */
174
+ external_id: z.string().min(1).optional(),
175
+ once,
176
+ });
177
+ const ReviewPosted = z.object({
178
+ repo: z.string().min(1),
179
+ number: z.number().int().positive(),
180
+ sha: z.string().regex(/^[0-9a-f]{7,64}$/i),
181
+ review_id: z.string().min(1),
182
+ url: z.string().min(1),
183
+ findings: z.number().int().nonnegative(),
184
+ inline: z.number().int().nonnegative(),
185
+ skipped: z.number().int().nonnegative(),
186
+ /** `review:<repo>:<number>:<head sha>`. Same shape as the review-request dedupe. */
187
+ dedupe: z.string().min(1),
188
+ once,
189
+ });
190
+ const ReviewFailed = z.object({
191
+ repo: z.string().min(1),
192
+ number: z.number().int().positive(),
193
+ sha: z.string().regex(/^[0-9a-f]{7,64}$/i),
194
+ class: z.enum(["transient", "deterministic", "budget"]),
195
+ detail: z.string().min(1),
196
+ dedupe: z.string().min(1),
165
197
  once,
166
198
  });
167
199
  const envelope = {
@@ -172,6 +204,7 @@ const envelope = {
172
204
  };
173
205
  export const EventSchema = z.discriminatedUnion("event", [
174
206
  z.object({ ...envelope, event: z.literal("job.added"), job_id: z.string().min(1), properties: JobAdded }),
207
+ z.object({ ...envelope, event: z.literal("job.cancelled"), job_id: z.string().min(1), properties: JobCancelled }),
175
208
  z.object({ ...envelope, event: z.literal("job.retried"), job_id: z.string().min(1), properties: JobRetried }),
176
209
  z.object({ ...envelope, event: z.literal("run.started"), job_id: z.string().min(1), properties: RunStarted }),
177
210
  z.object({ ...envelope, event: z.literal("phase.started"), job_id: z.string().min(1), properties: PhaseStarted }),
@@ -233,6 +266,8 @@ export const EventSchema = z.discriminatedUnion("event", [
233
266
  job_id: z.string().min(1).nullable(),
234
267
  properties: TriggerSkipped,
235
268
  }),
269
+ z.object({ ...envelope, event: z.literal("review.posted"), job_id: z.string().min(1), properties: ReviewPosted }),
270
+ z.object({ ...envelope, event: z.literal("review.failed"), job_id: z.string().min(1), properties: ReviewFailed }),
236
271
  ]);
237
272
  const LOCK_WAIT_MS = 5_000;
238
273
  const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
package/dist/host.js CHANGED
@@ -15,6 +15,15 @@ export class HostError extends Error {
15
15
  this.name = "HostError";
16
16
  }
17
17
  }
18
+ /** GitHub label `review-requests` requires when the trigger sets none. */
19
+ export const DEFAULT_REVIEW_LABEL = "kiln-review";
20
+ /** `gh pr list --search` query. Opt-in: the pull request must carry `label`. */
21
+ export function reviewSearchQuery(label) {
22
+ const trimmed = label.trim();
23
+ if (!trimmed || /[\r\n]/.test(trimmed))
24
+ throw new Error("Review label must be a single line.");
25
+ return `review-requested:@me label:${quoteSearch(trimmed)}`;
26
+ }
18
27
  export function defaultHost() {
19
28
  return { github: realGitHub, git: realGitRemote, feedback: realPullRequestFeedback };
20
29
  }
@@ -24,14 +33,27 @@ export function productionHost() {
24
33
  }
25
34
  /**
26
35
  * Open pull requests awaiting the authenticated user's review, per registered repo.
27
- * A remote that is not GitHub is skipped, so a local test origin does not call gh.
36
+ * The search is opt-in: a pull request must carry `label`, or `kiln-review` when
37
+ * that is omitted. A remote that is not GitHub is skipped, so a local test origin does not call gh.
28
38
  */
29
39
  export function listReviewRequests(repos) {
30
40
  const found = [];
31
41
  for (const repo of repos) {
32
42
  if (!githubRemote(repo.path))
33
43
  continue;
34
- const result = spawnSync(ghBin(), ["pr", "list", "--search", "review-requested:@me", "--state", "open", "--limit", "50", "--json", "number,title,url,headRefOid"], {
44
+ const label = repo.label?.trim() || DEFAULT_REVIEW_LABEL;
45
+ const result = spawnSync(ghBin(), [
46
+ "pr",
47
+ "list",
48
+ "--search",
49
+ reviewSearchQuery(label),
50
+ "--state",
51
+ "open",
52
+ "--limit",
53
+ "50",
54
+ "--json",
55
+ "number,title,url,headRefOid,labels",
56
+ ], {
35
57
  cwd: repo.path,
36
58
  encoding: "utf8",
37
59
  timeout: 20_000,
@@ -57,6 +79,8 @@ export function listReviewRequests(repos) {
57
79
  const sha = typeof record.headRefOid === "string" ? record.headRefOid.trim().toLowerCase() : "";
58
80
  if (!Number.isInteger(number) || number <= 0 || !/^[0-9a-f]{7,64}$/.test(sha))
59
81
  continue;
82
+ if (!carriesLabel(record.labels, label))
83
+ continue;
60
84
  const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : `PR ${number}`;
61
85
  const url = typeof record.url === "string" ? record.url : "";
62
86
  found.push({ repo: repo.name, number, title, url, headSha: sha });
@@ -64,6 +88,25 @@ export function listReviewRequests(repos) {
64
88
  }
65
89
  return found;
66
90
  }
91
+ /** Missing `labels` trusts the search. A present list must include `label`. */
92
+ function carriesLabel(labels, label) {
93
+ if (labels === undefined)
94
+ return true;
95
+ if (!Array.isArray(labels))
96
+ return false;
97
+ const want = label.trim().toLowerCase();
98
+ return labels.some((item) => {
99
+ if (typeof item === "string")
100
+ return item.trim().toLowerCase() === want;
101
+ if (typeof item !== "object" || item === null || !("name" in item))
102
+ return false;
103
+ const name = item.name;
104
+ return typeof name === "string" && name.trim().toLowerCase() === want;
105
+ });
106
+ }
107
+ function quoteSearch(value) {
108
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
109
+ }
67
110
  function githubRemote(checkout) {
68
111
  const result = gitResult(checkout, ["remote", "get-url", "origin"]);
69
112
  if (result.status !== 0)
@@ -174,11 +217,12 @@ export const realGitRemote = {
174
217
  function ghBin() {
175
218
  return process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
176
219
  }
177
- function runGh(checkout, args) {
220
+ function runGh(checkout, args, input) {
178
221
  const bin = ghBin();
179
222
  const result = spawnSync(bin, args, {
180
223
  cwd: checkout,
181
224
  encoding: "utf8",
225
+ input,
182
226
  env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
183
227
  });
184
228
  if (result.error) {
@@ -304,4 +348,55 @@ export const realGitHub = {
304
348
  }
305
349
  return { state: asState(parsed.state), mergedAt: asMergedAt(parsed.mergedAt) };
306
350
  },
351
+ postCommentReview(input) {
352
+ return submitCommentReview(input);
353
+ },
307
354
  };
355
+ /**
356
+ * `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `event: COMMENT`.
357
+ * One call submits the review. There is no pending-review step, no approve,
358
+ * and no merge.
359
+ */
360
+ export function submitCommentReview(input) {
361
+ const repo = githubOwnerRepo(input.checkout);
362
+ const event = "COMMENT";
363
+ const payload = {
364
+ commit_id: input.commitId,
365
+ body: input.body,
366
+ event,
367
+ };
368
+ if (input.comments.length > 0) {
369
+ payload.comments = input.comments.map((comment) => ({
370
+ path: comment.path,
371
+ body: comment.body,
372
+ line: comment.line,
373
+ side: "RIGHT",
374
+ }));
375
+ }
376
+ const result = runGh(input.checkout, ["api", "--method", "POST", `repos/${repo.owner}/${repo.name}/pulls/${input.number}/reviews`, "--input", "-"], JSON.stringify(payload));
377
+ if (result.status !== 0) {
378
+ const detail = (result.stderr || result.stdout).trim() || "gh api failed";
379
+ throw new HostError(`Could not post the pull request review: ${detail}`);
380
+ }
381
+ let parsed;
382
+ try {
383
+ parsed = JSON.parse(result.stdout);
384
+ }
385
+ catch {
386
+ throw new HostError("gh api returned data that is not a pull request review.");
387
+ }
388
+ const id = typeof parsed.id === "number" || typeof parsed.id === "string" ? String(parsed.id) : "";
389
+ const url = typeof parsed.html_url === "string" ? parsed.html_url.trim() : "";
390
+ if (!id || !url)
391
+ throw new HostError("gh api did not return a review id and URL.");
392
+ return { id, url };
393
+ }
394
+ function githubOwnerRepo(checkout) {
395
+ const result = runGh(checkout, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
396
+ const name = result.stdout.trim();
397
+ const [owner, repo] = name.split("/");
398
+ if (result.status !== 0 || !owner || !repo || name.split("/").length !== 2) {
399
+ throw new HostError(`Could not read owner/name for the review: ${(result.stderr || result.stdout).trim() || name || "gh repo view failed"}`);
400
+ }
401
+ return { owner, name: repo };
402
+ }