@lchase/kiln 0.8.0 → 0.9.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 CHANGED
@@ -277,6 +277,8 @@ While a `deliver=pr` job is `in-review`, tick reads that pull request with `gh`:
277
277
 
278
278
  `npm run kiln -- status` lists jobs across repos. When a job has `source`, status prints `source <kind> <ref>`. For `needs-human` it prints `approve: kiln approve <id>` and `reject: kiln reject <id>`. Run those as `npm run kiln -- approve <id>` and `npm run kiln -- reject <id>`. For `done` and `in-review` it prints the branch. `deliver=local` prints `branch yak/<run-id>`. `deliver=pr` prints the remote branch name recorded on the delivery, and the pull request url once one exists. `auth-failed` prints `Claude login rejected (401) — check CLAUDE_CODE_OAUTH_TOKEN / claude login, then Retry`. For an investigation whose findings file exists, it prints `findings <path>`. That path is `findingsPath` on the same status view `kiln serve` renders, and the console links it. For `blocked` on a dependency id that has no job file, it prints `blocked on missing job <id>`. While an upstream pull request is open, it prints `waiting for PR #N to merge`. `npm run kiln -- retry <id>` relaunches a failed or dead job, or delivers again when the only failure is opening the pull request, and clears the attempt ledger.
279
279
 
280
+ `npm run kiln -- ledger` counts human interventions from `<kiln-home>/events.jsonl`: a rejected gate, a job retried by hand, an edit noted on a gate answer, and a needs-human stop. `--since 7d` (also `12h`, `30m`, `45s`, or an ISO time) keeps events at or after that instant. `--repo <name>` limits the count to one repo. A failed append is reported and does not fail the job. `GET /api/events?job_id=` on `kiln serve` returns that job's lines. A pull request body gains a short Evidence section from the same lines, with tokens and secret env values masked.
281
+
280
282
  State is derived from the yak journal, the gate files, and (for `deliver=pr`) `gh pr view`:
281
283
 
282
284
  | State | Meaning |
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
3
  import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
4
4
  import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
5
+ import { parseSince, renderLedgerFile } from "./ledger.js";
5
6
  import { startServe } from "./serve.js";
6
7
  import { addFromFile } from "./intake/file.js";
7
8
  import { addFromJira } from "./intake/jira.js";
