@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/driver.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { buildAttention } from "./attention.js";
|
|
5
6
|
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
7
|
+
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
6
8
|
import { defaultHost } from "./host.js";
|
|
7
|
-
import { readJobSpec } from "./jobs.js";
|
|
9
|
+
import { readJobSpec, resolveJobSpec } from "./jobs.js";
|
|
8
10
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
9
|
-
import { decide, jobState, } from "./reconcile.js";
|
|
10
|
-
import {
|
|
11
|
+
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
12
|
+
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
13
|
+
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
|
|
11
14
|
import { JobSchema } from "./schemas.js";
|
|
12
|
-
import { materializeWorkflow, workflowInputs } from "./workflow.js";
|
|
15
|
+
import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
|
|
13
16
|
const TAG_PREFIX = "kiln:";
|
|
14
17
|
export function jobTag(jobId) {
|
|
15
18
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -22,14 +25,28 @@ export function parseJobTag(tag) {
|
|
|
22
25
|
return undefined;
|
|
23
26
|
return id;
|
|
24
27
|
}
|
|
28
|
+
/** The derived view `kiln status` prints and `kiln serve` renders. */
|
|
29
|
+
export function readStatus(config, jobId, host = defaultHost()) {
|
|
30
|
+
const view = statusView(observe(config, host).observation, jobId);
|
|
31
|
+
return { ...view, attention: buildAttention(view.jobs) };
|
|
32
|
+
}
|
|
25
33
|
export function runStatus(config, jobId, host = defaultHost()) {
|
|
26
|
-
return renderStatus(
|
|
34
|
+
return renderStatus(readStatus(config, jobId, host));
|
|
27
35
|
}
|
|
28
|
-
export function runTick(config, host = defaultHost()) {
|
|
36
|
+
export function runTick(config, host = defaultHost(), options = {}) {
|
|
37
|
+
const startup = options.startup ?? true;
|
|
38
|
+
const now = options.now ?? new Date();
|
|
29
39
|
return withTickLock(config.kilnDir, () => {
|
|
40
|
+
const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
|
|
30
41
|
const world = observe(config, host);
|
|
31
|
-
const actions = decide(world.observation);
|
|
32
|
-
return apply(config, world, actions, host);
|
|
42
|
+
const actions = decide(world.observation, now);
|
|
43
|
+
return apply(config, world, actions, host, notes);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
47
|
+
export function startupRecovery(config, host = defaultHost(), now = new Date()) {
|
|
48
|
+
withTickLock(config.kilnDir, () => {
|
|
49
|
+
recoverRuns(config, observe(config, host).observation.jobs, { startup: true, now });
|
|
33
50
|
});
|
|
34
51
|
}
|
|
35
52
|
export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
@@ -55,8 +72,79 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
55
72
|
if (result.status !== 0) {
|
|
56
73
|
throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
|
|
57
74
|
}
|
|
75
|
+
if (choice === "approve") {
|
|
76
|
+
recordInvestigationFindings(config, observe(config, host), jobId);
|
|
77
|
+
}
|
|
58
78
|
return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
|
|
59
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Run a failed or dead job again. The board Retry button and `kiln retry`
|
|
82
|
+
* both call this.
|
|
83
|
+
* A landed job whose only failure is opening the pull request is handed to
|
|
84
|
+
* the same deliver path tick uses. Everything else is a new `yak run`, the
|
|
85
|
+
* same launch tick uses. The attempt ledger is cleared either way, and the
|
|
86
|
+
* current run id is ignored so a dead job is not stuck on the old journal.
|
|
87
|
+
* A dead journal is cancelled first.
|
|
88
|
+
*/
|
|
89
|
+
export function retryJob(config, jobId, host = defaultHost()) {
|
|
90
|
+
return withTickLock(config.kilnDir, () => {
|
|
91
|
+
const world = observe(config, host);
|
|
92
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
93
|
+
if (!observed)
|
|
94
|
+
throw new Error(`No job ${jobId}.`);
|
|
95
|
+
if (observed.error)
|
|
96
|
+
throw new Error(`Job ${jobId} is invalid: ${observed.error}`);
|
|
97
|
+
const state = jobState(observed, world.observation);
|
|
98
|
+
if (state !== "failed" && state !== "dead") {
|
|
99
|
+
throw new Error(`Job ${jobId} is ${state}, not failed or dead.`);
|
|
100
|
+
}
|
|
101
|
+
const job = world.jobs.get(jobId);
|
|
102
|
+
if (!job)
|
|
103
|
+
throw new Error(`No job ${jobId}.`);
|
|
104
|
+
const crumb = readBreadcrumbs(config.kilnDir).find((candidate) => candidate.jobId === jobId);
|
|
105
|
+
if (crumb && pidAlive(crumb.pid)) {
|
|
106
|
+
throw new Error(`Job ${jobId} is already launching.`);
|
|
107
|
+
}
|
|
108
|
+
const cleared = observed.attemptCount ?? 0;
|
|
109
|
+
resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
|
|
110
|
+
rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
|
|
111
|
+
clearLaunchError(config.kilnDir, jobId);
|
|
112
|
+
const noun = cleared === 1 ? "attempt" : "attempts";
|
|
113
|
+
const clearedLine = `cleared ${cleared} ${noun}\n`;
|
|
114
|
+
if (shouldRedeliver(observed)) {
|
|
115
|
+
const report = deliverObserved(config, world, jobId, host);
|
|
116
|
+
if (!report.ok)
|
|
117
|
+
throw new Error(report.detail);
|
|
118
|
+
const detail = report.detail.endsWith("\n") || report.detail.length === 0 ? report.detail : `${report.detail}\n`;
|
|
119
|
+
return `retry ${jobId} ${job.repo}\n${clearedLine}${detail}`;
|
|
120
|
+
}
|
|
121
|
+
if (state === "dead" && observed.run) {
|
|
122
|
+
try {
|
|
123
|
+
spawnSync(process.execPath, [yakCliPath(), "cancel", observed.run.runId], {
|
|
124
|
+
cwd: observed.run.checkout,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
timeout: 20_000,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Cancel is the same cleanup status prints. The new run still supersedes the journal.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
134
|
+
if (existsSync(logPath))
|
|
135
|
+
writeFileSync(logPath, "");
|
|
136
|
+
const report = launchJob(config, world, job, host);
|
|
137
|
+
const output = report.output.endsWith("\n") || report.output.length === 0 ? report.output : `${report.output}\n`;
|
|
138
|
+
return `retry ${report.jobId} ${report.repo}\n${clearedLine}${output}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function shouldRedeliver(job) {
|
|
142
|
+
if (!isLanded(job))
|
|
143
|
+
return false;
|
|
144
|
+
if (job.delivery?.prState === "CLOSED")
|
|
145
|
+
return false;
|
|
146
|
+
return Boolean(job.delivery?.error) && !job.delivery?.prNumber;
|
|
147
|
+
}
|
|
60
148
|
function gateForChoice(gates, choice) {
|
|
61
149
|
return (gates.find((gate) => gate.choices?.includes(choice)) ??
|
|
62
150
|
gates.find((gate) => !gate.choices?.length));
|
|
@@ -78,11 +166,16 @@ function observe(config, host) {
|
|
|
78
166
|
if (entry.job)
|
|
79
167
|
records.set(entry.job.id, entry.job);
|
|
80
168
|
const tag = jobTag(entry.meta.jobId);
|
|
81
|
-
const
|
|
169
|
+
const ledgerResult = readAttemptLedger(config.kilnDir, entry.meta.jobId);
|
|
170
|
+
const ledger = ledgerResult && ledgerResult !== "corrupt" ? ledgerResult : undefined;
|
|
171
|
+
const picked = selectRun(runs.get(tag) ?? [], ledger);
|
|
172
|
+
const run = picked.run;
|
|
82
173
|
const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
|
|
83
|
-
if (crumb && run)
|
|
174
|
+
if (crumb && run && !picked.retired)
|
|
84
175
|
breadcrumbsToClear.push({ path: crumb.path });
|
|
85
176
|
const childAlive = crumb ? pidAlive(crumb.pid) : false;
|
|
177
|
+
const launching = Boolean(crumb) && childAlive && (!run || picked.retired);
|
|
178
|
+
const launchFailed = Boolean(crumb) && !childAlive && (!run || picked.retired);
|
|
86
179
|
const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
|
|
87
180
|
const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
|
|
88
181
|
const repo = entry.job ? config.repos[entry.job.repo] : undefined;
|
|
@@ -94,6 +187,27 @@ function observe(config, host) {
|
|
|
94
187
|
const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
|
|
95
188
|
? "stack needs exactly one dependency."
|
|
96
189
|
: undefined;
|
|
190
|
+
const logPath = crumb?.logPath || existingLog(config.kilnDir, entry.meta.jobId);
|
|
191
|
+
if (run && logPath)
|
|
192
|
+
run.activityMs = later(run.activityMs, fileMtimeMs(logPath));
|
|
193
|
+
const launchActivityMs = later(crumb ? fileMtimeMs(crumb.path) : undefined, logPath ? fileMtimeMs(logPath) : undefined);
|
|
194
|
+
const attemptCount = ledger?.attempts.length ?? 0;
|
|
195
|
+
const lastFailure = ledger?.attempts.at(-1);
|
|
196
|
+
const exhausted = attemptCount >= config.maxAttempts &&
|
|
197
|
+
attemptCount > 0 &&
|
|
198
|
+
!launching &&
|
|
199
|
+
(picked.retired || !run || run.status === "dead" || run.status === "failed");
|
|
200
|
+
const specPath = entry.job ? resolveJobSpec(config.kilnDir, entry.job.spec) : undefined;
|
|
201
|
+
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
202
|
+
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
203
|
+
: undefined;
|
|
204
|
+
const logTail = readLogTail(logPath);
|
|
205
|
+
const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
|
|
206
|
+
const diffSummary = run &&
|
|
207
|
+
run.status === "suspended" &&
|
|
208
|
+
entry.job?.workflow !== INVESTIGATION_WORKFLOW
|
|
209
|
+
? readDiffSummary(run.checkout, run.runId)
|
|
210
|
+
: undefined;
|
|
97
211
|
jobs.push({
|
|
98
212
|
jobId: entry.meta.jobId,
|
|
99
213
|
repo: entry.job?.repo ?? "",
|
|
@@ -101,14 +215,28 @@ function observe(config, host) {
|
|
|
101
215
|
title: entry.job?.title ?? "",
|
|
102
216
|
dependsOn: entry.job?.dependsOn ?? [],
|
|
103
217
|
...(entry.job?.source ? { source: entry.job.source } : {}),
|
|
218
|
+
...(specPath ? { specPath } : {}),
|
|
104
219
|
deliver,
|
|
105
220
|
...(entry.job?.stack ? { stack: true } : {}),
|
|
106
221
|
...(delivery ? { delivery } : {}),
|
|
107
|
-
|
|
108
|
-
|
|
222
|
+
...(findingsPath ? { findingsPath } : {}),
|
|
223
|
+
launching,
|
|
224
|
+
launchFailed,
|
|
109
225
|
...(launchError ? { launchError } : {}),
|
|
110
|
-
...(crumb
|
|
226
|
+
...(crumb ? { launchPid: crumb.pid } : {}),
|
|
227
|
+
...(launchActivityMs !== undefined ? { launchActivityMs } : {}),
|
|
228
|
+
...(logPath ? { logPath } : {}),
|
|
229
|
+
...(entry.job?.createdAt ? { createdAt: entry.job.createdAt } : {}),
|
|
230
|
+
...(logTail ? { logTail } : {}),
|
|
231
|
+
...(diffSummary ? { diffSummary } : {}),
|
|
232
|
+
...(stepPlan && stepPlan.length > 0 ? { stepPlan } : {}),
|
|
111
233
|
...(run ? { run } : {}),
|
|
234
|
+
attemptCount,
|
|
235
|
+
...(lastFailure ? { lastFailureReason: lastFailure.reason } : {}),
|
|
236
|
+
...(ledger?.nextRetryAt && !exhausted ? { nextRetryAt: ledger.nextRetryAt } : {}),
|
|
237
|
+
...(exhausted ? { retriesExhausted: true } : {}),
|
|
238
|
+
...(picked.retired ? { runRetired: true } : {}),
|
|
239
|
+
...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
|
|
112
240
|
...(entry.meta.error || repoError || stackError
|
|
113
241
|
? { error: entry.meta.error ?? repoError ?? stackError }
|
|
114
242
|
: {}),
|
|
@@ -117,15 +245,17 @@ function observe(config, host) {
|
|
|
117
245
|
return {
|
|
118
246
|
observation: {
|
|
119
247
|
maxConcurrent: config.maxConcurrent,
|
|
248
|
+
maxAttempts: config.maxAttempts,
|
|
120
249
|
jobs,
|
|
121
250
|
breadcrumbsToClear,
|
|
122
251
|
},
|
|
123
252
|
jobs: records,
|
|
124
253
|
};
|
|
125
254
|
}
|
|
126
|
-
function apply(config, world, actions, host) {
|
|
255
|
+
function apply(config, world, actions, host, notes = []) {
|
|
127
256
|
const launches = [];
|
|
128
257
|
const delivers = [];
|
|
258
|
+
const findings = [];
|
|
129
259
|
for (const action of actions) {
|
|
130
260
|
if (action.type === "clear-breadcrumb") {
|
|
131
261
|
rmSync(action.path, { force: true });
|
|
@@ -135,13 +265,39 @@ function apply(config, world, actions, host) {
|
|
|
135
265
|
delivers.push(deliverObserved(config, world, action.jobId, host));
|
|
136
266
|
continue;
|
|
137
267
|
}
|
|
268
|
+
if (action.type === "record-findings") {
|
|
269
|
+
const recorded = recordInvestigationFindings(config, world, action.jobId);
|
|
270
|
+
if (recorded.status !== "skip")
|
|
271
|
+
findings.push(recorded);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
138
274
|
const job = world.jobs.get(action.jobId);
|
|
139
275
|
if (!job) {
|
|
140
276
|
throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
|
|
141
277
|
}
|
|
142
|
-
|
|
278
|
+
const report = launchJob(config, world, job, host);
|
|
279
|
+
launches.push(action.type === "retry" ? { ...report, retry: true } : report);
|
|
280
|
+
}
|
|
281
|
+
return renderTick(world.observation, actions, launches, delivers, findings, notes);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Copy an approved investigation's markdown into `<kilnDir>/findings`.
|
|
285
|
+
* A file that is already present is left as it is. A missing or incomplete
|
|
286
|
+
* artifact is reported and does not fail the tick.
|
|
287
|
+
*/
|
|
288
|
+
function recordInvestigationFindings(config, world, jobId) {
|
|
289
|
+
const job = world.jobs.get(jobId);
|
|
290
|
+
const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
|
|
291
|
+
if (!job || job.workflow !== INVESTIGATION_WORKFLOW || !observed?.run || !isLanded(observed)) {
|
|
292
|
+
return { status: "skip", jobId };
|
|
143
293
|
}
|
|
144
|
-
|
|
294
|
+
if (observed.findingsPath)
|
|
295
|
+
return { status: "skip", jobId };
|
|
296
|
+
const markdown = readFindingsMarkdown(observed.run.checkout, observed.run.runId);
|
|
297
|
+
if (!markdown)
|
|
298
|
+
return { status: "missing", jobId };
|
|
299
|
+
const path = writeFindings(config.kilnDir, job.id, markdown);
|
|
300
|
+
return { status: "wrote", jobId, path };
|
|
145
301
|
}
|
|
146
302
|
function deliverObserved(config, world, jobId, host) {
|
|
147
303
|
const job = world.jobs.get(jobId);
|
|
@@ -190,6 +346,33 @@ function pullRequestBase(world, job, repo, host) {
|
|
|
190
346
|
host.git.fetch(repo.path);
|
|
191
347
|
return host.git.defaultBranch(repo.path);
|
|
192
348
|
}
|
|
349
|
+
function assertStackTarget(world, job) {
|
|
350
|
+
if (!job.stack)
|
|
351
|
+
return;
|
|
352
|
+
const upstreamId = job.dependsOn?.[0];
|
|
353
|
+
if (!upstreamId)
|
|
354
|
+
return;
|
|
355
|
+
const upstream = world.jobs.get(upstreamId);
|
|
356
|
+
if (upstream?.workflow !== INVESTIGATION_WORKFLOW)
|
|
357
|
+
return;
|
|
358
|
+
throw new Error(`stack cannot target investigation job ${upstream.id}. An investigation does not land a branch.`);
|
|
359
|
+
}
|
|
360
|
+
/** Spec text for this launch. Investigation dependencies append their findings. */
|
|
361
|
+
function specHandedToYak(config, world, job) {
|
|
362
|
+
const base = readJobSpec(config.kilnDir, job);
|
|
363
|
+
const sections = [];
|
|
364
|
+
for (const depId of job.dependsOn ?? []) {
|
|
365
|
+
const dep = world.jobs.get(depId);
|
|
366
|
+
if (!dep || dep.workflow !== INVESTIGATION_WORKFLOW)
|
|
367
|
+
continue;
|
|
368
|
+
const path = findingsFilePath(config.kilnDir, dep.id);
|
|
369
|
+
if (!existsSync(path)) {
|
|
370
|
+
throw new Error(`Job ${job.id} depends on investigation ${dep.id} (${dep.title}), which has no findings file at ${path}.`);
|
|
371
|
+
}
|
|
372
|
+
sections.push({ title: dep.title, markdown: readFileSync(path, "utf8") });
|
|
373
|
+
}
|
|
374
|
+
return appendInvestigationFindings(base, sections);
|
|
375
|
+
}
|
|
193
376
|
function upstreamBranch(world, job) {
|
|
194
377
|
const upstreamId = job.dependsOn?.[0];
|
|
195
378
|
if (!upstreamId)
|
|
@@ -233,8 +416,9 @@ function launchJob(config, world, job, host) {
|
|
|
233
416
|
if (!repo) {
|
|
234
417
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
235
418
|
}
|
|
419
|
+
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
236
420
|
const testCommand = repo.test;
|
|
237
|
-
if (!testCommand?.trim()) {
|
|
421
|
+
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
238
422
|
throw new Error(`Repo ${job.repo} has no test command. Set repos.${job.repo}.test. The feature workflow refuses to run an empty KILN_TEST.`);
|
|
239
423
|
}
|
|
240
424
|
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
|
|
@@ -243,8 +427,10 @@ function launchJob(config, world, job, host) {
|
|
|
243
427
|
const crumbPath = breadcrumbPath(config.kilnDir, job.id);
|
|
244
428
|
ensureCheckoutExcludes(repo.path);
|
|
245
429
|
let baseSha;
|
|
430
|
+
let specText;
|
|
246
431
|
try {
|
|
247
432
|
clearLaunchError(config.kilnDir, job.id);
|
|
433
|
+
assertStackTarget(world, job);
|
|
248
434
|
baseSha = resolveLaunchBase({
|
|
249
435
|
checkout: repo.path,
|
|
250
436
|
deliver: repo.deliver,
|
|
@@ -253,6 +439,7 @@ function launchJob(config, world, job, host) {
|
|
|
253
439
|
...(job.stack ? { upstreamBranch: upstreamBranch(world, job) } : {}),
|
|
254
440
|
git: host.git,
|
|
255
441
|
});
|
|
442
|
+
specText = specHandedToYak(config, world, job);
|
|
256
443
|
}
|
|
257
444
|
catch (error) {
|
|
258
445
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -260,8 +447,6 @@ function launchJob(config, world, job, host) {
|
|
|
260
447
|
writeFileSync(logPath, `${message}\n`);
|
|
261
448
|
return { jobId: job.id, repo: job.repo, output: message };
|
|
262
449
|
}
|
|
263
|
-
const specText = readJobSpec(config.kilnDir, job);
|
|
264
|
-
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
|
|
265
450
|
const input = workflowInputs(sourceWorkflow, {
|
|
266
451
|
title: job.title,
|
|
267
452
|
specText,
|
|
@@ -359,6 +544,12 @@ function scanRuns(config) {
|
|
|
359
544
|
const status = classify(events);
|
|
360
545
|
const gateDecision = readApproveDecision(runDir);
|
|
361
546
|
const title = readRunTitle(runDir);
|
|
547
|
+
const finished = [...events].reverse().find((event) => event.t === "run.finished");
|
|
548
|
+
const stepPlan = readRunStepPlan(runDir);
|
|
549
|
+
const stepEvents = stepEventsFrom(events);
|
|
550
|
+
const pid = livePid(events);
|
|
551
|
+
const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
|
|
552
|
+
const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
|
|
362
553
|
const run = {
|
|
363
554
|
runId,
|
|
364
555
|
checkout: repo.path,
|
|
@@ -366,14 +557,175 @@ function scanRuns(config) {
|
|
|
366
557
|
gates: status === "suspended" ? openGates(runDir, events) : [],
|
|
367
558
|
...(gateDecision ? { gateDecision } : {}),
|
|
368
559
|
...(title ? { title } : {}),
|
|
560
|
+
...(typeof started?.at === "string" ? { startedAt: started.at } : {}),
|
|
561
|
+
...(typeof finished?.at === "string" ? { finishedAt: finished.at } : {}),
|
|
562
|
+
...(stepPlan.length > 0 ? { stepPlan } : {}),
|
|
563
|
+
...(stepEvents.length > 0 ? { stepEvents } : {}),
|
|
564
|
+
...(pid !== undefined ? { pid } : {}),
|
|
565
|
+
...(activityMs !== undefined ? { activityMs } : {}),
|
|
566
|
+
...(finishedReason ? { finishedReason } : {}),
|
|
369
567
|
};
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
568
|
+
const list = byTag.get(started.tag) ?? [];
|
|
569
|
+
list.push(run);
|
|
570
|
+
byTag.set(started.tag, list);
|
|
373
571
|
}
|
|
374
572
|
}
|
|
375
573
|
return byTag;
|
|
376
574
|
}
|
|
575
|
+
function stepEventsFrom(events) {
|
|
576
|
+
const found = [];
|
|
577
|
+
for (const event of events) {
|
|
578
|
+
if (event.t !== "step.started" && event.t !== "step.completed" && event.t !== "step.failed")
|
|
579
|
+
continue;
|
|
580
|
+
if (!event.stepId)
|
|
581
|
+
continue;
|
|
582
|
+
const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
|
|
583
|
+
found.push({
|
|
584
|
+
t: event.t,
|
|
585
|
+
stepId: event.stepId,
|
|
586
|
+
...(detail ? { detail } : {}),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return found;
|
|
590
|
+
}
|
|
591
|
+
function formatFailure(failure) {
|
|
592
|
+
if (!failure)
|
|
593
|
+
return undefined;
|
|
594
|
+
const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
|
|
595
|
+
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
|
596
|
+
const text = detail && reason ? `${detail} (${reason})` : detail || reason;
|
|
597
|
+
return text || undefined;
|
|
598
|
+
}
|
|
599
|
+
function readRunStepPlan(runDir) {
|
|
600
|
+
const file = join(runDir, "workflow.json");
|
|
601
|
+
if (!existsSync(file))
|
|
602
|
+
return [];
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
605
|
+
return leafStepIds(parsed.steps);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function readDiffSummary(checkout, runId) {
|
|
612
|
+
const worktree = join(checkout, ".yak", "worktrees", runId);
|
|
613
|
+
if (existsSync(worktree)) {
|
|
614
|
+
const result = spawnSync("git", ["diff", "--stat", "HEAD"], {
|
|
615
|
+
cwd: worktree,
|
|
616
|
+
encoding: "utf8",
|
|
617
|
+
timeout: 5_000,
|
|
618
|
+
});
|
|
619
|
+
const text = result.status === 0 ? result.stdout.trim() : "";
|
|
620
|
+
if (text)
|
|
621
|
+
return clipText(text, 1500);
|
|
622
|
+
}
|
|
623
|
+
return readBuildSummary(join(checkout, ".runs", runId));
|
|
624
|
+
}
|
|
625
|
+
function readBuildSummary(runDir) {
|
|
626
|
+
const dir = join(runDir, "artifacts");
|
|
627
|
+
if (!existsSync(dir))
|
|
628
|
+
return undefined;
|
|
629
|
+
let files;
|
|
630
|
+
try {
|
|
631
|
+
files = readdirSync(dir);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
let best;
|
|
637
|
+
for (const file of files) {
|
|
638
|
+
const match = /^build(?:\.(\d+))?\.json$/.exec(file);
|
|
639
|
+
if (!match)
|
|
640
|
+
continue;
|
|
641
|
+
const iter = match[1] ? Number(match[1]) : 0;
|
|
642
|
+
if (!best || iter >= best.iter)
|
|
643
|
+
best = { iter, file };
|
|
644
|
+
}
|
|
645
|
+
if (!best)
|
|
646
|
+
return undefined;
|
|
647
|
+
try {
|
|
648
|
+
const parsed = JSON.parse(readFileSync(join(dir, best.file), "utf8"));
|
|
649
|
+
if (typeof parsed.summary !== "string" || !parsed.summary.trim())
|
|
650
|
+
return undefined;
|
|
651
|
+
return clipText(parsed.summary.trim(), 280);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function readLogTail(logPath) {
|
|
658
|
+
if (!logPath)
|
|
659
|
+
return undefined;
|
|
660
|
+
let size;
|
|
661
|
+
try {
|
|
662
|
+
size = statSync(logPath).size;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
if (size <= 0)
|
|
668
|
+
return undefined;
|
|
669
|
+
const take = Math.min(size, 64_000);
|
|
670
|
+
let fd;
|
|
671
|
+
try {
|
|
672
|
+
fd = openSync(logPath, "r");
|
|
673
|
+
const buffer = Buffer.alloc(take);
|
|
674
|
+
readSync(fd, buffer, 0, take, size - take);
|
|
675
|
+
const text = buffer.toString("utf8");
|
|
676
|
+
const lines = text.split(/\r?\n/);
|
|
677
|
+
if (size > take)
|
|
678
|
+
lines.shift();
|
|
679
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
680
|
+
lines.pop();
|
|
681
|
+
const tail = lines.slice(-40).join("\n").trim();
|
|
682
|
+
return tail || undefined;
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
if (fd !== undefined)
|
|
689
|
+
closeSync(fd);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Prefer a run that is not already a recorded failure, so a retry is visible
|
|
694
|
+
* even when its id does not sort after the failed one. Otherwise keep the
|
|
695
|
+
* latest id, which is what a single-run job has always shown.
|
|
696
|
+
*/
|
|
697
|
+
function selectRun(runs, ledger) {
|
|
698
|
+
const ignored = new Set(ledger?.ignoredRunIds ?? []);
|
|
699
|
+
const failed = new Set((ledger?.attempts ?? []).flatMap((attempt) => (attempt.runId ? [attempt.runId] : [])));
|
|
700
|
+
const visible = runs.filter((run) => !ignored.has(run.runId));
|
|
701
|
+
visible.sort((left, right) => (left.runId < right.runId ? 1 : left.runId > right.runId ? -1 : 0));
|
|
702
|
+
const fresh = visible.filter((run) => !failed.has(run.runId));
|
|
703
|
+
if (fresh[0])
|
|
704
|
+
return { run: fresh[0], retired: false };
|
|
705
|
+
if (visible[0])
|
|
706
|
+
return { run: visible[0], retired: true };
|
|
707
|
+
return { retired: false };
|
|
708
|
+
}
|
|
709
|
+
function later(left, right) {
|
|
710
|
+
if (left === undefined)
|
|
711
|
+
return right;
|
|
712
|
+
if (right === undefined)
|
|
713
|
+
return left;
|
|
714
|
+
return Math.max(left, right);
|
|
715
|
+
}
|
|
716
|
+
function eventActivityMs(events) {
|
|
717
|
+
let latest;
|
|
718
|
+
for (const event of events) {
|
|
719
|
+
if (!event.at)
|
|
720
|
+
continue;
|
|
721
|
+
const parsed = Date.parse(event.at);
|
|
722
|
+
if (!Number.isFinite(parsed))
|
|
723
|
+
continue;
|
|
724
|
+
if (latest === undefined || parsed > latest)
|
|
725
|
+
latest = parsed;
|
|
726
|
+
}
|
|
727
|
+
return latest;
|
|
728
|
+
}
|
|
377
729
|
function listRunDirs(checkout) {
|
|
378
730
|
const runsDir = join(checkout, ".runs");
|
|
379
731
|
if (!existsSync(runsDir))
|
|
@@ -467,20 +819,141 @@ function openGates(runDir, events) {
|
|
|
467
819
|
return gates;
|
|
468
820
|
}
|
|
469
821
|
function readGate(runDir, stepId) {
|
|
822
|
+
const reviews = readReviewArtifacts(runDir);
|
|
470
823
|
const file = join(runDir, "pending", `${stepId}.request.json`);
|
|
471
|
-
if (!existsSync(file))
|
|
472
|
-
return { stepId, prompt: "(request file missing)" };
|
|
824
|
+
if (!existsSync(file)) {
|
|
825
|
+
return { stepId, prompt: "(request file missing)", ...(reviews.length > 0 ? { reviews } : {}) };
|
|
826
|
+
}
|
|
473
827
|
const request = JSON.parse(readFileSync(file, "utf8"));
|
|
474
828
|
const prompt = request.kind === "loop-exhausted"
|
|
475
829
|
? `loop exhausted (${request.tripped ?? "budget"} at iteration ${request.iteration ?? "?"})`
|
|
476
830
|
: (request.rendered ?? stepId).split("\n")[0] ?? stepId;
|
|
477
831
|
const shape = answerShape(request.answerSchema);
|
|
832
|
+
const rendered = typeof request.rendered === "string" ? clipText(request.rendered, 2000) : undefined;
|
|
833
|
+
const artifacts = readNamedArtifacts(runDir, artifactNames(request.context?.artifacts));
|
|
478
834
|
return {
|
|
479
835
|
stepId,
|
|
480
836
|
prompt,
|
|
837
|
+
...(rendered ? { rendered } : {}),
|
|
481
838
|
...(shape ? { answerField: shape.field, choices: shape.choices } : {}),
|
|
839
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
840
|
+
...(reviews.length > 0 ? { reviews } : {}),
|
|
482
841
|
};
|
|
483
842
|
}
|
|
843
|
+
const REVIEW_STEPS = ["security", "ux", "ui", "code"];
|
|
844
|
+
const ARTIFACT_NAME = /^[A-Za-z0-9._-]+$/;
|
|
845
|
+
const SECRET_KEY = /api[_-]?token|password|secret|authorization|credential/i;
|
|
846
|
+
/** The launch log stays after the breadcrumb is cleared. Status prints it only for a failed launch. */
|
|
847
|
+
function existingLog(kilnDir, jobId) {
|
|
848
|
+
let name;
|
|
849
|
+
try {
|
|
850
|
+
name = jobBasename(jobId);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
const path = join(kilnDir, "logs", `${name}.log`);
|
|
856
|
+
return existsSync(path) ? path : undefined;
|
|
857
|
+
}
|
|
858
|
+
function artifactNames(value) {
|
|
859
|
+
if (!Array.isArray(value))
|
|
860
|
+
return [];
|
|
861
|
+
const names = [];
|
|
862
|
+
for (const entry of value) {
|
|
863
|
+
if (typeof entry !== "string" || !ARTIFACT_NAME.test(entry) || names.includes(entry))
|
|
864
|
+
continue;
|
|
865
|
+
names.push(entry);
|
|
866
|
+
}
|
|
867
|
+
return names;
|
|
868
|
+
}
|
|
869
|
+
function readNamedArtifacts(runDir, names) {
|
|
870
|
+
const found = [];
|
|
871
|
+
for (const name of names) {
|
|
872
|
+
const file = join(runDir, "artifacts", `${name}.json`);
|
|
873
|
+
if (!existsSync(file))
|
|
874
|
+
continue;
|
|
875
|
+
found.push(readArtifactSummary(file, name));
|
|
876
|
+
}
|
|
877
|
+
return found;
|
|
878
|
+
}
|
|
879
|
+
function readReviewArtifacts(runDir) {
|
|
880
|
+
const dir = join(runDir, "artifacts");
|
|
881
|
+
if (!existsSync(dir))
|
|
882
|
+
return [];
|
|
883
|
+
let files;
|
|
884
|
+
try {
|
|
885
|
+
files = readdirSync(dir);
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
890
|
+
const best = new Map();
|
|
891
|
+
for (const file of files) {
|
|
892
|
+
const match = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/.exec(file);
|
|
893
|
+
if (!match?.[1])
|
|
894
|
+
continue;
|
|
895
|
+
const iter = match[2] ? Number(match[2]) : 0;
|
|
896
|
+
const previous = best.get(match[1]);
|
|
897
|
+
if (!previous || iter >= previous.iter)
|
|
898
|
+
best.set(match[1], { iter, file });
|
|
899
|
+
}
|
|
900
|
+
const found = [];
|
|
901
|
+
for (const step of REVIEW_STEPS) {
|
|
902
|
+
const picked = best.get(step);
|
|
903
|
+
if (!picked)
|
|
904
|
+
continue;
|
|
905
|
+
found.push(readArtifactSummary(join(dir, picked.file), step));
|
|
906
|
+
}
|
|
907
|
+
return found;
|
|
908
|
+
}
|
|
909
|
+
function readArtifactSummary(file, name) {
|
|
910
|
+
if (!existsSync(file))
|
|
911
|
+
return { name, summary: "(missing artifact)" };
|
|
912
|
+
try {
|
|
913
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
914
|
+
return { name, summary: summarizeArtifact(name, parsed) };
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
return { name, summary: "(unreadable artifact)" };
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function summarizeArtifact(name, value) {
|
|
921
|
+
const clean = redactSecrets(value);
|
|
922
|
+
if (name === "input" && isRecord(clean)) {
|
|
923
|
+
const title = clean.title;
|
|
924
|
+
return typeof title === "string" && title.trim() ? `title: ${clipText(title, 120)}` : "(no title)";
|
|
925
|
+
}
|
|
926
|
+
if (isRecord(clean)) {
|
|
927
|
+
const head = typeof clean.decision === "string" ? clean.decision : typeof clean.verdict === "string" ? clean.verdict : undefined;
|
|
928
|
+
const detail = typeof clean.reason === "string" ? clean.reason : typeof clean.findings === "string" ? clean.findings : undefined;
|
|
929
|
+
if (head)
|
|
930
|
+
return clipText(detail ? `${head} — ${detail}` : head, 280);
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
return clipText(JSON.stringify(clean), 280);
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
return "(unreadable artifact)";
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function redactSecrets(value) {
|
|
940
|
+
if (Array.isArray(value))
|
|
941
|
+
return value.map(redactSecrets);
|
|
942
|
+
if (!isRecord(value))
|
|
943
|
+
return value;
|
|
944
|
+
const out = {};
|
|
945
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
946
|
+
out[key] = SECRET_KEY.test(key) ? "[redacted]" : redactSecrets(inner);
|
|
947
|
+
}
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
function isRecord(value) {
|
|
951
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
952
|
+
}
|
|
953
|
+
function clipText(text, max) {
|
|
954
|
+
const trimmed = text.trim();
|
|
955
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}...`;
|
|
956
|
+
}
|
|
484
957
|
function answerShape(schema) {
|
|
485
958
|
if (typeof schema !== "object" || schema === null || !("properties" in schema))
|
|
486
959
|
return undefined;
|
|
@@ -616,15 +1089,7 @@ function releaseLock(lockPath) {
|
|
|
616
1089
|
}
|
|
617
1090
|
}
|
|
618
1091
|
function pidAlive(pid) {
|
|
619
|
-
|
|
620
|
-
return false;
|
|
621
|
-
try {
|
|
622
|
-
process.kill(pid, 0);
|
|
623
|
-
return true;
|
|
624
|
-
}
|
|
625
|
-
catch (error) {
|
|
626
|
-
return isErrno(error, "EPERM");
|
|
627
|
-
}
|
|
1092
|
+
return isPidAlive(pid);
|
|
628
1093
|
}
|
|
629
1094
|
function isErrno(error, code) {
|
|
630
1095
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|