@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/jobs.js CHANGED
@@ -102,6 +102,15 @@ export function writeJob(config, input) {
102
102
  });
103
103
  return { job, jsonPath };
104
104
  }
105
+ /** Record a cancel on the job file. A second call leaves the file as it is. */
106
+ export function markJobCancelled(config, job, now = new Date()) {
107
+ if (job.cancelledAt)
108
+ return job;
109
+ const next = JobSchema.parse({ ...job, cancelledAt: now.toISOString() });
110
+ const jsonPath = jobFilePath(config.jobsDir, job.id);
111
+ writeFileSync(jsonPath, `${JSON.stringify(next, null, 2)}\n`);
112
+ return next;
113
+ }
105
114
  export function renderAddedJob(job, jsonPath) {
106
115
  const lines = [`added ${job.id}`, `repo ${job.repo}`, `workflow ${job.workflow}`, `spec ${job.spec}`];
107
116
  if (job.issue)
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A small markdown subset for the board. Text is escaped before any tag is
3
+ * added, so a draft cannot inject HTML or a script. Links are http(s) only.
4
+ */
5
+ export function renderMarkdown(src) {
6
+ const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
7
+ let index = 0;
8
+ let html = "";
9
+ while (index < lines.length) {
10
+ const line = lines[index] ?? "";
11
+ if (!line.trim()) {
12
+ index += 1;
13
+ continue;
14
+ }
15
+ if (line.startsWith("```")) {
16
+ const fence = [];
17
+ index += 1;
18
+ while (index < lines.length && !(lines[index] ?? "").startsWith("```")) {
19
+ fence.push(lines[index] ?? "");
20
+ index += 1;
21
+ }
22
+ index += 1;
23
+ html += `<pre><code>${escapeHtml(fence.join("\n"))}</code></pre>`;
24
+ continue;
25
+ }
26
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
27
+ if (heading?.[1] && heading[2] !== undefined) {
28
+ const level = Math.min(heading[1].length, 3);
29
+ html += `<h${level}>${inline(heading[2])}</h${level}>`;
30
+ index += 1;
31
+ continue;
32
+ }
33
+ if (line.startsWith(">")) {
34
+ const quote = [];
35
+ while (index < lines.length && (lines[index] ?? "").startsWith(">")) {
36
+ quote.push((lines[index] ?? "").replace(/^>\s?/, ""));
37
+ index += 1;
38
+ }
39
+ html += `<blockquote>${inline(quote.join(" "))}</blockquote>`;
40
+ continue;
41
+ }
42
+ if (isList(line)) {
43
+ const ordered = /^\s*\d+\./.test(line);
44
+ const items = [];
45
+ while (index < lines.length && (isList(lines[index] ?? "") || (/^\s{2,}\S/.test(lines[index] ?? "") && items.length > 0))) {
46
+ const item = lines[index] ?? "";
47
+ index += 1;
48
+ if (isList(item))
49
+ items.push(item.replace(/^\s*(?:[-*+]|\d+\.)\s+/, ""));
50
+ else
51
+ items[items.length - 1] = `${items[items.length - 1]} ${item.trim()}`;
52
+ }
53
+ const tag = ordered ? "ol" : "ul";
54
+ html += `<${tag}>${items.map((item) => `<li>${inline(item)}</li>`).join("")}</${tag}>`;
55
+ continue;
56
+ }
57
+ const paragraph = [];
58
+ while (index < lines.length && (lines[index] ?? "").trim() && !isBlock(lines[index] ?? "")) {
59
+ paragraph.push(lines[index] ?? "");
60
+ index += 1;
61
+ }
62
+ html += `<p>${inline(paragraph.join(" "))}</p>`;
63
+ }
64
+ return html;
65
+ }
66
+ function isList(line) {
67
+ return /^\s*(?:[-*+]|\d+\.)\s+/.test(line);
68
+ }
69
+ function isBlock(line) {
70
+ return /^(?:#{1,6}\s|```|>)/.test(line) || isList(line);
71
+ }
72
+ function inline(raw) {
73
+ const codes = [];
74
+ const slotted = raw.replace(/`([^`]+)`/g, (_match, code) => {
75
+ codes.push(escapeHtml(code));
76
+ return `\u0000${codes.length - 1}\u0000`;
77
+ });
78
+ let html = escapeHtml(slotted);
79
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
80
+ html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
81
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>');
82
+ return html.replace(/\u0000(\d+)\u0000/g, (_match, slot) => `<code>${codes[Number(slot)] ?? ""}</code>`);
83
+ }
84
+ export function escapeHtml(value) {
85
+ return value
86
+ .replaceAll("&", "&amp;")
87
+ .replaceAll("<", "&lt;")
88
+ .replaceAll(">", "&gt;")
89
+ .replaceAll('"', "&quot;")
90
+ .replaceAll("'", "&#39;");
91
+ }
package/dist/project.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
- import { basename, join, relative, sep } from "node:path";
4
+ import { basename, dirname, join, relative, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { appendEvent } from "./events.js";
6
7
  import { costFromJournal, decideFailure } from "./failure.js";
7
8
  import { jobBasename, packageRoot } from "./paths.js";
@@ -24,7 +25,31 @@ export function projectRunJournal(input) {
24
25
  console.error(`kiln: event log: ${message}`);
25
26
  }
26
27
  }
28
+ /**
29
+ * Version of the `@lchase/kiln` package that contains this file.
30
+ * Walks up from the running module so a parent package.json (the workspace
31
+ * root has none) cannot supply a stale or missing version.
32
+ */
27
33
  export function kilnVersion() {
34
+ let dir = dirname(fileURLToPath(import.meta.url));
35
+ for (let i = 0; i < 8; i += 1) {
36
+ const pkgPath = join(dir, "package.json");
37
+ if (existsSync(pkgPath)) {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
40
+ if (parsed.name === "@lchase/kiln" && typeof parsed.version === "string" && parsed.version) {
41
+ return parsed.version;
42
+ }
43
+ }
44
+ catch {
45
+ // An unreadable file is not the running package. Keep walking.
46
+ }
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir)
50
+ break;
51
+ dir = parent;
52
+ }
28
53
  return packageVersion(join(packageRoot(), "package.json"));
29
54
  }
30
55
  export function yakVersion() {
package/dist/reconcile.js CHANGED
@@ -35,6 +35,7 @@ function inFlight(job, observation) {
35
35
  * | launching breadcrumb | running |
36
36
  * | dependencies unmet | blocked |
37
37
  * | launch error | failed |
38
+ * | cancelled before start | cancelled |
38
39
  * | otherwise | queued |
39
40
  *
40
41
  * `deliver=local`: `done` is a finished run whose gate says approve.
@@ -51,6 +52,8 @@ export function jobState(job, observation) {
51
52
  return "in-review";
52
53
  if (job.launching && (!job.run || job.runRetired || job.fixInProgress))
53
54
  return "running";
55
+ if (job.cancelled && !job.run && !job.launching)
56
+ return "cancelled";
54
57
  if ((job.feedbackExhausted || job.feedbackAmbiguous) && isLanded(job) && !job.fixInProgress) {
55
58
  if (job.delivery?.merged || job.delivery?.prState === "MERGED")
56
59
  return "done";
@@ -165,7 +168,7 @@ function needsDelivery(job) {
165
168
  return true;
166
169
  }
167
170
  function canLaunch(job, observation) {
168
- if (job.error || job.run || job.launching || job.launchFailed || job.fixInProgress)
171
+ if (job.cancelled || job.error || job.run || job.launching || job.launchFailed || job.fixInProgress)
169
172
  return false;
170
173
  return dependenciesMet(job, observation);
171
174
  }
@@ -177,7 +180,7 @@ function canStartFix(job) {
177
180
  }
178
181
  /** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
179
182
  function canRetry(job, observation, now) {
180
- if (job.error || job.launching || job.retriesExhausted || job.stopReason || job.ledgerError)
183
+ if (job.cancelled || job.error || job.launching || job.retriesExhausted || job.stopReason || job.ledgerError)
181
184
  return false;
182
185
  if (job.run && !job.runRetired)
183
186
  return false;
package/dist/report.js CHANGED
@@ -132,6 +132,10 @@ export function statusDetails(job) {
132
132
  lines.push(`findings ${job.findingsPath}`);
133
133
  else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
134
134
  lines.push("findings missing");
135
+ if (job.reviewUrl)
136
+ lines.push(`review ${job.reviewUrl}`);
137
+ if (job.reviewPostError)
138
+ lines.push(job.reviewPostError);
135
139
  if (job.prPending)
136
140
  lines.push("pull request not opened yet");
137
141
  if (job.state === "failed" && job.deliveryError)
@@ -222,6 +226,8 @@ function toStatusJob(job, observation) {
222
226
  ...(job.specPath ? { specPath: job.specPath } : {}),
223
227
  ...(job.logPath ? { logPath: job.logPath } : {}),
224
228
  ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
229
+ ...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
230
+ ...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
225
231
  launchFailed: job.launchFailed,
226
232
  attemptCount: job.attemptCount ?? 0,
227
233
  maxAttempts: job.attemptCap ?? observation.maxAttempts,
@@ -270,6 +276,8 @@ function toStatusJob(job, observation) {
270
276
  ...(job.logPath ? { logPath: job.logPath } : {}),
271
277
  ...(job.specPath ? { specPath: job.specPath } : {}),
272
278
  ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
279
+ ...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
280
+ ...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
273
281
  launchFailed: job.launchFailed,
274
282
  attemptCount: job.attemptCount ?? 0,
275
283
  maxAttempts: job.attemptCap ?? observation.maxAttempts,
@@ -0,0 +1,269 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { appendEvent, readEvents } from "./events.js";
4
+ import { classifyFailureText } from "./failure.js";
5
+ import { realGitHub } from "./host.js";
6
+ import { redact } from "./redact.js";
7
+ import { parseReviewSource } from "./triggers.js";
8
+ /**
9
+ * A review draft the human has not posted yet, and the COMMENT review Kiln
10
+ * writes after they approve the Inbox gate.
11
+ *
12
+ * The review event is always COMMENT. Kiln does not approve, request
13
+ * changes, or merge. A second approve at the same `review:<repo>:<number>:<sha>`
14
+ * does not post again.
15
+ */
16
+ export const REVIEW_SEVERITIES = ["note", "nit", "warning", "blocking"];
17
+ const NEEDS_HUMAN_REASON = "review-post";
18
+ /** Target of a review-request job, or undefined when the draft is not tied to a pull request. */
19
+ export function reviewTargetFrom(repo, sourceRef) {
20
+ if (!sourceRef)
21
+ return undefined;
22
+ const parsed = parseReviewSource(sourceRef);
23
+ if (!parsed || parsed.repo !== repo)
24
+ return undefined;
25
+ return {
26
+ repo: parsed.repo,
27
+ number: parsed.number,
28
+ sha: parsed.sha,
29
+ dedupe: `review:${parsed.repo}:${parsed.number}:${parsed.sha}`,
30
+ };
31
+ }
32
+ export function reviewAlreadyPosted(events, dedupe) {
33
+ return events.some((event) => event.event === "review.posted" && event.properties.dedupe === dedupe);
34
+ }
35
+ /**
36
+ * Latest post for this job. A `review.posted` for the same dedupe, including
37
+ * one recorded on another job, counts as posted.
38
+ */
39
+ export function reviewPostForJob(events, job) {
40
+ const dedupe = job.source?.kind === "trigger" ? reviewTargetFrom(job.repo, job.source.ref)?.dedupe : undefined;
41
+ let url;
42
+ let error;
43
+ for (const event of events) {
44
+ const posted = event.event === "review.posted" &&
45
+ (event.job_id === job.jobId || (dedupe !== undefined && event.properties.dedupe === dedupe));
46
+ if (posted) {
47
+ url = event.properties.url;
48
+ error = undefined;
49
+ continue;
50
+ }
51
+ if (event.event === "review.failed" && event.job_id === job.jobId) {
52
+ url = undefined;
53
+ error = `Could not post the review (${event.properties.class}). ${event.properties.detail}`;
54
+ }
55
+ }
56
+ return { ...(url ? { url } : {}), ...(error ? { error } : {}) };
57
+ }
58
+ /** The draft step's artifact. Missing or unreadable is undefined. Extra fields are ignored. */
59
+ export function readReviewDraft(checkout, runId) {
60
+ const path = join(checkout, ".runs", runId, "artifacts", "draft.json");
61
+ if (!existsSync(path))
62
+ return undefined;
63
+ try {
64
+ return parseReviewDraft(JSON.parse(readFileSync(path, "utf8")));
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function parseReviewDraft(value) {
71
+ if (typeof value !== "object" || value === null)
72
+ return undefined;
73
+ const markdown = value.markdown;
74
+ if (typeof markdown !== "string" || !markdown.trim())
75
+ return undefined;
76
+ const raw = value.findings;
77
+ const findings = [];
78
+ if (Array.isArray(raw)) {
79
+ for (const item of raw) {
80
+ const finding = parseFinding(item);
81
+ if (finding)
82
+ findings.push(finding);
83
+ }
84
+ }
85
+ return { markdown: markdown.trim(), findings };
86
+ }
87
+ /**
88
+ * Body is the draft markdown. Findings with a real file and line become
89
+ * inline comments. Anything else is folded into the body when the markdown
90
+ * does not already contain it. Line numbers are never invented.
91
+ */
92
+ export function buildCommentReview(draft) {
93
+ const comments = [];
94
+ const skipped = [];
95
+ for (const finding of draft.findings) {
96
+ const path = commentPath(finding.file);
97
+ const line = commentLine(finding.line);
98
+ if (!path || line === undefined) {
99
+ skipped.push(finding);
100
+ continue;
101
+ }
102
+ comments.push({ path, line, body: commentText(finding) });
103
+ }
104
+ return {
105
+ body: redact(foldSkipped(draft.markdown, skipped)),
106
+ comments: comments.map((comment) => ({ ...comment, body: redact(comment.body) })),
107
+ findings: draft.findings.length,
108
+ inline: comments.length,
109
+ skipped: skipped.length,
110
+ };
111
+ }
112
+ /**
113
+ * Post the approved draft, or do nothing when that head SHA was already
114
+ * posted. Throws without calling gh when the factory is paused, the daily
115
+ * cap is blocking, or the post fails. The caller leaves the gate open on throw.
116
+ */
117
+ export function postApprovedReview(input) {
118
+ const target = reviewTargetFrom(input.repo, input.sourceRef);
119
+ if (!target) {
120
+ recordNeedsHuman(input);
121
+ throw new Error("This review draft is not tied to a pull request, so Kiln will not post it.");
122
+ }
123
+ const events = readEvents(input.kilnDir, { warn: () => { } });
124
+ if (reviewAlreadyPosted(events, target.dedupe))
125
+ return;
126
+ if (input.paused) {
127
+ throw new Error("Paused. Kiln will not post this review until kiln resume.");
128
+ }
129
+ if (input.spendBlocked) {
130
+ throw new Error("Daily cap reached. Kiln will not post this review until the local day rolls over.");
131
+ }
132
+ const draft = readReviewDraft(input.checkout, input.runId);
133
+ if (!draft) {
134
+ failPost(input, target, "deterministic", "The draft artifact is missing or unreadable.");
135
+ }
136
+ const review = buildCommentReview(draft);
137
+ const poster = input.host.github.postCommentReview ?? realGitHub.postCommentReview;
138
+ if (!poster) {
139
+ failPost(input, target, "deterministic", "gh cannot post a review from this host.");
140
+ }
141
+ let posted;
142
+ try {
143
+ posted = poster.call(input.host.github, {
144
+ checkout: input.checkout,
145
+ number: target.number,
146
+ commitId: target.sha,
147
+ body: review.body,
148
+ comments: review.comments,
149
+ });
150
+ }
151
+ catch (error) {
152
+ const detail = publicDetail(error);
153
+ failPost(input, target, classifyFailureText(detail), detail);
154
+ }
155
+ appendEvent(input.kilnDir, {
156
+ job_id: input.jobId,
157
+ run_id: input.runId,
158
+ event: "review.posted",
159
+ step: "approve",
160
+ properties: {
161
+ repo: target.repo,
162
+ number: target.number,
163
+ sha: target.sha,
164
+ review_id: posted.id,
165
+ url: posted.url,
166
+ findings: review.findings,
167
+ inline: review.inline,
168
+ skipped: review.skipped,
169
+ dedupe: target.dedupe,
170
+ once: `posted:${target.dedupe}`,
171
+ },
172
+ });
173
+ }
174
+ function failPost(input, target, failureClass, detail) {
175
+ const clipped = detail.trim() || "The review was not posted.";
176
+ appendEvent(input.kilnDir, {
177
+ job_id: input.jobId,
178
+ run_id: input.runId,
179
+ event: "review.failed",
180
+ step: "approve",
181
+ properties: {
182
+ repo: target.repo,
183
+ number: target.number,
184
+ sha: target.sha,
185
+ class: failureClass,
186
+ detail: clipped,
187
+ dedupe: target.dedupe,
188
+ },
189
+ });
190
+ recordNeedsHuman(input);
191
+ throw new Error(`Could not post the review (${failureClass}). ${clipped}`);
192
+ }
193
+ function recordNeedsHuman(input) {
194
+ appendEvent(input.kilnDir, {
195
+ job_id: input.jobId,
196
+ run_id: input.runId,
197
+ event: "needs-human",
198
+ step: "approve",
199
+ properties: { reason: NEEDS_HUMAN_REASON, once: `needs-human:${NEEDS_HUMAN_REASON}` },
200
+ });
201
+ }
202
+ function publicDetail(error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ const cleaned = redact(message).replace(/\s+/g, " ").trim();
205
+ if (cleaned.length > 500)
206
+ return `${cleaned.slice(0, 499)}…`;
207
+ return cleaned || "The review was not posted.";
208
+ }
209
+ function parseFinding(value) {
210
+ if (typeof value !== "object" || value === null)
211
+ return undefined;
212
+ const record = value;
213
+ if (typeof record.comment !== "string" || !record.comment.trim())
214
+ return undefined;
215
+ const severity = REVIEW_SEVERITIES.find((item) => item === record.severity) ?? "note";
216
+ const file = typeof record.file === "string" ? record.file : undefined;
217
+ const line = commentLine(record.line);
218
+ return {
219
+ severity,
220
+ comment: record.comment.trim(),
221
+ ...(file !== undefined ? { file } : {}),
222
+ ...(line !== undefined ? { line } : {}),
223
+ };
224
+ }
225
+ /** A relative path inside the repo. Absolute paths and `..` are not locations. A leading `./` is a normal relative path. */
226
+ export function commentPath(file) {
227
+ if (!file)
228
+ return undefined;
229
+ const raw = file.trim().replace(/\\/g, "/");
230
+ if (!raw || raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw))
231
+ return undefined;
232
+ if (/[\0\r\n]/.test(raw))
233
+ return undefined;
234
+ const parts = raw.split("/");
235
+ if (parts.some((part) => part === "" || part === ".."))
236
+ return undefined;
237
+ const cleaned = parts.filter((part) => part !== ".");
238
+ if (cleaned.length === 0)
239
+ return undefined;
240
+ return cleaned.join("/");
241
+ }
242
+ function commentLine(line) {
243
+ if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
244
+ return undefined;
245
+ return line;
246
+ }
247
+ function commentText(finding) {
248
+ const text = finding.comment.trim();
249
+ if (finding.severity === "nit")
250
+ return `**nit** (not blocking)\n\n${text}`;
251
+ return `**${finding.severity}**\n\n${text}`;
252
+ }
253
+ function foldSkipped(markdown, skipped) {
254
+ const base = markdown.trim();
255
+ const extra = skipped.filter((finding) => !base.includes(finding.comment.trim()));
256
+ if (extra.length === 0)
257
+ return `${base}\n`;
258
+ const lines = [
259
+ base,
260
+ "",
261
+ "## Not posted on a line",
262
+ "",
263
+ "These findings had no file and line Kiln could check, so they are only in this review.",
264
+ "",
265
+ ];
266
+ for (const finding of extra)
267
+ lines.push(`- **${finding.severity}** ${finding.comment.trim()}`);
268
+ return `${lines.join("\n")}\n`;
269
+ }
package/dist/schemas.js CHANGED
@@ -48,6 +48,11 @@ export const RepoEntrySchema = z.object({
48
48
  .int("Max fix rounds must be a whole number.")
49
49
  .min(1, "Max fix rounds must be at least 1.")
50
50
  .optional(),
51
+ /**
52
+ * GitHub label `review-requests` searches for in this checkout.
53
+ * Overrides the trigger `label`. Omitted uses that label, which defaults to `kiln-review`.
54
+ */
55
+ reviewLabel: z.string().min(1).optional(),
51
56
  });
52
57
  /** Repo map shared by the config file and the settings API. */
53
58
  export const KilnReposSchema = z.record(z.string().regex(REPO_NAME, "must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens"), RepoEntrySchema);
@@ -135,6 +140,10 @@ const TRIGGER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
135
140
  * `schedule` uses `cron` (5 fields, local time). `jira` uses `jql` or
136
141
  * `label` / `status` (compiled to JQL) and `every` (default `5m`).
137
142
  * `pr` is the built-in Kiln-owned pull request source.
143
+ * On `review-requests`, `label` is the GitHub label a pull request must
144
+ * carry (default `kiln-review`). `maxNew` is how many new review jobs one
145
+ * fire may create (default 3). `maxQueued` is how many review jobs may be
146
+ * queued or running at once (default 5).
138
147
  */
139
148
  export const TriggerConfigSchema = z
140
149
  .object({
@@ -147,6 +156,10 @@ export const TriggerConfigSchema = z
147
156
  jql: z.string().min(1).optional(),
148
157
  label: z.string().min(1).optional(),
149
158
  status: z.string().min(1).optional(),
159
+ /** Review jobs created on one fire of `review-requests`. Default 3. */
160
+ maxNew: z.number().int().min(1).optional(),
161
+ /** Queued or running review jobs at once. Default 5. */
162
+ maxQueued: z.number().int().min(1).optional(),
150
163
  })
151
164
  .strict();
152
165
  /** Maps a trigger id to create a job or attach to one. Hot-reloaded with the config. */
@@ -240,6 +253,11 @@ export const JobSchema = z.object({
240
253
  issue: z.string().regex(ISSUE_KEY).optional(),
241
254
  source: JobSourceSchema.optional(),
242
255
  createdAt: z.string().min(1),
256
+ /**
257
+ * Set when a person cancels the job before it starts.
258
+ * Tick does not launch it. Cancelling does not delete a branch.
259
+ */
260
+ cancelledAt: z.string().min(1).optional(),
243
261
  });
244
262
  /**
245
263
  * Derived from the yak journal, gate files, and (for `deliver=pr`) the
@@ -255,7 +273,8 @@ export const JobSchema = z.object({
255
273
  * after-merged dependent. A stacked job unblocks when its one upstream has
256
274
  * landed, which includes `in-review`. A job that has used its attempt cap,
257
275
  * fix-round cap, or fingerprint breaker is `needs-human`. `dead` is a run
258
- * whose process died and has not been recorded yet. Attempts live under
276
+ * whose process died and has not been recorded yet. `cancelled` is a job
277
+ * cancelled before it started. Attempts live under
259
278
  * the kiln directory, not in this file.
260
279
  */
261
280
  export const JOB_STATES = [
@@ -269,4 +288,5 @@ export const JOB_STATES = [
269
288
  "dead",
270
289
  "in-review",
271
290
  "done",
291
+ "cancelled",
272
292
  ];