@@ -19,6 +20,7 @@ function usage() {
19
20
  " kiln approve <jobId> [--config <file>]",
20
21
  " kiln reject <jobId> [--config <file>]",
21
22
  " kiln retry <jobId> [--config <file>]",
23
+ " kiln ledger [--since 7d] [--repo <name>] [--config <file>]",
22
24
  " kiln sandbox [build] [--config <file>]",
23
25
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
24
26
  "",
@@ -27,7 +29,7 @@ function usage() {
27
29
  "A relative path is stored absolute, resolved from the working directory.",
28
30
  "~ is expanded from the home directory.",
29
31
  "",
30
- "Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
32
+ "Config, jobs, logs, events, and deliveries live in one kiln home: --config <file>",
31
33
  "if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
32
34
  "otherwise ~/.kiln/kiln.config.json. repo add creates that directory.",
33
35
  "Run kiln repo add <name> <path> from any directory. A leftover",
@@ -66,6 +68,12 @@ function usage() {
66
68
  "A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
67
69
  "(default 3). The job is then dead. A rejected gate is not retried.",
68
70
  "Attempts are files under the kiln home, not fields on the job.",
71
+ "Events are one JSON line each in <kiln-home>/events.jsonl. A write",
72
+ "failure is reported and does not stop the job. kiln ledger counts",
73
+ "human interventions from that log: a rejected gate, a manual retry,",
74
+ "an edit noted on a gate, and a needs-human stop. --since 7d (or 12h,",
75
+ "30m, 45s, or an ISO time) keeps events at or after that time. --repo",
76
+ "limits the count to one registered repo name.",
69
77
  "Once a pull request is open, tick watches it. A failed check or a review",
70
78
  "that requests changes starts a fix on that same branch, up to maxFixRounds",
71
79
  "(default 2). Kiln does not merge, approve, or comment. Past that bound the",
@@ -219,6 +227,13 @@ function parseFlags(argv) {
219
227
  bag.noTick = true;
220
228
  continue;
221
229
  }
230
+ if (flag === "--since") {
231
+ if (!next || next.startsWith("--"))
232
+ usage();
233
+ bag.since = next;
234
+ i += 1;
235
+ continue;
236
+ }
222
237
  if (flag?.startsWith("--"))
223
238
  usage();
224
239
  if (flag !== undefined)
@@ -226,9 +241,11 @@ function parseFlags(argv) {
226
241
  }
227
242
  return bag;
228
243
  }
229
- function rejectServeFlags(flags) {
244
+ function rejectServeFlags(flags, options = {}) {
230
245
  if (flags.port !== undefined || flags.tickSeconds !== undefined || flags.noTick)
231
246
  usage();
247
+ if (options.since !== false && flags.since)
248
+ usage();
232
249
  }
233
250
  function repoCommand(argv) {
234
251
  const sub = argv[0];
@@ -388,6 +405,32 @@ function retryCommand(argv) {
388
405
  const config = resolveKilnConfig({ configPath: flags.configPath });
389
406
  process.stdout.write(retryJob(config, jobId));
390
407
  }
408
+ function ledgerCommand(argv) {
409
+ const flags = parseFlags(argv);
410
+ rejectServeFlags(flags, { since: false });
411
+ if (flags.positionals.length !== 0 ||
412
+ flags.test ||
413
+ flags.spec ||
414
+ flags.prompt ||
415
+ flags.jira ||
416
+ flags.title ||
417
+ flags.issue ||
418
+ flags.workflow ||
419
+ flags.context ||
420
+ flags.dryRun ||
421
+ flags.stack ||
422
+ flags.after.length > 0 ||
423
+ flags.deliver ||
424
+ flags.base) {
425
+ usage();
426
+ }
427
+ const config = resolveKilnConfig({ configPath: flags.configPath });
428
+ const since = flags.since ? parseSince(flags.since) : undefined;
429
+ process.stdout.write(renderLedgerFile(config.kilnDir, {
430
+ ...(since ? { since } : {}),
431
+ ...(flags.repo ? { repo: flags.repo } : {}),
432
+ }));
433
+ }
391
434
  function gateCommand(choice, argv) {
392
435
  const flags = parseFlags(argv);
393
436
  rejectServeFlags(flags);
@@ -468,6 +511,10 @@ async function main() {
468
511
  retryCommand(argv.slice(1));
469
512
  return;
470
513
  }
514
+ if (command === "ledger") {
515
+ ledgerCommand(argv.slice(1));
516
+ return;
517
+ }
471
518
  if (command === "sandbox") {
472
519
  sandboxCommand(argv.slice(1));
473
520
  return;
package/dist/delivery.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
+ import { evidenceSectionFor } from "./evidence.js";
5
+ import { appendEvent } from "./events.js";
4
6
  import { HostError } from "./host.js";
5
7
  import { readJobSpec } from "./jobs.js";
6
8
  import { jobBasename } from "./paths.js";
@@ -181,6 +183,20 @@ export function deliverJob(request) {
181
183
  prNumber: pr.number,
182
184
  prUrl: pr.url,
183
185
  });
186
+ if (!sameRun?.prNumber && remoteName) {
187
+ appendEvent(request.config.kilnDir, {
188
+ job_id: request.job.id,
189
+ run_id: request.runId,
190
+ event: "pr.opened",
191
+ properties: {
192
+ number: pr.number,
193
+ url: pr.url,
194
+ branch: remoteName,
195
+ base: request.baseBranch,
196
+ once: `pr.opened:${pr.number}`,
197
+ },
198
+ });
199
+ }
184
200
  return { ok: true, detail: pr.url, prUrl: pr.url, prNumber: pr.number };
185
201
  }
186
202
  catch (error) {
@@ -213,6 +229,8 @@ export function pullRequestBody(request) {
213
229
  "## Reviewers",
214
230
  ...(reviews.length > 0 ? reviews : ["- (no reviewer artifacts)"]),
215
231
  "",
232
+ evidenceSectionFor(request.config.kilnDir, request.job.id).trimEnd(),
233
+ "",
216
234
  ].join("\n");
217
235
  }
218
236
  export function specSummary(specText) {
package/dist/driver.js CHANGED
@@ -6,12 +6,14 @@ import { claudeCredentialPreflight } from "./auth.js";
6
6
  import { buildAttention } from "./attention.js";
7
7
  import { clearLaunchError, deliverJob, plannedRemoteBranch, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
8
8
  import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
9
+ import { appendEvent } from "./events.js";
9
10
  import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
10
11
  import { defaultHost } from "./host.js";
11
12
  import { readJobSpec, resolveJobSpec } from "./jobs.js";
12
13
  import { jobBasename, packageRoot } from "./paths.js";
13
14
  import { decide, isLanded, jobState, } from "./reconcile.js";
14
15
  import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
16
+ import { projectRunJournal } from "./project.js";
15
17
  import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
16
18
  import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
17
19
  import { JobSchema } from "./schemas.js";
@@ -87,6 +89,20 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
87
89
  if (result.status !== 0) {
88
90
  throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
89
91
  }
92
+ const noted = gateEdit(job.run.checkout, job.run.runId, gate.stepId);
93
+ appendEvent(config.kilnDir, {
94
+ job_id: jobId,
95
+ run_id: job.run.runId,
96
+ event: "gate.resolved",
97
+ step: gate.stepId,
98
+ properties: {
99
+ decision: choice,
100
+ by: "human",
101
+ repo: job.repo,
102
+ ...noted,
103
+ once: `gate.resolved:${gate.stepId}`,
104
+ },
105
+ });
90
106
  if (choice === "approve") {
91
107
  recordInvestigationFindings(config, observe(config, host), jobId);
92
108
  }
@@ -120,6 +136,11 @@ export function retryJob(config, jobId, host = defaultHost()) {
120
136
  if (crumb && pidAlive(crumb.pid)) {
121
137
  throw new Error(`Job ${jobId} is already launching.`);
122
138
  }
139
+ appendEvent(config.kilnDir, {
140
+ job_id: jobId,
141
+ event: "job.retried",
142
+ properties: { by: "human", repo: job.repo },
143
+ });
123
144
  const cleared = observed.attemptCount ?? 0;
124
145
  resetAttemptLedger(config.kilnDir, jobId, observed.run?.runId);
125
146
  rmSync(breadcrumbPath(config.kilnDir, jobId), { force: true });
@@ -545,6 +566,7 @@ function publishReadyFixes(config, host, world) {
545
566
  if (sha === open.headSha) {
546
567
  open.outcome = "fixed";
547
568
  writeFeedbackLedger(config.kilnDir, ledger);
569
+ recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "fixed");
548
570
  notes.push(`fixed ${job.jobId} ${sha}`);
549
571
  continue;
550
572
  }
@@ -580,6 +602,7 @@ function publishReadyFixes(config, host, world) {
580
602
  });
581
603
  open.outcome = "pushed";
582
604
  writeFeedbackLedger(config.kilnDir, ledger);
605
+ recordFeedbackOutcome(config.kilnDir, job.jobId, job.run.runId, open, "pushed");
583
606
  notes.push(`pushed ${job.jobId} ${record.branch} ${sha}`);
584
607
  }
585
608
  return notes;
@@ -587,6 +610,46 @@ function publishReadyFixes(config, host, world) {
587
610
  function isNonFastForward(message) {
588
611
  return /non-fast-forward|\[rejected\]|failed to push some refs/i.test(message);
589
612
  }
613
+ function recordFeedbackOutcome(kilnDir, jobId, runId, round, outcome) {
614
+ appendEvent(kilnDir, {
615
+ job_id: jobId,
616
+ run_id: runId,
617
+ event: "pr.feedback",
618
+ properties: {
619
+ round: round.round,
620
+ trigger: round.trigger,
621
+ outcome,
622
+ items: round.items.length,
623
+ once: `pr.feedback:${round.round}:${outcome}`,
624
+ },
625
+ });
626
+ }
627
+ /** An edit note the human left on the gate answer, when the answer file has one. */
628
+ function gateEdit(checkout, runId, stepId) {
629
+ const paths = [
630
+ join(checkout, ".runs", runId, "pending", `${stepId}.answer.json`),
631
+ join(checkout, ".runs", runId, "artifacts", "approval.json"),
632
+ ];
633
+ for (const path of paths) {
634
+ if (!existsSync(path))
635
+ continue;
636
+ try {
637
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
638
+ const note = typeof parsed.note === "string" ? parsed.note.trim() : "";
639
+ const edit = typeof parsed.edit === "string" ? parsed.edit.trim() : "";
640
+ if (!note && !edit)
641
+ continue;
642
+ return {
643
+ ...(note ? { note: note.slice(0, 240) } : {}),
644
+ ...(edit ? { edit: edit.slice(0, 240) } : {}),
645
+ };
646
+ }
647
+ catch {
648
+ // An unreadable answer is not an edit note.
649
+ }
650
+ }
651
+ return {};
652
+ }
590
653
  /**
591
654
  * Read each open pull request once per tick and attach a plan.
592
655
  * `readStatus` does not call this. A host with no feedback reader does not watch.
@@ -641,6 +704,11 @@ function markFeedback(kilnDir, jobId, reason) {
641
704
  else
642
705
  ledger.ambiguous = true;
643
706
  writeFeedbackLedger(kilnDir, ledger);
707
+ appendEvent(kilnDir, {
708
+ job_id: jobId,
709
+ event: "needs-human",
710
+ properties: { reason, once: `needs-human:${reason}` },
711
+ });
644
712
  }
645
713
  /**
646
714
  * Open one feedback round and start the fix workflow on the pull request head.
@@ -678,6 +746,16 @@ function startFix(config, world, job, host, now, sandboxFacts) {
678
746
  at: now.toISOString(),
679
747
  });
680
748
  writeFeedbackLedger(config.kilnDir, ledger);
749
+ appendEvent(config.kilnDir, {
750
+ job_id: job.id,
751
+ event: "pr.feedback",
752
+ properties: {
753
+ round,
754
+ trigger: plan.trigger,
755
+ items: plan.items.length,
756
+ once: `pr.feedback:${round}:open`,
757
+ },
758
+ });
681
759
  let specText;
682
760
  try {
683
761
  specText = feedbackSpec(specHandedToYak(config, world, job), plan.items);
@@ -943,6 +1021,11 @@ function loadJobFile(file) {
943
1021
  return { meta: { jobId: job.id }, job };
944
1022
  }
945
1023
  function scanRuns(config) {
1024
+ const jobs = new Map();
1025
+ for (const entry of loadJobs(config)) {
1026
+ if (entry.job)
1027
+ jobs.set(entry.job.id, entry.job);
1028
+ }
946
1029
  const byTag = new Map();
947
1030
  for (const repo of Object.values(config.repos)) {
948
1031
  for (const runDir of listRunDirs(repo.path)) {
@@ -951,6 +1034,21 @@ function scanRuns(config) {
951
1034
  if (!started?.tag || !parseJobTag(started.tag))
952
1035
  continue;
953
1036
  const runId = basename(runDir);
1037
+ const jobId = parseJobTag(started.tag);
1038
+ if (jobId) {
1039
+ const job = jobs.get(jobId);
1040
+ const testCommand = job ? config.repos[job.repo]?.test : undefined;
1041
+ projectRunJournal({
1042
+ kilnDir: config.kilnDir,
1043
+ jobId,
1044
+ runId,
1045
+ runDir,
1046
+ workflow: job?.workflow ?? "",
1047
+ repo: job?.repo ?? "",
1048
+ ...(testCommand ? { testCommand } : {}),
1049
+ events,
1050
+ });
1051
+ }
954
1052
  const status = classify(events);
955
1053
  const gateDecision = readApproveDecision(runDir);
956
1054
  const title = readRunTitle(runDir);
package/dist/events.js ADDED
@@ -0,0 +1,354 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { redactDeep } from "./redact.js";
6
+ /**
7
+ * Append-only event log at `<kilnDir>/events.jsonl` (`~/.kiln/events.jsonl`
8
+ * when that directory is the kiln home). One module writes it. One reader
9
+ * parses it. A write failure is reported and does not escape to the caller.
10
+ *
11
+ * `run_id` and `step` are null when the moment has none. `job_id` is null
12
+ * only for `config.reloaded`, which is not a job.
13
+ */
14
+ export const EVENT_NAMES = [
15
+ "job.added",
16
+ "job.retried",
17
+ "run.started",
18
+ "phase.started",
19
+ "phase.finished",
20
+ "gate.opened",
21
+ "gate.resolved",
22
+ "run.failed",
23
+ "run.finished",
24
+ "pr.opened",
25
+ "pr.feedback",
26
+ "worktree.cleaned",
27
+ "worktree.kept",
28
+ "config.reloaded",
29
+ "needs-human",
30
+ ];
31
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
32
+ const once = z.string().min(1).optional();
33
+ const JobAdded = z.object({
34
+ repo: z.string().min(1),
35
+ workflow: z.string().min(1),
36
+ title: z.string().min(1),
37
+ source_kind: z.string().min(1).optional(),
38
+ once,
39
+ });
40
+ const JobRetried = z.object({
41
+ by: z.literal("human"),
42
+ repo: z.string().min(1),
43
+ once,
44
+ });
45
+ const RunStarted = z.object({
46
+ kiln_version: z.string().min(1),
47
+ yak_version: z.string().min(1),
48
+ workflow: z.string().min(1),
49
+ prompts_hash: z.string().min(1),
50
+ repo: z.string().min(1),
51
+ once,
52
+ });
53
+ const PhaseStarted = z.object({
54
+ iteration: z.number().int().nonnegative().optional(),
55
+ once,
56
+ });
57
+ const PhaseFinished = z.object({
58
+ outcome: z.enum(["completed", "failed"]),
59
+ iteration: z.number().int().nonnegative().optional(),
60
+ cached: z.boolean().optional(),
61
+ skipped: z.boolean().optional(),
62
+ reason: z.string().min(1).optional(),
63
+ detail: z.string().min(1).optional(),
64
+ verdict: z.string().min(1).optional(),
65
+ test_command: z.string().min(1).optional(),
66
+ exit_code: z.number().int().optional(),
67
+ once,
68
+ });
69
+ const GateOpened = z.object({
70
+ prompt: z.string().min(1).optional(),
71
+ note: z.string().min(1).optional(),
72
+ edit: z.string().min(1).optional(),
73
+ once,
74
+ });
75
+ const GateResolved = z.object({
76
+ decision: z.string().min(1),
77
+ by: z.string().min(1),
78
+ repo: z.string().min(1).optional(),
79
+ note: z.string().min(1).optional(),
80
+ edit: z.string().min(1).optional(),
81
+ once,
82
+ });
83
+ const RunFailed = z.object({
84
+ reason: z.string().min(1).optional(),
85
+ detail: z.string().min(1).optional(),
86
+ status: z.string().min(1).optional(),
87
+ once,
88
+ });
89
+ const RunFinished = z.object({
90
+ status: z.literal("ok"),
91
+ once,
92
+ });
93
+ const PrOpened = z.object({
94
+ number: z.number().int().positive(),
95
+ url: z.string().min(1),
96
+ branch: z.string().min(1),
97
+ base: z.string().min(1),
98
+ once,
99
+ });
100
+ const PrFeedback = z.object({
101
+ round: z.number().int().positive(),
102
+ trigger: z.enum(["ci", "review"]),
103
+ outcome: z.enum(["fixed", "pushed"]).optional(),
104
+ items: z.number().int().nonnegative().optional(),
105
+ once,
106
+ });
107
+ const Worktree = z.object({ once });
108
+ const ConfigReloaded = z.object({ once });
109
+ const NeedsHuman = z.object({
110
+ reason: z.string().min(1),
111
+ once,
112
+ });
113
+ const envelope = {
114
+ id: z.string().min(1),
115
+ ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
116
+ run_id: z.string().min(1).nullable(),
117
+ step: z.string().min(1).nullable(),
118
+ };
119
+ export const EventSchema = z.discriminatedUnion("event", [
120
+ z.object({ ...envelope, event: z.literal("job.added"), job_id: z.string().min(1), properties: JobAdded }),
121
+ z.object({ ...envelope, event: z.literal("job.retried"), job_id: z.string().min(1), properties: JobRetried }),
122
+ z.object({ ...envelope, event: z.literal("run.started"), job_id: z.string().min(1), properties: RunStarted }),
123
+ z.object({ ...envelope, event: z.literal("phase.started"), job_id: z.string().min(1), properties: PhaseStarted }),
124
+ z.object({ ...envelope, event: z.literal("phase.finished"), job_id: z.string().min(1), properties: PhaseFinished }),
125
+ z.object({ ...envelope, event: z.literal("gate.opened"), job_id: z.string().min(1), properties: GateOpened }),
126
+ z.object({ ...envelope, event: z.literal("gate.resolved"), job_id: z.string().min(1), properties: GateResolved }),
127
+ z.object({ ...envelope, event: z.literal("run.failed"), job_id: z.string().min(1), properties: RunFailed }),
128
+ z.object({ ...envelope, event: z.literal("run.finished"), job_id: z.string().min(1), properties: RunFinished }),
129
+ z.object({ ...envelope, event: z.literal("pr.opened"), job_id: z.string().min(1), properties: PrOpened }),
130
+ z.object({ ...envelope, event: z.literal("pr.feedback"), job_id: z.string().min(1), properties: PrFeedback }),
131
+ z.object({ ...envelope, event: z.literal("worktree.cleaned"), job_id: z.string().min(1), properties: Worktree }),
132
+ z.object({ ...envelope, event: z.literal("worktree.kept"), job_id: z.string().min(1), properties: Worktree }),
133
+ z.object({
134
+ ...envelope,
135
+ event: z.literal("config.reloaded"),
136
+ job_id: z.string().min(1).nullable(),
137
+ properties: ConfigReloaded,
138
+ }),
139
+ z.object({ ...envelope, event: z.literal("needs-human"), job_id: z.string().min(1), properties: NeedsHuman }),
140
+ ]);
141
+ const LOCK_WAIT_MS = 5_000;
142
+ const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
143
+ export function eventsPath(kilnDir) {
144
+ return join(kilnDir, "events.jsonl");
145
+ }
146
+ /**
147
+ * Append one JSON line. Concurrent callers in this process take a file lock
148
+ * and each lock holder performs one `write` of the full line, so lines do
149
+ * not interleave. A failure is printed and swallowed.
150
+ *
151
+ * `properties.once` is an idempotency key. A later append with the same
152
+ * job, run, event, step, and `once` is a no-op. Moments that should repeat
153
+ * (a second manual retry, a second config reload) omit it.
154
+ */
155
+ export function appendEvent(kilnDir, draft) {
156
+ let line;
157
+ let event;
158
+ try {
159
+ event = buildEvent(draft);
160
+ line = `${JSON.stringify(event)}\n`;
161
+ }
162
+ catch (error) {
163
+ report(error);
164
+ return;
165
+ }
166
+ try {
167
+ withEventsLock(kilnDir, () => {
168
+ if (alreadyRecorded(kilnDir, event))
169
+ return;
170
+ writeLine(eventsPath(kilnDir), line);
171
+ });
172
+ }
173
+ catch (error) {
174
+ report(error);
175
+ }
176
+ }
177
+ /** Parse the log. A blank line is ignored. Any other bad line is skipped. */
178
+ export function readEvents(kilnDir, options = {}) {
179
+ const warn = options.warn ?? ((message) => console.error(message));
180
+ const path = eventsPath(kilnDir);
181
+ if (!existsSync(path))
182
+ return [];
183
+ let raw;
184
+ try {
185
+ raw = readFileSync(path, "utf8");
186
+ }
187
+ catch (error) {
188
+ warn(`kiln: event log unreadable: ${error instanceof Error ? error.message : String(error)}`);
189
+ return [];
190
+ }
191
+ const events = [];
192
+ const lines = raw.split("\n");
193
+ for (let index = 0; index < lines.length; index += 1) {
194
+ const line = lines[index];
195
+ if (line === undefined || line.trim() === "")
196
+ continue;
197
+ const parsed = parseLine(line);
198
+ if (!parsed) {
199
+ warn(`kiln: skipped event log line ${index + 1}`);
200
+ continue;
201
+ }
202
+ events.push(parsed);
203
+ }
204
+ return events;
205
+ }
206
+ export function eventsForJob(kilnDir, jobId, options) {
207
+ return readEvents(kilnDir, options).filter((event) => event.job_id === jobId);
208
+ }
209
+ function buildEvent(draft) {
210
+ const properties = redactDeep(draft.properties);
211
+ const candidate = {
212
+ id: draft.id ?? randomUUID(),
213
+ ts: draft.ts ?? new Date().toISOString(),
214
+ job_id: draft.job_id,
215
+ run_id: draft.run_id ?? null,
216
+ event: draft.event,
217
+ step: draft.step ?? null,
218
+ properties,
219
+ };
220
+ const parsed = EventSchema.safeParse(candidate);
221
+ if (!parsed.success) {
222
+ throw new Error(`refused ${draft.event}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
223
+ }
224
+ return parsed.data;
225
+ }
226
+ function parseLine(line) {
227
+ try {
228
+ const parsed = EventSchema.safeParse(JSON.parse(line));
229
+ return parsed.success ? parsed.data : undefined;
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ function alreadyRecorded(kilnDir, event) {
236
+ const key = event.properties.once;
237
+ if (!key)
238
+ return false;
239
+ const path = eventsPath(kilnDir);
240
+ if (!existsSync(path))
241
+ return false;
242
+ let raw;
243
+ try {
244
+ raw = readFileSync(path, "utf8");
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ for (const line of raw.split("\n")) {
250
+ if (!line.trim())
251
+ continue;
252
+ try {
253
+ const parsed = JSON.parse(line);
254
+ if (parsed.job_id === event.job_id &&
255
+ parsed.run_id === event.run_id &&
256
+ parsed.event === event.event &&
257
+ parsed.step === event.step &&
258
+ parsed.properties?.once === key) {
259
+ return true;
260
+ }
261
+ }
262
+ catch {
263
+ // A corrupt line does not count as a recorded event.
264
+ }
265
+ }
266
+ return false;
267
+ }
268
+ /** One `write` of the whole line, including the newline. */
269
+ function writeLine(path, line) {
270
+ const fd = openSync(path, "a");
271
+ try {
272
+ const buf = Buffer.from(line);
273
+ const wrote = writeSync(fd, buf, 0, buf.length);
274
+ if (wrote !== buf.length) {
275
+ throw new Error(`short write (${wrote} of ${buf.length})`);
276
+ }
277
+ }
278
+ finally {
279
+ closeSync(fd);
280
+ }
281
+ }
282
+ /**
283
+ * Exclusive append across threads in this process. The lock file holds the
284
+ * pid. Another thread in the same process shares that pid and waits. A
285
+ * later process may remove a lock whose pid is gone.
286
+ */
287
+ function withEventsLock(kilnDir, fn) {
288
+ mkdirSync(kilnDir, { recursive: true });
289
+ const lockPath = join(kilnDir, "events.lock");
290
+ const tmp = `${lockPath}.${process.pid}.${randomBytes(4).toString("hex")}`;
291
+ writeFileSync(tmp, String(process.pid));
292
+ const deadline = Date.now() + LOCK_WAIT_MS;
293
+ let held = false;
294
+ try {
295
+ while (!held) {
296
+ try {
297
+ linkSync(tmp, lockPath);
298
+ held = true;
299
+ }
300
+ catch (error) {
301
+ if (!isErrno(error, "EEXIST"))
302
+ throw error;
303
+ if (lockHolderDead(lockPath)) {
304
+ rmSync(lockPath, { force: true });
305
+ continue;
306
+ }
307
+ if (Date.now() > deadline)
308
+ throw new Error("timed out waiting for the event log lock");
309
+ Atomics.wait(pauseBuf, 0, 0, 5);
310
+ }
311
+ }
312
+ fn();
313
+ }
314
+ finally {
315
+ if (held)
316
+ rmSync(lockPath, { force: true });
317
+ rmSync(tmp, { force: true });
318
+ }
319
+ }
320
+ function lockHolderDead(lockPath) {
321
+ let holder = Number.NaN;
322
+ try {
323
+ holder = Number(readFileSync(lockPath, "utf8").trim());
324
+ }
325
+ catch {
326
+ return true;
327
+ }
328
+ if (holder === process.pid)
329
+ return false;
330
+ return !pidAlive(holder);
331
+ }
332
+ function pidAlive(pid) {
333
+ if (!Number.isInteger(pid) || pid <= 0)
334
+ return false;
335
+ try {
336
+ process.kill(pid, 0);
337
+ return true;
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ }
343
+ function isErrno(error, code) {
344
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
345
+ }
346
+ function report(error) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ try {
349
+ console.error(`kiln: event log: ${message}`);
350
+ }
351
+ catch {
352
+ // The caller still continues when stderr itself fails.
353
+ }
354
+ }