@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/README.md +57 -17
- package/assets/serve.css +452 -0
- package/assets/serve.js +361 -0
- package/dist/attention.js +254 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +105 -6
- package/dist/config.js +48 -28
- package/dist/driver.js +499 -34
- package/dist/findings.js +79 -0
- package/dist/intake/jira.js +4 -1
- package/dist/intake/tickets.js +23 -18
- package/dist/jobs.js +6 -1
- package/dist/reconcile.js +78 -21
- package/dist/reliability.js +441 -0
- package/dist/report.js +274 -73
- package/dist/schemas.js +23 -5
- package/dist/serve-page.js +352 -0
- package/dist/serve.js +373 -0
- package/dist/workflow.js +95 -10
- package/package.json +2 -1
- package/prompts/kiln/investigation.md +31 -0
- package/workflows/investigation.yaml +67 -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 { 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
155
|
+
return lines;
|
|
87
156
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
+
reasons.push({ dep, detail: `waiting for ${dep} to open a pull request` });
|
|
118
316
|
}
|
|
119
317
|
}
|
|
120
|
-
return
|
|
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(
|
|
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
|
|
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.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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",
|