@lchase/kiln 0.1.0 → 0.4.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 { INVESTIGATION_WORKFLOW } from "./findings.js";
4
6
  import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
5
7
  export function shellQuote(value) {
6
8
  return `'${value.replaceAll("'", `'\\''`)}'`;
@@ -28,105 +30,294 @@ export function gateAnswerCommand(checkout, runId, gate, choice, title) {
28
30
  const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
29
31
  return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
30
32
  }
31
- export function renderStatus(observation, jobId) {
32
- const jobs = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
33
- if (jobId && jobs.length === 0) {
33
+ /**
34
+ * Mark a workflow's leaf steps from yak journal events.
35
+ * A later `step.started` reopens a step that already completed (the next loop round).
36
+ * Events whose id is not in the plan are ignored when the plan is non-empty,
37
+ * so a loop container does not sit beside its body. An empty plan uses
38
+ * first-seen event order.
39
+ */
40
+ export function stepProgress(plan, events) {
41
+ const order = [...plan];
42
+ const phase = new Map();
43
+ for (const id of order)
44
+ phase.set(id, "pending");
45
+ for (const event of events) {
46
+ if (!event.stepId)
47
+ continue;
48
+ if (!phase.has(event.stepId)) {
49
+ if (plan.length > 0)
50
+ continue;
51
+ order.push(event.stepId);
52
+ phase.set(event.stepId, "pending");
53
+ }
54
+ if (event.t === "step.started")
55
+ phase.set(event.stepId, "current");
56
+ else if (event.t === "step.completed")
57
+ phase.set(event.stepId, "done");
58
+ else if (event.t === "step.failed")
59
+ phase.set(event.stepId, "failed");
60
+ }
61
+ return order.map((id) => ({ id, phase: phase.get(id) ?? "pending" }));
62
+ }
63
+ /** Detail of the latest step that failed and has not started or completed since. */
64
+ export function latestStepFailure(events) {
65
+ const settled = new Set();
66
+ for (let index = events.length - 1; index >= 0; index -= 1) {
67
+ const event = events[index];
68
+ if (!event)
69
+ continue;
70
+ if (event.t === "step.started" || event.t === "step.completed") {
71
+ settled.add(event.stepId);
72
+ continue;
73
+ }
74
+ if (event.t === "step.failed" && !settled.has(event.stepId) && event.detail)
75
+ return event.detail;
76
+ }
77
+ return undefined;
78
+ }
79
+ /**
80
+ * Derived state for every job, or for one id.
81
+ * Throws `No job <id>` when that id is not on disk, same as `kiln status`.
82
+ */
83
+ export function statusView(observation, jobId) {
84
+ const selected = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
85
+ if (jobId && selected.length === 0) {
34
86
  throw new Error(`No job ${jobId}.`);
35
87
  }
36
- if (jobs.length === 0)
88
+ return { jobs: selected.map((job) => toStatusJob(job, observation)) };
89
+ }
90
+ export function renderStatus(view) {
91
+ if (view.jobs.length === 0)
37
92
  return "no jobs\n";
38
93
  const lines = [];
39
- for (const job of jobs) {
40
- if (job.error) {
41
- lines.push(`${job.jobId} invalid: ${job.error}`);
94
+ for (const job of view.jobs) {
95
+ lines.push(statusHeader(job));
96
+ if (job.state === "invalid")
42
97
  continue;
98
+ for (const detail of statusDetails(job))
99
+ lines.push(` ${detail}`);
100
+ }
101
+ return `${lines.join("\n")}\n`;
102
+ }
103
+ /** Extra lines `kiln status` prints under the job header, without the indent. */
104
+ export function statusDetails(job) {
105
+ const lines = [];
106
+ if (job.source)
107
+ lines.push(`source ${job.source.kind} ${job.source.ref}`);
108
+ if (job.findingsPath)
109
+ lines.push(`findings ${job.findingsPath}`);
110
+ else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
111
+ lines.push("findings missing");
112
+ if (job.prPending)
113
+ lines.push("pull request not opened yet");
114
+ if (job.state === "failed" && job.deliveryError)
115
+ lines.push(job.deliveryError);
116
+ if (job.state === "failed" && job.prClosedUnmerged && job.prNumber) {
117
+ lines.push(`pull request #${job.prNumber} was closed without merging`);
118
+ }
119
+ if (job.viewError && (job.state === "in-review" || job.state === "done")) {
120
+ lines.push(`could not read pull request: ${job.viewError}`);
121
+ }
122
+ if (job.state === "failed" && job.launchFailed && job.logPath)
123
+ lines.push(`log ${job.logPath}`);
124
+ if (job.state === "failed" && job.launchError)
125
+ lines.push(job.launchError);
126
+ if (job.ledgerError)
127
+ lines.push(job.ledgerError);
128
+ if (job.attemptCount > 0)
129
+ lines.push(`attempts ${job.attemptCount}`);
130
+ if (job.lastFailureReason)
131
+ lines.push(`reason ${job.lastFailureReason}`);
132
+ if (job.nextRetryAt)
133
+ lines.push(`next retry ${job.nextRetryAt}`);
134
+ if (job.state === "dead" && job.attemptCount > 0)
135
+ lines.push(`retry: kiln retry ${job.jobId}`);
136
+ if (job.noReadableApproval)
137
+ lines.push("no readable approval decision");
138
+ if (job.cancelCommand)
139
+ lines.push(job.cancelCommand);
140
+ if (job.state === "blocked") {
141
+ for (const dep of job.dependsOn) {
142
+ if (dep.blockedDetail)
143
+ lines.push(dep.blockedDetail);
43
144
  }
44
- const state = jobState(job, observation);
45
- const run = job.run ? ` run ${job.run.runId}` : "";
46
- const branch = job.run && (state === "done" || state === "in-review") ? ` branch yak/${job.run.runId}` : "";
47
- const pr = prSuffix(job, state);
48
- lines.push(`${job.jobId} ${job.repo} ${job.workflow} ${state}${run}${branch}${pr}`);
49
- if (job.source)
50
- lines.push(` source ${job.source.kind} ${job.source.ref}`);
51
- if (state === "in-review" && !job.delivery?.prNumber) {
52
- lines.push(" pull request not opened yet");
53
- }
54
- if (state === "failed" && job.delivery?.error) {
55
- lines.push(` ${job.delivery.error}`);
56
- }
57
- if (state === "failed" && job.delivery?.prState === "CLOSED" && job.delivery.prNumber) {
58
- lines.push(` pull request #${job.delivery.prNumber} was closed without merging`);
59
- }
60
- if (job.delivery?.viewError && (state === "in-review" || state === "done")) {
61
- lines.push(` could not read pull request: ${job.delivery.viewError}`);
62
- }
63
- if (state === "failed" && job.launchFailed && job.logPath) {
64
- lines.push(` log ${job.logPath}`);
65
- }
66
- if (state === "failed" && job.launchError) {
67
- lines.push(` ${job.launchError}`);
68
- }
69
- if (state === "failed" && job.run?.status === "ok" && !job.run.gateDecision) {
70
- lines.push(" no readable approval decision");
71
- }
72
- if (state === "dead" && job.run) {
73
- lines.push(` fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}`);
74
- }
75
- if (state === "blocked") {
76
- for (const line of blockedLines(job, observation))
77
- lines.push(line);
78
- }
79
- if (state === "needs-human" && job.run) {
80
- for (const gate of job.run.gates) {
81
- lines.push(` gate ${gate.stepId}: ${gate.prompt}`);
82
- appendGateCommands(lines, job, gate);
83
- }
145
+ }
146
+ if (job.state === "needs-human") {
147
+ for (const gate of job.gates) {
148
+ lines.push(`gate ${gate.stepId}: ${gate.prompt}`);
149
+ if (gate.choices.includes("approve"))
150
+ lines.push(`approve: kiln approve ${job.jobId}`);
151
+ if (gate.choices.includes("reject"))
152
+ lines.push(`reject: kiln reject ${job.jobId}`);
84
153
  }
85
154
  }
86
- return `${lines.join("\n")}\n`;
155
+ return lines;
87
156
  }
