@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/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 gates = state === "needs-human" && job.run ? job.run.gates.map(toGateView) : [];
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") && runId !== undefined;
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
- ...(state === "dead" && job.run
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
- lines.push(`launch ${launch.jobId} ${launch.repo}`);
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
- if (!actions.some((action) => action.type === "launch") &&
248
- launches.length === 0 &&
249
- delivers.length === 0 &&
250
- findings.length === 0) {
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",