@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/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 { gateAnswerCommand, renderStatus, renderTick, yakCliPath, } from "./report.js";
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(observe(config, host).observation, jobId);
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 run = runs.get(tag);
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
- launching: Boolean(crumb) && !run && childAlive,
108
- launchFailed: Boolean(crumb) && !run && !childAlive,
222
+ ...(findingsPath ? { findingsPath } : {}),
223
+ launching,
224
+ launchFailed,
109
225
  ...(launchError ? { launchError } : {}),
110
- ...(crumb?.logPath ? { logPath: crumb.logPath } : {}),
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
- launches.push(launchJob(config, world, job, host));
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
- return renderTick(world.observation, actions, launches, delivers);
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 previous = byTag.get(started.tag);
371
- if (!previous || run.runId > previous.runId)
372
- byTag.set(started.tag, run);
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
- if (!Number.isInteger(pid) || pid <= 0)
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;