88
- function prSuffix(job, state) {
89
- const url = job.delivery?.prUrl;
90
- if (!url)
91
- return "";
92
- if (state !== "in-review" && state !== "done" && state !== "failed")
93
- return "";
94
- return ` pr ${url}`;
157
+ function statusHeader(job) {
158
+ if (job.state === "invalid")
159
+ return `${job.jobId} invalid: ${job.invalidReason ?? "invalid"}`;
160
+ const run = job.runId ? ` run ${job.runId}` : "";
161
+ const branch = job.branch ? ` branch ${job.branch}` : "";
162
+ const pr = job.prUrl && (job.state === "in-review" || job.state === "done" || job.state === "failed")
163
+ ? ` pr ${job.prUrl}`
164
+ : "";
165
+ return `${job.jobId} ${job.repo} ${job.workflow} ${job.state}${run}${branch}${pr}`;
95
166
  }
96
- function blockedLines(job, observation) {
97
- const lines = [];
167
+ function toStatusJob(job, observation) {
168
+ if (job.error) {
169
+ return {
170
+ jobId: job.jobId,
171
+ title: job.title,
172
+ repo: job.repo,
173
+ workflow: job.workflow,
174
+ state: "invalid",
175
+ dependsOn: dependencyViews(job, observation),
176
+ stack: job.stack === true,
177
+ ...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
178
+ ...(job.specPath ? { specPath: job.specPath } : {}),
179
+ ...(job.logPath ? { logPath: job.logPath } : {}),
180
+ ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
181
+ launchFailed: job.launchFailed,
182
+ attemptCount: job.attemptCount ?? 0,
183
+ maxAttempts: observation.maxAttempts,
184
+ ...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
185
+ ...(job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
186
+ invalidReason: job.error,
187
+ prPending: false,
188
+ prClosedUnmerged: false,
189
+ noReadableApproval: false,
190
+ gates: [],
191
+ canApprove: false,
192
+ canReject: false,
193
+ steps: progressFor(job),
194
+ ...(job.createdAt ? { createdAt: job.createdAt } : {}),
195
+ ...(job.logTail ? { logTail: job.logTail } : {}),
196
+ ...boardFields(job),
197
+ };
198
+ }
199
+ const state = jobState(job, observation);
200
+ const gates = state === "needs-human" && job.run ? job.run.gates.map(toGateView) : [];
201
+ const prNumber = job.delivery?.prNumber;
202
+ const runId = job.run?.runId;
203
+ const showBranch = (state === "done" || state === "in-review") && runId !== undefined;
204
+ const failureDetail = job.run?.stepEvents ? latestStepFailure(job.run.stepEvents) : undefined;
205
+ return {
206
+ jobId: job.jobId,
207
+ title: job.title,
208
+ repo: job.repo,
209
+ workflow: job.workflow,
210
+ state,
211
+ dependsOn: dependencyViews(job, observation),
212
+ stack: job.stack === true,
213
+ ...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
214
+ ...(runId ? { runId } : {}),
215
+ ...(showBranch ? { branch: `yak/${runId}` } : {}),
216
+ ...(job.delivery?.prUrl ? { prUrl: job.delivery.prUrl } : {}),
217
+ ...(prNumber ? { prNumber } : {}),
218
+ ...(job.logPath ? { logPath: job.logPath } : {}),
219
+ ...(job.specPath ? { specPath: job.specPath } : {}),
220
+ ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
221
+ launchFailed: job.launchFailed,
222
+ attemptCount: job.attemptCount ?? 0,
223
+ maxAttempts: observation.maxAttempts,
224
+ ...(job.lastFailureReason ? { lastFailureReason: job.lastFailureReason } : {}),
225
+ ...(job.nextRetryAt && !job.retriesExhausted ? { nextRetryAt: job.nextRetryAt } : {}),
226
+ ...(job.ledgerError ? { ledgerError: job.ledgerError } : {}),
227
+ ...(job.launchError ? { launchError: job.launchError } : {}),
228
+ ...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
229
+ ...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
230
+ prPending: state === "in-review" && !prNumber,
231
+ prClosedUnmerged: job.delivery?.prState === "CLOSED",
232
+ noReadableApproval: state === "failed" && job.run?.status === "ok" && !job.run.gateDecision,
233
+ ...(job.run && (job.run.status === "dead" || state === "dead")
234
+ ? { cancelCommand: `fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}` }
235
+ : {}),
236
+ gates,
237
+ canApprove: gates.some((gate) => gate.choices.includes("approve")),
238
+ canReject: gates.some((gate) => gate.choices.includes("reject")),
239
+ steps: progressFor(job),
240
+ ...(job.createdAt ? { createdAt: job.createdAt } : {}),
241
+ ...(job.run?.startedAt ? { startedAt: job.run.startedAt } : {}),
242
+ ...(job.run?.finishedAt ? { finishedAt: job.run.finishedAt } : {}),
243
+ ...(job.logTail ? { logTail: job.logTail } : {}),
244
+ ...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
245
+ ...(failureDetail ? { failureDetail } : {}),
246
+ ...boardFields(job),
247
+ };
248
+ }
249
+ function boardFields(job) {
250
+ const runDir = job.run ? join(job.run.checkout, ".runs", job.run.runId) : undefined;
251
+ const rounds = readReviewRounds(runDir);
252
+ const files = readBoardFiles({
253
+ ...(runDir ? { runDir } : {}),
254
+ ...(job.specPath ? { specPath: job.specPath } : {}),
255
+ ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
256
+ ...(job.workflow !== INVESTIGATION_WORKFLOW && job.diffSummary ? { diffSummary: job.diffSummary } : {}),
257
+ ...(job.logTail ? { logTail: job.logTail } : {}),
258
+ ...(job.workflow ? { workflow: job.workflow } : {}),
259
+ gates: job.run?.gates ?? [],
260
+ });
261
+ return {
262
+ ...(rounds.length > 0 ? { rounds } : {}),
263
+ ...(files.length > 0 ? { files } : {}),
264
+ };
265
+ }
266
+ function progressFor(job) {
267
+ const plan = job.run?.stepPlan?.length ? job.run.stepPlan : (job.stepPlan ?? []);
268
+ return stepProgress(plan, job.run?.stepEvents ?? []);
269
+ }
270
+ function toGateView(gate) {
271
+ const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
272
+ return {
273
+ stepId: gate.stepId,
274
+ prompt: gate.prompt,
275
+ ...(gate.rendered ? { rendered: gate.rendered } : {}),
276
+ choices,
277
+ artifacts: gate.artifacts ?? [],
278
+ reviews: gate.reviews ?? [],
279
+ };
280
+ }
281
+ function dependencyViews(job, observation) {
282
+ const reasons = blockReasons(job, observation);
283
+ return job.dependsOn.map((dep) => {
284
+ const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
285
+ const state = !upstream ? "missing" : upstream.error ? "invalid" : jobState(upstream, observation);
286
+ const blockedDetail = reasons.find((reason) => reason.dep === dep)?.detail;
287
+ return {
288
+ jobId: dep,
289
+ state,
290
+ ...(blockedDetail ? { blockedDetail } : {}),
291
+ };
292
+ });
293
+ }
294
+ function blockReasons(job, observation) {
295
+ const reasons = [];
98
296
  for (const dep of job.dependsOn) {
99
297
  const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
100
298
  if (!upstream) {
101
- lines.push(` blocked on missing job ${dep}`);
299
+ reasons.push({ dep, detail: `blocked on missing job ${dep}` });
102
300
  continue;
103
301
  }
104
302
  if (job.stack) {
105
303
  if (!isLanded(upstream))
106
- lines.push(` waiting for ${dep} to land`);
304
+ reasons.push({ dep, detail: `waiting for ${dep} to land` });
107
305
  continue;
108
306
  }
109
307
  if (isDone(upstream))
110
308
  continue;
111
309
  const number = upstream.delivery?.prNumber;
112
310
  if (number && !upstream.delivery?.merged && upstream.delivery?.prState !== "CLOSED") {
113
- lines.push(` waiting for PR #${number} to merge`);
311
+ reasons.push({ dep, detail: `waiting for PR #${number} to merge` });
114
312
  continue;
115
313
  }
116
314
  if ((upstream.deliver ?? "local") === "pr" && isLanded(upstream)) {
117
- lines.push(` waiting for ${dep} to open a pull request`);
315
+ reasons.push({ dep, detail: `waiting for ${dep} to open a pull request` });
118
316
  }
119
317
  }
120
- return lines;
121
- }
122
- function appendGateCommands(lines, job, gate) {
123
- const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
124
- if (choices.includes("approve"))
125
- lines.push(` approve: kiln approve ${job.jobId}`);
126
- if (choices.includes("reject"))
127
- lines.push(` reject: kiln reject ${job.jobId}`);
318
+ return reasons;
128
319
  }
129
- export function renderTick(observation, actions, launches, delivers = []) {
320
+ export function renderTick(observation, actions, launches, delivers = [], findings = [], notes = []) {
130
321
  const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
131
322
  for (const job of observation.jobs) {
132
323
  if (job.error)
@@ -141,16 +332,26 @@ export function renderTick(observation, actions, launches, delivers = []) {
141
332
  ? `deliver ${deliver.jobId} ${deliver.repo} ${deliver.detail}`
142
333
  : `deliver ${deliver.jobId} ${deliver.repo} failed: ${deliver.detail}`);
143
334
  }
335
+ for (const recorded of findings) {
336
+ lines.push(recorded.status === "wrote"
337
+ ? `findings ${recorded.jobId} ${recorded.path}`
338
+ : `findings ${recorded.jobId} missing`);
339
+ }
340
+ for (const note of notes) {
341
+ const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
342
+ lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
343
+ }
144
344
  for (const launch of launches) {
145
- lines.push(`launch ${launch.jobId} ${launch.repo}`);
345
+ lines.push(`${launch.retry ? "retry" : "launch"} ${launch.jobId} ${launch.repo}`);
146
346
  for (const line of launch.output.split("\n")) {
147
347
  if (line.trim())
148
348
  lines.push(` ${line}`);
149
349
  }
150
350
  }
151
- if (!actions.some((action) => action.type === "launch") &&
351
+ if (!actions.some((action) => action.type === "launch" || action.type === "retry") &&
152
352
  launches.length === 0 &&
153
- delivers.length === 0) {
353
+ delivers.length === 0 &&
354
+ findings.length === 0) {
154
355
  lines.push("nothing to launch");
155
356
  }
156
357
  return `${lines.join("\n")}\n`;
package/dist/schemas.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  /**
3
3
  * Kiln config file. `repos` is an open map: any name, not a fixed set.
4
- * `path` is absolute, relative to the config file's directory, or `~`.
4
+ * `path` is absolute, `~`, or (in older files) relative to the config directory.
5
+ * `repo add` stores a relative path as absolute, resolved from the working directory.
5
6
  * `test` is the shell command a workflow runs as its verify step (`KILN_TEST`).
6
7
  */
7
8
  /**
@@ -25,9 +26,20 @@ export const JiraConfigSchema = z.object({
25
26
  email: z.string().optional(),
26
27
  apiToken: z.string().optional(),
27
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;
28
33
  export const KilnConfigFileSchema = z.object({
29
34
  note: z.string().min(1).optional(),
30
35
  maxConcurrent: z.number().int().min(1).optional(),
36
+ /**
37
+ * Milliseconds without a yak event or log output before a running job is
38
+ * killed and retried. Default 15 minutes. `0` or negative disables stall detection.
39
+ */
40
+ stallTimeoutMs: z.number().int().optional(),
41
+ /** Transient failures before the job is dead. Default 3. */
42
+ maxAttempts: z.number().int().min(1).optional(),
31
43
  adapter: z.enum(["mock", "claude-code"]).optional(),
32
44
  repos: z.record(z.string(), RepoEntrySchema),
33
45
  jira: JiraConfigSchema.optional(),
@@ -59,6 +71,8 @@ export const JobSchema = z.object({
59
71
  * When true, this job starts once its single upstream has landed, on that
60
72
  * upstream's branch. Omitted means after-merged: wait until every upstream
61
73
  * is `done`. More than one `dependsOn` with `stack` is rejected at add time.
74
+ * Stacking onto an investigation is rejected: that workflow does not land
75
+ * a branch.
62
76
  */
63
77
  stack: z.boolean().optional(),
64
78
  source: JobSourceSchema.optional(),
@@ -71,10 +85,14 @@ export const JobSchema = z.object({
71
85
  * For `deliver=local`, `done` is a run that finished ok with an approve
72
86
  * decision. For `deliver=pr`, `done` is that landed run whose pull request
73
87
  * has merged (`state` or `mergedAt`, so a squash merge counts). `in-review`
74
- * is the landed run while the pull request is still open. A reject, a
75
- * failure, a dead process, and an ok run with no readable approval do not
76
- * unblock an after-merged dependent. A stacked job unblocks when its one
77
- * upstream has landed, which includes `in-review`.
88
+ * is the landed run while the pull request is still open. An investigation
89
+ * is `done` when the gate says approve, including when the repo is
90
+ * `deliver=pr`. It does not enter `in-review`. A reject, a failure, a dead
91
+ * process, and an ok run with no readable approval do not unblock an
92
+ * after-merged dependent. A stacked job unblocks when its one upstream has
93
+ * landed, which includes `in-review`. `dead` is also a job whose transient
94
+ * failures have used `maxAttempts`. Those attempts live under the kiln
95
+ * directory, not in this file.
78
96
  */
79
97
  export const JOB_STATES = [
80
98
  "queued",