@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/README.md +14 -3
- package/assets/serve.css +136 -11
- package/assets/serve.js +44 -5
- package/dist/attention.js +5 -1
- package/dist/board-view.js +4 -162
- package/dist/cli.js +51 -3
- package/dist/config.js +1 -0
- package/dist/driver.js +63 -9
- package/dist/events.js +36 -1
- package/dist/host.js +98 -3
- package/dist/job-view.js +755 -0
- package/dist/jobs.js +9 -0
- package/dist/markdown.js +91 -0
- package/dist/project.js +26 -1
- package/dist/reconcile.js +5 -2
- package/dist/report.js +8 -0
- package/dist/review-post.js +269 -0
- package/dist/schemas.js +21 -1
- package/dist/serve-page.js +278 -69
- package/dist/serve.js +46 -2
- package/dist/settings.js +4 -2
- package/dist/triggers.js +232 -23
- package/package.json +1 -1
- package/prompts/kiln/review.md +1 -1
- package/workflows/review.yaml +4 -4
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|