@lchase/kiln 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,634 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
6
+ import { defaultHost } from "./host.js";
7
+ import { readJobSpec } from "./jobs.js";
8
+ import { jobBasename, packageRoot } from "./paths.js";
9
+ import { decide, jobState, } from "./reconcile.js";
10
+ import { gateAnswerCommand, renderStatus, renderTick, yakCliPath, } from "./report.js";
11
+ import { JobSchema } from "./schemas.js";
12
+ import { materializeWorkflow, workflowInputs } from "./workflow.js";
13
+ const TAG_PREFIX = "kiln:";
14
+ export function jobTag(jobId) {
15
+ return `${TAG_PREFIX}${jobId}`;
16
+ }
17
+ export function parseJobTag(tag) {
18
+ if (!tag.startsWith(TAG_PREFIX))
19
+ return undefined;
20
+ const id = tag.slice(TAG_PREFIX.length);
21
+ if (!id || id.includes("/"))
22
+ return undefined;
23
+ return id;
24
+ }
25
+ export function runStatus(config, jobId, host = defaultHost()) {
26
+ return renderStatus(observe(config, host).observation, jobId);
27
+ }
28
+ export function runTick(config, host = defaultHost()) {
29
+ return withTickLock(config.kilnDir, () => {
30
+ const world = observe(config, host);
31
+ const actions = decide(world.observation);
32
+ return apply(config, world, actions, host);
33
+ });
34
+ }
35
+ export function answerJob(config, jobId, choice, host = defaultHost()) {
36
+ const world = observe(config, host);
37
+ const job = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
38
+ if (!job) {
39
+ throw new Error(`No job ${jobId}.`);
40
+ }
41
+ if (job.error) {
42
+ throw new Error(`Job ${jobId} is invalid: ${job.error}`);
43
+ }
44
+ const state = jobState(job, world.observation);
45
+ if (state !== "needs-human" || !job.run) {
46
+ throw new Error(`Job ${jobId} is ${state}, not needs-human.`);
47
+ }
48
+ const gate = gateForChoice(job.run.gates, choice);
49
+ if (!gate) {
50
+ throw new Error(`Job ${jobId} has no open gate that accepts ${choice}.`);
51
+ }
52
+ const command = gateAnswerCommand(job.run.checkout, job.run.runId, gate, choice, job.run.title ?? job.title);
53
+ const result = spawnSync("sh", ["-c", command], { encoding: "utf8" });
54
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
55
+ if (result.status !== 0) {
56
+ throw new Error(output.trim() || `Could not ${choice} job ${jobId}.`);
57
+ }
58
+ return output.endsWith("\n") || output.length === 0 ? output : `${output}\n`;
59
+ }
60
+ function gateForChoice(gates, choice) {
61
+ return (gates.find((gate) => gate.choices?.includes(choice)) ??
62
+ gates.find((gate) => !gate.choices?.length));
63
+ }
64
+ function observe(config, host) {
65
+ const loaded = loadJobs(config);
66
+ const runs = scanRuns(config);
67
+ const breadcrumbs = readBreadcrumbs(config.kilnDir);
68
+ const jobs = [];
69
+ const records = new Map();
70
+ const breadcrumbsToClear = [];
71
+ const ordered = [...loaded].sort((left, right) => {
72
+ const byTime = (left.job?.createdAt ?? "").localeCompare(right.job?.createdAt ?? "");
73
+ if (byTime !== 0)
74
+ return byTime;
75
+ return left.meta.jobId.localeCompare(right.meta.jobId);
76
+ });
77
+ for (const entry of ordered) {
78
+ if (entry.job)
79
+ records.set(entry.job.id, entry.job);
80
+ const tag = jobTag(entry.meta.jobId);
81
+ const run = runs.get(tag);
82
+ const crumb = breadcrumbs.find((candidate) => candidate.jobId === entry.meta.jobId);
83
+ if (crumb && run)
84
+ breadcrumbsToClear.push({ path: crumb.path });
85
+ const childAlive = crumb ? pidAlive(crumb.pid) : false;
86
+ const repoKnown = entry.job ? Boolean(config.repos[entry.job.repo]) : false;
87
+ const repoError = entry.job && !repoKnown ? `Repo ${entry.job.repo} is not registered.` : undefined;
88
+ const repo = entry.job ? config.repos[entry.job.repo] : undefined;
89
+ const deliver = repo?.deliver ?? "local";
90
+ const delivery = entry.job && repo && deliver === "pr"
91
+ ? observedDelivery(config.kilnDir, host, repo.path, entry.job.id, run?.runId)
92
+ : undefined;
93
+ const launchError = entry.job && !run ? readLaunchError(config.kilnDir, entry.job.id) : undefined;
94
+ const stackError = entry.job?.stack && (entry.job.dependsOn?.length ?? 0) !== 1
95
+ ? "stack needs exactly one dependency."
96
+ : undefined;
97
+ jobs.push({
98
+ jobId: entry.meta.jobId,
99
+ repo: entry.job?.repo ?? "",
100
+ workflow: entry.job?.workflow ?? "",
101
+ title: entry.job?.title ?? "",
102
+ dependsOn: entry.job?.dependsOn ?? [],
103
+ ...(entry.job?.source ? { source: entry.job.source } : {}),
104
+ deliver,
105
+ ...(entry.job?.stack ? { stack: true } : {}),
106
+ ...(delivery ? { delivery } : {}),
107
+ launching: Boolean(crumb) && !run && childAlive,
108
+ launchFailed: Boolean(crumb) && !run && !childAlive,
109
+ ...(launchError ? { launchError } : {}),
110
+ ...(crumb?.logPath ? { logPath: crumb.logPath } : {}),
111
+ ...(run ? { run } : {}),
112
+ ...(entry.meta.error || repoError || stackError
113
+ ? { error: entry.meta.error ?? repoError ?? stackError }
114
+ : {}),
115
+ });
116
+ }
117
+ return {
118
+ observation: {
119
+ maxConcurrent: config.maxConcurrent,
120
+ jobs,
121
+ breadcrumbsToClear,
122
+ },
123
+ jobs: records,
124
+ };
125
+ }
126
+ function apply(config, world, actions, host) {
127
+ const launches = [];
128
+ const delivers = [];
129
+ for (const action of actions) {
130
+ if (action.type === "clear-breadcrumb") {
131
+ rmSync(action.path, { force: true });
132
+ continue;
133
+ }
134
+ if (action.type === "deliver") {
135
+ delivers.push(deliverObserved(config, world, action.jobId, host));
136
+ continue;
137
+ }
138
+ const job = world.jobs.get(action.jobId);
139
+ if (!job) {
140
+ throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
141
+ }
142
+ launches.push(launchJob(config, world, job, host));
143
+ }
144
+ return renderTick(world.observation, actions, launches, delivers);
145
+ }
146
+ function deliverObserved(config, world, jobId, host) {
147
+ const job = world.jobs.get(jobId);
148
+ const observed = world.observation.jobs.find((candidate) => candidate.jobId === jobId);
149
+ const repo = job ? config.repos[job.repo] : undefined;
150
+ if (!job || !observed?.run || !repo) {
151
+ const detail = `Job ${jobId} is not ready to open a pull request.`;
152
+ return { jobId, repo: job?.repo ?? "", ok: false, detail };
153
+ }
154
+ try {
155
+ const baseBranch = pullRequestBase(world, job, repo, host);
156
+ const outcome = deliverJob({
157
+ config,
158
+ job,
159
+ checkout: repo.path,
160
+ runId: observed.run.runId,
161
+ baseBranch,
162
+ host,
163
+ });
164
+ return { jobId, repo: job.repo, ok: outcome.ok, detail: outcome.detail };
165
+ }
166
+ catch (error) {
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ writeDelivery(config.kilnDir, {
169
+ jobId,
170
+ runId: observed.run.runId,
171
+ branch: `yak/${observed.run.runId}`,
172
+ base: repo.base ?? "unknown",
173
+ pushed: false,
174
+ error: message,
175
+ });
176
+ return { jobId, repo: job.repo, ok: false, detail: message };
177
+ }
178
+ }
179
+ /** Stacked jobs target the upstream branch. Everyone else targets the repo base. */
180
+ function pullRequestBase(world, job, repo, host) {
181
+ if (job.stack) {
182
+ const branch = upstreamBranch(world, job);
183
+ if (!branch) {
184
+ throw new Error(`Job ${job.id} is stacked but its upstream has no branch to target.`);
185
+ }
186
+ return branch;
187
+ }
188
+ if (repo.base)
189
+ return repo.base;
190
+ host.git.fetch(repo.path);
191
+ return host.git.defaultBranch(repo.path);
192
+ }
193
+ function upstreamBranch(world, job) {
194
+ const upstreamId = job.dependsOn?.[0];
195
+ if (!upstreamId)
196
+ return undefined;
197
+ const upstream = world.observation.jobs.find((candidate) => candidate.jobId === upstreamId);
198
+ if (!upstream?.run)
199
+ return undefined;
200
+ return upstream.delivery?.branch ?? `yak/${upstream.run.runId}`;
201
+ }
202
+ function observedDelivery(kilnDir, host, checkout, jobId, runId) {
203
+ const record = readDelivery(kilnDir, jobId);
204
+ if (!record)
205
+ return undefined;
206
+ if (runId && record.runId !== runId)
207
+ return undefined;
208
+ let prState;
209
+ let merged = false;
210
+ let viewError;
211
+ if (record.prNumber) {
212
+ try {
213
+ const viewed = host.github.view(checkout, record.prNumber);
214
+ prState = viewed.state;
215
+ merged = isMerged(viewed);
216
+ }
217
+ catch (error) {
218
+ viewError = error instanceof Error ? error.message : String(error);
219
+ }
220
+ }
221
+ return {
222
+ branch: record.branch,
223
+ ...(record.prNumber ? { prNumber: record.prNumber } : {}),
224
+ ...(record.prUrl ? { prUrl: record.prUrl } : {}),
225
+ merged,
226
+ ...(prState ? { prState } : {}),
227
+ ...(record.error && !record.prNumber ? { error: record.error } : {}),
228
+ ...(viewError ? { viewError } : {}),
229
+ };
230
+ }
231
+ function launchJob(config, world, job, host) {
232
+ const repo = config.repos[job.repo];
233
+ if (!repo) {
234
+ throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
235
+ }
236
+ const testCommand = repo.test;
237
+ if (!testCommand?.trim()) {
238
+ 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
+ }
240
+ const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow);
241
+ const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
242
+ mkdirSync(dirname(logPath), { recursive: true });
243
+ const crumbPath = breadcrumbPath(config.kilnDir, job.id);
244
+ ensureCheckoutExcludes(repo.path);
245
+ let baseSha;
246
+ try {
247
+ clearLaunchError(config.kilnDir, job.id);
248
+ baseSha = resolveLaunchBase({
249
+ checkout: repo.path,
250
+ deliver: repo.deliver,
251
+ stack: job.stack === true,
252
+ ...(repo.base ? { configuredBase: repo.base } : {}),
253
+ ...(job.stack ? { upstreamBranch: upstreamBranch(world, job) } : {}),
254
+ git: host.git,
255
+ });
256
+ }
257
+ catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ writeLaunchError(config.kilnDir, job.id, message);
260
+ writeFileSync(logPath, `${message}\n`);
261
+ return { jobId: job.id, repo: job.repo, output: message };
262
+ }
263
+ const specText = readJobSpec(config.kilnDir, job);
264
+ const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
265
+ const input = workflowInputs(sourceWorkflow, {
266
+ title: job.title,
267
+ specText,
268
+ runNonce: randomUUID(),
269
+ baseRef: baseSha,
270
+ });
271
+ const args = [
272
+ yakCliPath(),
273
+ "run",
274
+ workflow,
275
+ "--adapter",
276
+ config.adapter,
277
+ "--isolation",
278
+ "worktree",
279
+ "--from",
280
+ baseSha,
281
+ "--tag",
282
+ jobTag(job.id),
283
+ ];
284
+ for (const [key, value] of Object.entries(input)) {
285
+ args.push("--input", `${key}=${value}`);
286
+ }
287
+ const env = {
288
+ ...process.env,
289
+ KILN_TEST: testCommand,
290
+ KILN_TASK_TITLE: job.title,
291
+ KILN_VERIFY_WRAPPER: join(packageRoot(), "dist", "truncate-output.js"),
292
+ };
293
+ writeBreadcrumb(crumbPath, { jobId: job.id, pid: 0, logPath });
294
+ const logFd = openSync(logPath, "a");
295
+ try {
296
+ const child = spawn(process.execPath, args, {
297
+ cwd: repo.path,
298
+ detached: true,
299
+ stdio: ["ignore", logFd, logFd],
300
+ env,
301
+ });
302
+ child.unref();
303
+ const pid = child.pid ?? 0;
304
+ writeBreadcrumb(crumbPath, { jobId: job.id, pid, logPath });
305
+ return { jobId: job.id, repo: job.repo, output: `pid ${pid}\nlog ${logPath}` };
306
+ }
307
+ catch (error) {
308
+ writeFileSync(logPath, `${errorMessage(error)}\n`);
309
+ return { jobId: job.id, repo: job.repo, output: `pid 0\nlog ${logPath}` };
310
+ }
311
+ finally {
312
+ closeSync(logFd);
313
+ }
314
+ }
315
+ function loadJobs(config) {
316
+ if (!existsSync(config.jobsDir))
317
+ return [];
318
+ return readdirSync(config.jobsDir)
319
+ .filter((name) => name.endsWith(".json"))
320
+ .sort()
321
+ .map((name) => loadJobFile(join(config.jobsDir, name)));
322
+ }
323
+ function loadJobFile(file) {
324
+ const fileId = basename(file, ".json");
325
+ let raw;
326
+ try {
327
+ raw = JSON.parse(readFileSync(file, "utf8"));
328
+ }
329
+ catch (error) {
330
+ return { meta: { jobId: fileId, error: errorMessage(error) } };
331
+ }
332
+ const parsed = JobSchema.safeParse(raw);
333
+ if (!parsed.success) {
334
+ return { meta: { jobId: fileId, error: parsed.error.message } };
335
+ }
336
+ const job = parsed.data;
337
+ if (`${jobBasename(job.id)}.json` !== basename(file)) {
338
+ return {
339
+ meta: {
340
+ jobId: job.id,
341
+ error: `Job file ${basename(file)} does not match id ${job.id}.`,
342
+ },
343
+ };
344
+ }
345
+ if ("state" in raw) {
346
+ return { meta: { jobId: job.id, error: "Job state is derived and must not be stored." } };
347
+ }
348
+ return { meta: { jobId: job.id }, job };
349
+ }
350
+ function scanRuns(config) {
351
+ const byTag = new Map();
352
+ for (const repo of Object.values(config.repos)) {
353
+ for (const runDir of listRunDirs(repo.path)) {
354
+ const events = readJournal(runDir);
355
+ const started = events.find((event) => event.t === "run.started");
356
+ if (!started?.tag || !parseJobTag(started.tag))
357
+ continue;
358
+ const runId = basename(runDir);
359
+ const status = classify(events);
360
+ const gateDecision = readApproveDecision(runDir);
361
+ const title = readRunTitle(runDir);
362
+ const run = {
363
+ runId,
364
+ checkout: repo.path,
365
+ status,
366
+ gates: status === "suspended" ? openGates(runDir, events) : [],
367
+ ...(gateDecision ? { gateDecision } : {}),
368
+ ...(title ? { title } : {}),
369
+ };
370
+ const previous = byTag.get(started.tag);
371
+ if (!previous || run.runId > previous.runId)
372
+ byTag.set(started.tag, run);
373
+ }
374
+ }
375
+ return byTag;
376
+ }
377
+ function listRunDirs(checkout) {
378
+ const runsDir = join(checkout, ".runs");
379
+ if (!existsSync(runsDir))
380
+ return [];
381
+ return readdirSync(runsDir, { withFileTypes: true })
382
+ .filter((entry) => entry.isDirectory())
383
+ .map((entry) => join(runsDir, entry.name));
384
+ }
385
+ function readJournal(runDir) {
386
+ const file = join(runDir, "journal.jsonl");
387
+ if (!existsSync(file))
388
+ return [];
389
+ const lines = readFileSync(file, "utf8").split("\n").filter((line) => line.trim());
390
+ const events = [];
391
+ for (let index = 0; index < lines.length; index += 1) {
392
+ const line = lines[index];
393
+ if (!line)
394
+ continue;
395
+ try {
396
+ events.push(JSON.parse(line));
397
+ }
398
+ catch (error) {
399
+ if (index === lines.length - 1)
400
+ continue;
401
+ throw new Error(`Could not read ${file}: ${errorMessage(error)}`);
402
+ }
403
+ }
404
+ return events;
405
+ }
406
+ /** Latest `run.resumed` pid, else `run.started`. Same rule as yak's livePidFromJournal. */
407
+ function livePid(events) {
408
+ for (let index = events.length - 1; index >= 0; index -= 1) {
409
+ const event = events[index];
410
+ if (event?.t === "run.resumed" && typeof event.pid === "number")
411
+ return event.pid;
412
+ }
413
+ const started = events.find((event) => event.t === "run.started");
414
+ return typeof started?.pid === "number" ? started.pid : undefined;
415
+ }
416
+ function classify(events) {
417
+ const last = events.at(-1);
418
+ if (last?.t === "run.finished" &&
419
+ (last.status === "ok" || last.status === "failed" || last.status === "suspended")) {
420
+ return last.status;
421
+ }
422
+ const pid = livePid(events);
423
+ if (pid === undefined || !pidAlive(pid))
424
+ return "dead";
425
+ return "alive";
426
+ }
427
+ function readApproveDecision(runDir) {
428
+ const file = join(runDir, "artifacts", "approval.json");
429
+ if (!existsSync(file))
430
+ return undefined;
431
+ try {
432
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
433
+ if (parsed.decision === "approve" || parsed.decision === "reject")
434
+ return parsed.decision;
435
+ }
436
+ catch {
437
+ // A partial artifact is not a decision.
438
+ }
439
+ return undefined;
440
+ }
441
+ function readRunTitle(runDir) {
442
+ const file = join(runDir, "artifacts", "input.json");
443
+ if (!existsSync(file))
444
+ return undefined;
445
+ try {
446
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
447
+ return typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : undefined;
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ }
453
+ function openGates(runDir, events) {
454
+ const open = new Map();
455
+ for (const event of events) {
456
+ if (event.t === "gate.opened" && event.stepId)
457
+ open.set(event.stepId, true);
458
+ else if (event.t === "gate.answered" && event.stepId)
459
+ open.set(event.stepId, false);
460
+ }
461
+ const gates = [];
462
+ for (const [stepId, isOpen] of open) {
463
+ if (!isOpen)
464
+ continue;
465
+ gates.push(readGate(runDir, stepId));
466
+ }
467
+ return gates;
468
+ }
469
+ function readGate(runDir, stepId) {
470
+ const file = join(runDir, "pending", `${stepId}.request.json`);
471
+ if (!existsSync(file))
472
+ return { stepId, prompt: "(request file missing)" };
473
+ const request = JSON.parse(readFileSync(file, "utf8"));
474
+ const prompt = request.kind === "loop-exhausted"
475
+ ? `loop exhausted (${request.tripped ?? "budget"} at iteration ${request.iteration ?? "?"})`
476
+ : (request.rendered ?? stepId).split("\n")[0] ?? stepId;
477
+ const shape = answerShape(request.answerSchema);
478
+ return {
479
+ stepId,
480
+ prompt,
481
+ ...(shape ? { answerField: shape.field, choices: shape.choices } : {}),
482
+ };
483
+ }
484
+ function answerShape(schema) {
485
+ if (typeof schema !== "object" || schema === null || !("properties" in schema))
486
+ return undefined;
487
+ const properties = schema.properties;
488
+ if (typeof properties !== "object" || properties === null)
489
+ return undefined;
490
+ const required = "required" in schema && Array.isArray(schema.required)
491
+ ? schema.required.filter((item) => typeof item === "string")
492
+ : [];
493
+ for (const key of required) {
494
+ const prop = properties[key];
495
+ if (typeof prop !== "object" || prop === null || !("enum" in prop) || !Array.isArray(prop.enum))
496
+ continue;
497
+ const choices = prop.enum.filter((value) => typeof value === "string");
498
+ if (choices.length > 0)
499
+ return { field: key, choices };
500
+ }
501
+ return undefined;
502
+ }
503
+ function breadcrumbPath(kilnDir, jobId) {
504
+ return join(kilnDir, "launching", `${jobBasename(jobId)}.json`);
505
+ }
506
+ function writeBreadcrumb(path, body) {
507
+ mkdirSync(dirname(path), { recursive: true });
508
+ writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`);
509
+ }
510
+ function readBreadcrumbs(kilnDir) {
511
+ const root = join(kilnDir, "launching");
512
+ if (!existsSync(root))
513
+ return [];
514
+ const found = [];
515
+ for (const name of readdirSync(root)) {
516
+ if (!name.endsWith(".json"))
517
+ continue;
518
+ const path = join(root, name);
519
+ try {
520
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
521
+ if (!parsed.jobId)
522
+ continue;
523
+ found.push({
524
+ path,
525
+ jobId: parsed.jobId,
526
+ pid: typeof parsed.pid === "number" ? parsed.pid : 0,
527
+ logPath: typeof parsed.logPath === "string" ? parsed.logPath : "",
528
+ });
529
+ }
530
+ catch {
531
+ // An unreadable crumb is left for a person. It does not count as a job.
532
+ }
533
+ }
534
+ return found;
535
+ }
536
+ /** The checkout's git metadata must not show up in `git status` or a later `git add`. */
537
+ function ensureCheckoutExcludes(checkout) {
538
+ const gitDir = gitCommonDir(checkout);
539
+ if (!gitDir)
540
+ return;
541
+ const info = join(gitDir, "info");
542
+ mkdirSync(info, { recursive: true });
543
+ const exclude = join(info, "exclude");
544
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
545
+ const present = new Set(current.split("\n").map((line) => line.trim()));
546
+ const missing = [".runs/", ".yak/"].filter((entry) => !present.has(entry));
547
+ if (missing.length === 0)
548
+ return;
549
+ const suffix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
550
+ writeFileSync(exclude, `${current}${suffix}${missing.join("\n")}\n`);
551
+ }
552
+ /** Main git dir, including when `.git` is a file (a linked worktree). */
553
+ function gitCommonDir(checkout) {
554
+ const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
555
+ cwd: checkout,
556
+ encoding: "utf8",
557
+ });
558
+ if (result.status !== 0)
559
+ return undefined;
560
+ const printed = result.stdout.trim();
561
+ if (!printed)
562
+ return undefined;
563
+ return isAbsolute(printed) ? printed : join(checkout, printed);
564
+ }
565
+ function withTickLock(kilnDir, fn) {
566
+ mkdirSync(kilnDir, { recursive: true });
567
+ const lockPath = join(kilnDir, "tick.lock");
568
+ acquireLock(lockPath);
569
+ try {
570
+ return fn();
571
+ }
572
+ finally {
573
+ releaseLock(lockPath);
574
+ }
575
+ }
576
+ /**
577
+ * Publish the pid with link(2) so a reader never observes the lock file
578
+ * before its contents are complete. open(O_EXCL) plus a later write leaves
579
+ * a window where the file exists and is empty.
580
+ */
581
+ function acquireLock(lockPath) {
582
+ for (let attempt = 0; attempt < 3; attempt += 1) {
583
+ const tmp = `${lockPath}.${process.pid}.tmp`;
584
+ writeFileSync(tmp, String(process.pid));
585
+ try {
586
+ linkSync(tmp, lockPath);
587
+ rmSync(tmp, { force: true });
588
+ return;
589
+ }
590
+ catch (error) {
591
+ rmSync(tmp, { force: true });
592
+ if (!isErrno(error, "EEXIST"))
593
+ throw error;
594
+ let pid = Number.NaN;
595
+ try {
596
+ pid = Number(readFileSync(lockPath, "utf8").trim());
597
+ }
598
+ catch {
599
+ continue;
600
+ }
601
+ if (pidAlive(pid)) {
602
+ throw new Error(`Another kiln tick is running (pid ${pid}).`);
603
+ }
604
+ rmSync(lockPath, { force: true });
605
+ }
606
+ }
607
+ throw new Error(`Could not acquire tick lock ${lockPath}.`);
608
+ }
609
+ function releaseLock(lockPath) {
610
+ try {
611
+ if (readFileSync(lockPath, "utf8").trim() === String(process.pid))
612
+ rmSync(lockPath, { force: true });
613
+ }
614
+ catch {
615
+ // The lock file is already gone.
616
+ }
617
+ }
618
+ 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
+ }
628
+ }
629
+ function isErrno(error, code) {
630
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
631
+ }
632
+ function errorMessage(error) {
633
+ return error instanceof Error ? error.message : String(error);
634
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export class KilnConfigError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "KilnConfigError";
5
+ }
6
+ }