@lchase/kiln 0.13.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,7 +13,7 @@ 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";
@@ -23,7 +23,7 @@ import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sa
23
23
  import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
24
24
  import { JobSchema } from "./schemas.js";
25
25
  import { ensureReviewHead, evaluateTriggers, recordOwnedPullRequest, reviewHeadOf, } from "./triggers.js";
26
- import { leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
26
+ import { assertWorkflowName, leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
27
27
  const TAG_PREFIX = "kiln:";
28
28
  export function jobTag(jobId) {
29
29
  return `${TAG_PREFIX}${jobId}`;
@@ -137,14 +137,48 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
137
137
  return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
138
138
  }
139
139
  /**
140
- * Run a failed or dead job again. The board Retry button and `kiln retry`
141
- * both call this.
142
- * A landed job whose only failure is opening the pull request is handed to
143
- * the same deliver path tick uses. Everything else is a new `yak run`, the
144
- * same launch tick uses. The attempt ledger is cleared either way, and the
145
- * current run id is ignored so a dead job is not stuck on the old journal.
146
- * 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.
147
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
+ }
148
182
  export function retryJob(config, jobId, host = defaultHost()) {
149
183
  return withTickLock(config.kilnDir, () => {
150
184
  const world = observe(config, host);
@@ -366,6 +400,7 @@ function observe(config, host, now = new Date()) {
366
400
  ...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
367
401
  ...(logPath ? { logPath } : {}),
368
402
  ...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
403
+ ...(entry.job?.cancelledAt ? { cancelled: true } : {}),
369
404
  ...(logTail ? { logTail } : {}),
370
405
  ...(diffSummary ? { diffSummary } : {}),
371
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",
@@ -52,6 +53,11 @@ const JobRetried = z.object({
52
53
  repo: z.string().min(1),
53
54
  once,
54
55
  });
56
+ const JobCancelled = z.object({
57
+ repo: z.string().min(1),
58
+ workflow: z.string().min(1),
59
+ once,
60
+ });
55
61
  const RunStarted = z.object({
56
62
  kiln_version: z.string().min(1),
57
63
  yak_version: z.string().min(1),
@@ -163,7 +169,9 @@ const TriggerFailed = z.object({
163
169
  });
164
170
  const TriggerSkipped = z.object({
165
171
  trigger_id: z.string().min(1),
166
- 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(),
167
175
  once,
168
176
  });
169
177
  const ReviewPosted = z.object({
@@ -196,6 +204,7 @@ const envelope = {
196
204
  };
197
205
  export const EventSchema = z.discriminatedUnion("event", [
198
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 }),
199
208
  z.object({ ...envelope, event: z.literal("job.retried"), job_id: z.string().min(1), properties: JobRetried }),
200
209
  z.object({ ...envelope, event: z.literal("run.started"), job_id: z.string().min(1), properties: RunStarted }),
201
210
  z.object({ ...envelope, event: z.literal("phase.started"), job_id: z.string().min(1), properties: PhaseStarted }),
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)