@lchase/kiln 0.3.0 → 0.5.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 +15 -9
- package/assets/serve.css +404 -185
- package/assets/serve.js +361 -0
- package/dist/attention.js +264 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +34 -6
- package/dist/config.js +4 -1
- package/dist/driver.js +717 -67
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +88 -21
- package/dist/reliability.js +441 -0
- package/dist/report.js +153 -10
- package/dist/schemas.js +21 -1
- package/dist/serve-page.js +301 -159
- package/dist/serve.js +36 -14
- package/dist/workflow.js +69 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/fix.yaml +44 -0
package/dist/report.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { readBoardFiles, readReviewRounds } from "./board.js";
|
|
5
|
+
import { feedbackRoundText } from "./feedback.js";
|
|
4
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
5
7
|
import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
|
|
6
8
|
export function shellQuote(value) {
|
|
@@ -29,6 +31,52 @@ export function gateAnswerCommand(checkout, runId, gate, choice, title) {
|
|
|
29
31
|
const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
|
|
30
32
|
return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Mark a workflow's leaf steps from yak journal events.
|
|
36
|
+
* A later `step.started` reopens a step that already completed (the next loop round).
|
|
37
|
+
* Events whose id is not in the plan are ignored when the plan is non-empty,
|
|
38
|
+
* so a loop container does not sit beside its body. An empty plan uses
|
|
39
|
+
* first-seen event order.
|
|
40
|
+
*/
|
|
41
|
+
export function stepProgress(plan, events) {
|
|
42
|
+
const order = [...plan];
|
|
43
|
+
const phase = new Map();
|
|
44
|
+
for (const id of order)
|
|
45
|
+
phase.set(id, "pending");
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (!event.stepId)
|
|
48
|
+
continue;
|
|
49
|
+
if (!phase.has(event.stepId)) {
|
|
50
|
+
if (plan.length > 0)
|
|
51
|
+
continue;
|
|
52
|
+
order.push(event.stepId);
|
|
53
|
+
phase.set(event.stepId, "pending");
|
|
54
|
+
}
|
|
55
|
+
if (event.t === "step.started")
|
|
56
|
+
phase.set(event.stepId, "current");
|
|
57
|
+
else if (event.t === "step.completed")
|
|
58
|
+
phase.set(event.stepId, "done");
|
|
59
|
+
else if (event.t === "step.failed")
|
|
60
|
+
phase.set(event.stepId, "failed");
|
|
61
|
+
}
|
|
62
|
+
return order.map((id) => ({ id, phase: phase.get(id) ?? "pending" }));
|
|
63
|
+
}
|
|
64
|
+
/** Detail of the latest step that failed and has not started or completed since. */
|
|
65
|
+
export function latestStepFailure(events) {
|
|
66
|
+
const settled = new Set();
|
|
67
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
68
|
+
const event = events[index];
|
|
69
|
+
if (!event)
|
|
70
|
+
continue;
|
|
71
|
+
if (event.t === "step.started" || event.t === "step.completed") {
|
|
72
|
+
settled.add(event.stepId);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (event.t === "step.failed" && !settled.has(event.stepId) && event.detail)
|
|
76
|
+
return event.detail;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
32
80
|
/**
|
|
33
81
|
* Derived state for every job, or for one id.
|
|
34
82
|
* Throws `No job <id>` when that id is not on disk, same as `kiln status`.
|
|
@@ -76,6 +124,22 @@ export function statusDetails(job) {
|
|
|
76
124
|
lines.push(`log ${job.logPath}`);
|
|
77
125
|
if (job.state === "failed" && job.launchError)
|
|
78
126
|
lines.push(job.launchError);
|
|
127
|
+
if (job.ledgerError)
|
|
128
|
+
lines.push(job.ledgerError);
|
|
129
|
+
if (job.feedbackLedgerError)
|
|
130
|
+
lines.push(job.feedbackLedgerError);
|
|
131
|
+
if ((job.feedbackRounds?.length ?? 0) > 0)
|
|
132
|
+
lines.push(`feedback ${job.feedbackRounds?.length}`);
|
|
133
|
+
if (job.state === "needs-human" && job.feedbackReason)
|
|
134
|
+
lines.push(`reason ${job.feedbackReason}`);
|
|
135
|
+
if (job.attemptCount > 0)
|
|
136
|
+
lines.push(`attempts ${job.attemptCount}`);
|
|
137
|
+
if (job.lastFailureReason)
|
|
138
|
+
lines.push(`reason ${job.lastFailureReason}`);
|
|
139
|
+
if (job.nextRetryAt)
|
|
140
|
+
lines.push(`next retry ${job.nextRetryAt}`);
|
|
141
|
+
if (job.state === "dead" && job.attemptCount > 0)
|
|
142
|
+
lines.push(`retry: kiln retry ${job.jobId}`);
|
|
79
143
|
if (job.noReadableApproval)
|
|
80
144
|
lines.push("no readable approval decision");
|
|
81
145
|
if (job.cancelCommand)
|
|
@@ -122,6 +186,10 @@ function toStatusJob(job, observation) {
|
|
|
122
186
|
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
123
187
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
124
188
|
launchFailed: job.launchFailed,
|
|
189
|
+
attemptCount: job.attemptCount ?? 0,
|
|
190
|
+
maxAttempts: observation.maxAttempts,
|
|
191
|
+
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
192
|
+
...(job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
125
193
|
invalidReason: job.error,
|
|
126
194
|
prPending: false,
|
|
127
195
|
prClosedUnmerged: false,
|
|
@@ -129,13 +197,23 @@ function toStatusJob(job, observation) {
|
|
|
129
197
|
gates: [],
|
|
130
198
|
canApprove: false,
|
|
131
199
|
canReject: false,
|
|
200
|
+
steps: progressFor(job),
|
|
201
|
+
...(job.createdAt ? { createdAt: job.createdAt } : {}),
|
|
202
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
203
|
+
...boardFields(job),
|
|
132
204
|
};
|
|
133
205
|
}
|
|
134
206
|
const state = jobState(job, observation);
|
|
135
|
-
const
|
|
207
|
+
const feedbackReason = job.feedbackExhausted
|
|
208
|
+
? "feedback-exhausted"
|
|
209
|
+
: job.feedbackAmbiguous
|
|
210
|
+
? "feedback-ambiguous"
|
|
211
|
+
: undefined;
|
|
212
|
+
const gates = state === "needs-human" && job.run && !feedbackReason ? job.run.gates.map(toGateView) : [];
|
|
136
213
|
const prNumber = job.delivery?.prNumber;
|
|
137
214
|
const runId = job.run?.runId;
|
|
138
|
-
const showBranch = (state === "done" || state === "in-review"
|
|
215
|
+
const showBranch = runId !== undefined && (state === "done" || state === "in-review" || feedbackReason !== undefined);
|
|
216
|
+
const failureDetail = job.run?.stepEvents ? latestStepFailure(job.run.stepEvents) : undefined;
|
|
139
217
|
return {
|
|
140
218
|
jobId: job.jobId,
|
|
141
219
|
title: job.title,
|
|
@@ -153,20 +231,73 @@ function toStatusJob(job, observation) {
|
|
|
153
231
|
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
154
232
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
155
233
|
launchFailed: job.launchFailed,
|
|
234
|
+
attemptCount: job.attemptCount ?? 0,
|
|
235
|
+
maxAttempts: observation.maxAttempts,
|
|
236
|
+
...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
|
|
237
|
+
...(job.nextRetryAt && !job.retriesExhausted ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
238
|
+
...(job.ledgerError ? { ledgerError: job.ledgerError } : {}),
|
|
239
|
+
...(job.feedbackLedgerError ? { feedbackLedgerError: job.feedbackLedgerError } : {}),
|
|
240
|
+
...(feedbackReason ? { feedbackReason } : {}),
|
|
156
241
|
...(job.launchError ? { launchError: job.launchError } : {}),
|
|
157
242
|
...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
|
|
158
243
|
...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
|
|
159
244
|
prPending: state === "in-review" && !prNumber,
|
|
160
245
|
prClosedUnmerged: job.delivery?.prState === "CLOSED",
|
|
161
246
|
noReadableApproval: state === "failed" && job.run?.status === "ok" && !job.run.gateDecision,
|
|
162
|
-
...(
|
|
247
|
+
...(job.run && (job.run.status === "dead" || state === "dead")
|
|
163
248
|
? { cancelCommand: `fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}` }
|
|
164
249
|
: {}),
|
|
165
250
|
gates,
|
|
166
251
|
canApprove: gates.some((gate) => gate.choices.includes("approve")),
|
|
167
252
|
canReject: gates.some((gate) => gate.choices.includes("reject")),
|
|
253
|
+
steps: progressFor(job),
|
|
254
|
+
...(job.createdAt ? { createdAt: job.createdAt } : {}),
|
|
255
|
+
...(job.run?.startedAt ? { startedAt: job.run.startedAt } : {}),
|
|
256
|
+
...(job.run?.finishedAt ? { finishedAt: job.run.finishedAt } : {}),
|
|
257
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
258
|
+
...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
259
|
+
...(failureDetail ? { failureDetail } : {}),
|
|
260
|
+
...boardFields(job),
|
|
168
261
|
};
|
|
169
262
|
}
|
|
263
|
+
function boardFields(job) {
|
|
264
|
+
const runDir = job.run ? join(job.run.checkout, ".runs", job.run.runId) : undefined;
|
|
265
|
+
const rounds = readReviewRounds(runDir);
|
|
266
|
+
const files = readBoardFiles({
|
|
267
|
+
...(runDir ? { runDir } : {}),
|
|
268
|
+
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
269
|
+
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
270
|
+
...(job.workflow !== INVESTIGATION_WORKFLOW && job.diffSummary ? { diffSummary: job.diffSummary } : {}),
|
|
271
|
+
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
272
|
+
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
273
|
+
gates: job.run?.gates ?? [],
|
|
274
|
+
});
|
|
275
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
276
|
+
const label = round.trigger === "ci" ? "CI" : "review";
|
|
277
|
+
files.push({
|
|
278
|
+
id: `feedback-r${round.round}`,
|
|
279
|
+
group: "Feedback",
|
|
280
|
+
label: `R${round.round} ${label}.md`,
|
|
281
|
+
text: feedbackRoundText(round),
|
|
282
|
+
tone: round.outcome ? "good" : "warn",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
...(rounds.length > 0 ? { rounds } : {}),
|
|
287
|
+
...(job.feedbackRounds?.length ? { feedbackRounds: job.feedbackRounds } : {}),
|
|
288
|
+
...(files.length > 0 ? { files } : {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function progressFor(job) {
|
|
292
|
+
if (job.fixInProgress && !job.run) {
|
|
293
|
+
return [
|
|
294
|
+
{ id: "build", phase: "current" },
|
|
295
|
+
{ id: "land", phase: "pending" },
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
const plan = job.run?.stepPlan?.length ? job.run.stepPlan : (job.stepPlan ?? []);
|
|
299
|
+
return stepProgress(plan, job.run?.stepEvents ?? []);
|
|
300
|
+
}
|
|
170
301
|
function toGateView(gate) {
|
|
171
302
|
const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
|
|
172
303
|
return {
|
|
@@ -217,7 +348,7 @@ function blockReasons(job, observation) {
|
|
|
217
348
|
}
|
|
218
349
|
return reasons;
|
|
219
350
|
}
|
|
220
|
-
export function renderTick(observation, actions, launches, delivers = [], findings = []) {
|
|
351
|
+
export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = [], feedbackNotes = []) {
|
|
221
352
|
const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
|
|
222
353
|
for (const job of observation.jobs) {
|
|
223
354
|
if (job.error)
|
|
@@ -237,18 +368,30 @@ export function renderTick(observation, actions, launches, delivers = [], findin
|
|
|
237
368
|
? `findings ${recorded.jobId} ${recorded.path}`
|
|
238
369
|
: `findings ${recorded.jobId} missing`);
|
|
239
370
|
}
|
|
371
|
+
for (const note of notes) {
|
|
372
|
+
const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
|
|
373
|
+
lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
|
|
374
|
+
}
|
|
375
|
+
for (const note of feedbackNotes) {
|
|
376
|
+
if (note.trim())
|
|
377
|
+
lines.push(note);
|
|
378
|
+
}
|
|
240
379
|
for (const launch of launches) {
|
|
241
|
-
|
|
380
|
+
const verb = launch.kind === "fix" ? "fix" : launch.retry ? "retry" : "launch";
|
|
381
|
+
lines.push(`${verb} ${launch.jobId} ${launch.repo}`);
|
|
242
382
|
for (const line of launch.output.split("\n")) {
|
|
243
383
|
if (line.trim())
|
|
244
384
|
lines.push(` ${line}`);
|
|
245
385
|
}
|
|
246
386
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
387
|
+
const acted = actions.some((action) => action.type === "launch" ||
|
|
388
|
+
action.type === "retry" ||
|
|
389
|
+
action.type === "start-fix" ||
|
|
390
|
+
action.type === "mark-feedback") ||
|
|
391
|
+
launches.length > 0 ||
|
|
392
|
+
delivers.length > 0 ||
|
|
393
|
+
findings.length > 0;
|
|
394
|
+
if (!acted)
|
|
251
395
|
lines.push("nothing to launch");
|
|
252
|
-
}
|
|
253
396
|
return `${lines.join("\n")}\n`;
|
|
254
397
|
}
|
package/dist/schemas.js
CHANGED
|
@@ -26,9 +26,27 @@ export const JiraConfigSchema = z.object({
|
|
|
26
26
|
email: z.string().optional(),
|
|
27
27
|
apiToken: z.string().optional(),
|
|
28
28
|
});
|
|
29
|
+
/** No yak event and no log output for this long stalls a running job. `<= 0` disables it. */
|
|
30
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
31
|
+
/** Transient failures retry until this many attempts, then the job is dead. */
|
|
32
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
33
|
+
/** Pull request fix rounds before the job is needs-human (`feedback-exhausted`). */
|
|
34
|
+
export const DEFAULT_MAX_FIX_ROUNDS = 2;
|
|
29
35
|
export const KilnConfigFileSchema = z.object({
|
|
30
36
|
note: z.string().min(1).optional(),
|
|
31
37
|
maxConcurrent: z.number().int().min(1).optional(),
|
|
38
|
+
/**
|
|
39
|
+
* Milliseconds without a yak event or log output before a running job is
|
|
40
|
+
* killed and retried. Default 15 minutes. `0` or negative disables stall detection.
|
|
41
|
+
*/
|
|
42
|
+
stallTimeoutMs: z.number().int().optional(),
|
|
43
|
+
/** Transient failures before the job is dead. Default 3. */
|
|
44
|
+
maxAttempts: z.number().int().min(1).optional(),
|
|
45
|
+
/**
|
|
46
|
+
* Fix rounds for an open pull request before the job is needs-human with
|
|
47
|
+
* reason `feedback-exhausted`. Default 2. Kiln does not merge.
|
|
48
|
+
*/
|
|
49
|
+
maxFixRounds: z.number().int().min(1).optional(),
|
|
32
50
|
adapter: z.enum(["mock", "claude-code"]).optional(),
|
|
33
51
|
repos: z.record(z.string(), RepoEntrySchema),
|
|
34
52
|
jira: JiraConfigSchema.optional(),
|
|
@@ -79,7 +97,9 @@ export const JobSchema = z.object({
|
|
|
79
97
|
* `deliver=pr`. It does not enter `in-review`. A reject, a failure, a dead
|
|
80
98
|
* process, and an ok run with no readable approval do not unblock an
|
|
81
99
|
* after-merged dependent. A stacked job unblocks when its one upstream has
|
|
82
|
-
* landed, which includes `in-review`.
|
|
100
|
+
* landed, which includes `in-review`. `dead` is also a job whose transient
|
|
101
|
+
* failures have used `maxAttempts`. Those attempts live under the kiln
|
|
102
|
+
* directory, not in this file.
|
|
83
103
|
*/
|
|
84
104
|
export const JOB_STATES = [
|
|
85
105
|
"queued",
|