@lchase/kiln 0.6.0 → 0.7.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.
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
3
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { dirname, join, resolve, sep } from "node:path";
5
5
  import { z } from "zod";
6
+ import { authenticationFailureLine } from "./auth.js";
6
7
  import { jobBasename } from "./paths.js";
7
8
  import { FAILURE_REASONS } from "./reconcile.js";
8
9
  /**
@@ -79,8 +80,10 @@ export function resetAttemptLedger(kilnDir, jobId, runId) {
79
80
  }
80
81
  /**
81
82
  * Stall, orphan, crash, and non-zero-exit handling for one observation.
82
- * Caller holds the tick lock. Safe to run twice: a run id is recorded once,
83
- * and worktree cleanup no-ops once the tree is gone or must be kept.
83
+ * A run that finishes with status `failed` takes the same worktree decision
84
+ * as a crash. Caller holds the tick lock. Safe to run twice: a run id is
85
+ * recorded once, and worktree cleanup no-ops once the tree is gone or must
86
+ * be kept.
84
87
  */
85
88
  export function recoverRuns(config, jobs, options) {
86
89
  const notes = [];
@@ -164,22 +167,79 @@ function pendingFailure(job, config, options) {
164
167
  if (runLive)
165
168
  return undefined;
166
169
  if (run && (run.status === "dead" || (run.status === "alive" && run.pid !== undefined && !isPidAlive(run.pid)))) {
167
- return options.startup
170
+ return classifyAuth(job, options.startup
168
171
  ? { reason: "orphaned", runId: run.runId, pid: run.pid, detail: "process gone at startup" }
169
- : { reason: "crashed", runId: run.runId, pid: run.pid, detail: "process exited" };
172
+ : { reason: "crashed", runId: run.runId, pid: run.pid, detail: "process exited" });
170
173
  }
174
+ // A normal failed exit (journal status failed) is not a crash. It still
175
+ // records an attempt and reaches cleanupRunWorktree below.
171
176
  if (run?.status === "failed") {
172
- return { reason: "failed", runId: run.runId, pid: run.pid, detail: "non-zero exit" };
177
+ return classifyAuth(job, { reason: "failed", runId: run.runId, pid: run.pid, detail: "non-zero exit" });
173
178
  }
174
179
  if (job.launchFailed) {
175
- return {
180
+ return classifyAuth(job, {
176
181
  reason: "crashed",
177
182
  ...(job.launchPid !== undefined ? { pid: job.launchPid } : {}),
178
183
  detail: "launch exited before a journal",
179
- };
184
+ });
180
185
  }
181
186
  return undefined;
182
187
  }
188
+ /** A rejected Claude login is recorded once and is not scheduled for retry. */
189
+ function classifyAuth(job, pending) {
190
+ const line = authenticationFailureLine(authTexts(job));
191
+ if (!line)
192
+ return pending;
193
+ return {
194
+ reason: "auth-failed",
195
+ ...(pending.runId ? { runId: pending.runId } : {}),
196
+ ...(pending.pid !== undefined ? { pid: pending.pid } : {}),
197
+ detail: line,
198
+ };
199
+ }
200
+ function authTexts(job) {
201
+ const texts = [];
202
+ for (const event of job.run?.stepEvents ?? []) {
203
+ if (event.detail)
204
+ texts.push(event.detail);
205
+ }
206
+ if (job.logTail)
207
+ texts.push(job.logTail);
208
+ const log = readAuthLog(job.logPath);
209
+ if (log)
210
+ texts.push(log);
211
+ return texts;
212
+ }
213
+ /** Tail of the kiln log. Yak prints the Claude stderr line here, past the 40-line status tail. */
214
+ function readAuthLog(logPath) {
215
+ if (!logPath)
216
+ return undefined;
217
+ let size;
218
+ try {
219
+ size = statSync(logPath).size;
220
+ }
221
+ catch {
222
+ return undefined;
223
+ }
224
+ if (size <= 0)
225
+ return undefined;
226
+ const take = Math.min(size, 256 * 1024);
227
+ let fd;
228
+ try {
229
+ fd = openSync(logPath, "r");
230
+ const buffer = Buffer.alloc(take);
231
+ readSync(fd, buffer, 0, take, size - take);
232
+ const text = buffer.toString("utf8").trim();
233
+ return text || undefined;
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ finally {
239
+ if (fd !== undefined)
240
+ closeSync(fd);
241
+ }
242
+ }
183
243
  function recordFailure(config, jobId, existing, pending, now) {
184
244
  const ledger = existing ?? { jobId, attempts: [] };
185
245
  if (alreadyRecorded(ledger, pending))
@@ -193,7 +253,7 @@ function recordFailure(config, jobId, existing, pending, now) {
193
253
  detail: pending.detail,
194
254
  };
195
255
  const attempts = [...ledger.attempts, attempt];
196
- const exhausted = attempts.length >= config.maxAttempts;
256
+ const exhausted = attempts.length >= config.maxAttempts || pending.reason === "auth-failed";
197
257
  const next = exhausted ? undefined : new Date(now.getTime() + retryDelayMs(attempts.length)).toISOString();
198
258
  const written = {
199
259
  jobId,
@@ -243,6 +303,8 @@ function writeLedger(kilnDir, ledger) {
243
303
  * Drop a failed run's worktree only when that loses no commits.
244
304
  * A branch with commits that are not on HEAD and not on a remote is kept,
245
305
  * and so is a dirty worktree. The checkout's own worktree is not removed.
306
+ * The branch is left in place.
307
+ * A second call, or a call when the worktree is already gone, does nothing.
246
308
  */
247
309
  export function cleanupRunWorktree(checkout, runId) {
248
310
  if (!safeRunId(runId))
@@ -253,25 +315,19 @@ export function cleanupRunWorktree(checkout, runId) {
253
315
  const worktreeRoot = resolve(root, ".yak", "worktrees");
254
316
  if (worktree === root || !worktree.startsWith(`${worktreeRoot}${sep}`))
255
317
  return "kept";
256
- const branchExists = gitOk(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
257
- const worktreeExists = existsSync(worktree);
258
- if (!branchExists && !worktreeExists)
318
+ if (!existsSync(worktree))
259
319
  return "absent";
320
+ const branchExists = gitOk(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
260
321
  if (checkedOut(root, branch))
261
322
  return "kept";
262
323
  if (branchExists && hasUnpushedCommits(root, branch))
263
324
  return "kept";
264
- if (worktreeExists && worktreeDirty(worktree))
325
+ if (worktreeDirty(worktree))
265
326
  return "kept";
266
- if (worktreeExists && worktreeListed(root, worktree)) {
327
+ if (worktreeListed(root, worktree)) {
267
328
  gitOk(root, ["worktree", "remove", worktree]);
268
329
  }
269
- if (existsSync(worktree))
270
- return "kept";
271
- if (branchExists)
272
- gitOk(root, ["branch", "-D", branch]);
273
- const stillBranch = gitOk(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
274
- return existsSync(worktree) || stillBranch ? "kept" : "removed";
330
+ return existsSync(worktree) ? "kept" : "removed";
275
331
  }
276
332
  function safeRunId(runId) {
277
333
  return RUN_ID.test(runId) && !runId.includes("..");
@@ -280,9 +336,14 @@ function checkedOut(checkout, branch) {
280
336
  const result = git(checkout, ["rev-parse", "--abbrev-ref", "HEAD"]);
281
337
  return result.status === 0 && result.stdout.trim() === branch;
282
338
  }
283
- /** Commits on the branch that are neither in HEAD nor on any remote. Failure keeps the branch. */
339
+ /**
340
+ * Commits on the branch that are neither in HEAD nor on any remote.
341
+ * `--not` toggles exclusion for every following revision, so it must appear
342
+ * once: a second `--not` would count commits that exist only on a remote.
343
+ * A git error keeps the worktree.
344
+ */
284
345
  function hasUnpushedCommits(checkout, branch) {
285
- const result = git(checkout, ["rev-list", "--count", branch, "--not", "HEAD", "--not", "--remotes"]);
346
+ const result = git(checkout, ["rev-list", "--count", branch, "--not", "HEAD", "--remotes"]);
286
347
  if (result.status !== 0)
287
348
  return true;
288
349
  const count = Number(result.stdout.trim());
package/dist/report.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
+ import { AUTH_FAILED_LINE } from "./auth.js";
4
5
  import { readBoardFiles, readReviewRounds } from "./board.js";
5
6
  import { feedbackRoundText } from "./feedback.js";
6
7
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
@@ -162,6 +163,8 @@ export function statusDetails(job) {
162
163
  lines.push(`attempts ${job.attemptCount}`);
163
164
  if (job.lastFailureReason)
164
165
  lines.push(`reason ${job.lastFailureReason}`);
166
+ if (job.lastFailureReason === "auth-failed")
167
+ lines.push(AUTH_FAILED_LINE);
165
168
  if (job.nextRetryAt)
166
169
  lines.push(`next retry ${job.nextRetryAt}`);
167
170
  if (job.state === "dead" && job.attemptCount > 0)
@@ -224,6 +227,7 @@ function toStatusJob(job, observation) {
224
227
  canApprove: false,
225
228
  canReject: false,
226
229
  steps: progressFor(job),
230
+ ...(job.deliver ? { deliver: job.deliver } : {}),
227
231
  ...(job.createdAt ? { createdAt: job.createdAt } : {}),
228
232
  ...(job.logTail ? { logTail: job.logTail } : {}),
229
233
  ...boardFields(job),
@@ -250,7 +254,8 @@ function toStatusJob(job, observation) {
250
254
  stack: job.stack === true,
251
255
  ...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
252
256
  ...(runId ? { runId } : {}),
253
- ...(showBranch ? { branch: `yak/${runId}` } : {}),
257
+ ...(showBranch ? { branch: job.delivery?.branch ?? `yak/${runId}` } : {}),
258
+ ...(job.deliver ? { deliver: job.deliver } : {}),
254
259
  ...(job.delivery?.prUrl ? { prUrl: job.delivery.prUrl } : {}),
255
260
  ...(prNumber ? { prNumber } : {}),
256
261
  ...(job.logPath ? { logPath: job.logPath } : {}),
@@ -397,7 +402,7 @@ export function renderTick(observation, actions, launches, delivers = [], findin
397
402
  : `findings ${recorded.jobId} missing`);
398
403
  }
399
404
  for (const note of notes) {
400
- const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : " dead";
405
+ const next = note.nextRetryAt ? ` next ${note.nextRetryAt}` : note.reason === "auth-failed" ? "" : " dead";
401
406
  lines.push(`${note.kind} ${note.jobId} ${note.reason} attempt ${note.attempt}${next}`);
402
407
  }
403
408
  for (const note of feedbackNotes) {
package/dist/sandbox.js CHANGED
@@ -14,10 +14,13 @@ import { jobBasename } from "./paths.js";
14
14
  * and land commits with the host git identity. Pull requests and
15
15
  * `gh pr view` run in the kiln process.
16
16
  *
17
- * @lchase/yak 0.7.1 drops `sandbox`, `image`, and `allowedDomains` while
18
- * loading workflow YAML, and calls `createAgentSandboxNetwork` with only
19
- * `api.anthropic.com`. Kiln refuses to launch while that is still true,
20
- * because the job would run on the host.
17
+ * Preflight searches the installed `@lchase/yak` bundle for
18
+ * `raw.agent.allowedDomains`. `@lchase/yak` 0.8.0 keeps that property
19
+ * access and passes the step allowlist into the sandbox proxy. An older
20
+ * package drops `sandbox`, `image`, and `allowedDomains` while loading
21
+ * workflow YAML and calls `createAgentSandboxNetwork` with only
22
+ * `api.anthropic.com`. Kiln refuses that pin, because the job would run
23
+ * on the host.
21
24
  */
22
25
  export const AGENT_SANDBOX_IMAGE = "yak-agent-sandbox:latest";
23
26
  export const PROXY_SANDBOX_IMAGE = "yak-agent-sandbox-proxy:latest";
@@ -167,7 +170,7 @@ export function sandboxPreflightMessage(config, facts) {
167
170
  return sandboxPreflight(config, facts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)));
168
171
  }
169
172
  /**
170
- * `docker` argv yak 0.7.1 builds for one sandboxed agent step
173
+ * `docker` argv yak builds for one sandboxed agent step
171
174
  * (`dockerAgentRunArgs` in `@lchase/yak`). Kiln does not spawn docker
172
175
  * itself. Credential values are copied through so a test can show the
173
176
  * shape; do not log the result when `env` holds a real token.
@@ -197,16 +200,21 @@ export function agentSandboxDockerArgs(input) {
197
200
  ...input.cliArgs,
198
201
  ];
199
202
  }
200
- /** Proxy sidecar argv from yak's `startProxyArgs`. */
201
- export function proxySandboxDockerArgs(networkName, allowedDomains) {
203
+ /**
204
+ * Proxy sidecar argv from yak's `startProxyArgs`. `--network` is the
205
+ * egress network (`bridge`, or `podman` when `bridge` is absent), not
206
+ * the internal sandbox network. Yak attaches that internal network only
207
+ * after the proxy accepts connections. Kiln does not spawn the proxy.
208
+ */
209
+ export function proxySandboxDockerArgs(egressNetwork, allowedDomains, containerName) {
202
210
  return [
203
211
  "run",
204
212
  "-d",
205
213
  "--rm",
206
214
  "--name",
207
- `${networkName}-proxy`,
215
+ containerName,
208
216
  "--network",
209
- networkName,
217
+ egressNetwork,
210
218
  "-e",
211
219
  `ALLOWED_DOMAINS=${allowedDomains.join(",")}`,
212
220
  PROXY_SANDBOX_IMAGE,
@@ -290,7 +298,7 @@ function missingPieces(agentImages, adapter, facts) {
290
298
  missing.push("no Claude credential for the sandbox. The container only receives ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. macOS keychain login is not forwarded. Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN for this kiln process.");
291
299
  }
292
300
  if (!yakDistHonorsAllowlist(facts.yakDist)) {
293
- missing.push(`installed @lchase/yak ${facts.yakVersion} does not pass allowedDomains from a workflow step into the sandbox proxy. @lchase/yak 0.7.1 drops sandbox, image, and allowedDomains when it loads workflow YAML, and the proxy allowlist stays api.anthropic.com. Kiln will not launch an unsandboxed run while sandbox mode is docker.`);
301
+ missing.push(`installed @lchase/yak ${facts.yakVersion} does not pass allowedDomains from a workflow step into the sandbox proxy. A package older than 0.8.0 drops sandbox, image, and allowedDomains when it loads workflow YAML, and the proxy allowlist stays api.anthropic.com. Kiln will not launch an unsandboxed run while sandbox mode is docker.`);
294
302
  }
295
303
  return missing;
296
304
  }
package/dist/schemas.js CHANGED
@@ -10,12 +10,35 @@ import { z } from "zod";
10
10
  * `pr` pushes that branch and opens a draft pull request against `base`.
11
11
  * `base` defaults to origin's default branch, discovered with git, when omitted.
12
12
  */
13
+ /** Jira-style issue keys (`ABC-123`). Stored and matched in uppercase. */
14
+ export const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
15
+ export function parseIssueKey(raw) {
16
+ const key = raw.trim().toUpperCase();
17
+ if (!ISSUE_KEY.test(key)) {
18
+ throw new Error(`Issue key ${JSON.stringify(raw)} must match KEY-123.`);
19
+ }
20
+ return key;
21
+ }
22
+ export const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
23
  export const RepoEntrySchema = z.object({
14
- path: z.string().min(1),
15
- test: z.string().min(1).optional(),
24
+ path: z.string().min(1, "Path is required."),
25
+ test: z.string().min(1, "Test command is empty.").optional(),
16
26
  deliver: z.enum(["local", "pr"]).optional(),
17
- base: z.string().min(1).optional(),
27
+ base: z.string().min(1, "Base branch is empty.").optional(),
28
+ /**
29
+ * Pull request title when the job has an `issue`. Placeholders `{issue}` and `{title}`.
30
+ * Default `[{issue}] {title}`. A job with no issue keeps its title and ignores this.
31
+ */
32
+ prTitle: z.string().min(1, "PR title template is empty.").optional(),
33
+ /**
34
+ * Remote branch for `deliver=pr`. Placeholders `{issue}`, `{slug}`, and `{jobId}`.
35
+ * Default `<issue>-<slug>`, or `kiln/<slug>-<short id>` when there is no issue.
36
+ * The local branch stays `yak/<run-id>`.
37
+ */
38
+ branch: z.string().min(1, "Branch template is empty.").optional(),
18
39
  });
40
+ /** Repo map shared by the config file and the settings API. */
41
+ export const KilnReposSchema = z.record(z.string().regex(REPO_NAME, "must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens"), RepoEntrySchema);
19
42
  /**
20
43
  * Optional Jira Cloud credentials in the gitignored kiln config.
21
44
  * `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` override these.
@@ -41,6 +64,14 @@ export const SandboxSettingsSchema = z.object({
41
64
  .optional(),
42
65
  allowDomains: z.array(z.string().regex(HOSTNAME, "must be a hostname")).min(1).optional(),
43
66
  });
67
+ /**
68
+ * Sandbox fields the settings UI edits. `image` stays on the file schema only
69
+ * and is preserved when settings are saved.
70
+ */
71
+ export const EditableSandboxSchema = SandboxSettingsSchema.pick({
72
+ mode: true,
73
+ allowDomains: true,
74
+ }).strict();
44
75
  const WORKFLOW_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
45
76
  /** No yak event and no log output for this long stalls a running job. `<= 0` disables it. */
46
77
  export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
@@ -48,26 +79,53 @@ export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
48
79
  export const DEFAULT_MAX_ATTEMPTS = 3;
49
80
  /** Pull request fix rounds before the job is needs-human (`feedback-exhausted`). */
50
81
  export const DEFAULT_MAX_FIX_ROUNDS = 2;
82
+ /**
83
+ * Milliseconds without a yak event or log output before a running job is
84
+ * killed and retried. Default 15 minutes. `0` or negative disables stall detection.
85
+ */
86
+ const stallTimeoutMs = z.number().int("Stall timeout must be a whole number of milliseconds.").optional();
87
+ /** Transient failures before the job is dead. Default 3. */
88
+ const maxAttempts = z
89
+ .number()
90
+ .int("Max attempts must be a whole number.")
91
+ .min(1, "Max attempts must be at least 1.")
92
+ .optional();
93
+ /**
94
+ * Fix rounds for an open pull request before the job is needs-human with
95
+ * reason `feedback-exhausted`. Default 2. Kiln does not merge.
96
+ */
97
+ const maxFixRounds = z
98
+ .number()
99
+ .int("Max fix rounds must be a whole number.")
100
+ .min(1, "Max fix rounds must be at least 1.")
101
+ .optional();
102
+ /**
103
+ * The subset `kiln serve` settings can edit. The file schema below uses these
104
+ * same field validators. Settings does not accept jira, tokens, adapter,
105
+ * workflows, or sandbox image.
106
+ */
107
+ export const EditableKilnSettingsSchema = z.object({
108
+ repos: KilnReposSchema,
109
+ stallTimeoutMs,
110
+ maxAttempts,
111
+ maxFixRounds,
112
+ sandbox: EditableSandboxSchema.optional(),
113
+ });
114
+ /** PUT /api/settings body. `revision` is the sha256 of the file the client loaded. */
115
+ export const SettingsWriteSchema = EditableKilnSettingsSchema.extend({
116
+ revision: z.string().min(1, "Reload settings and try again."),
117
+ }).strict();
51
118
  export const KilnConfigFileSchema = z.object({
52
119
  note: z.string().min(1).optional(),
53
120
  maxConcurrent: z.number().int().min(1).optional(),
54
- /**
55
- * Milliseconds without a yak event or log output before a running job is
56
- * killed and retried. Default 15 minutes. `0` or negative disables stall detection.
57
- */
58
- stallTimeoutMs: z.number().int().optional(),
59
- /** Transient failures before the job is dead. Default 3. */
60
- maxAttempts: z.number().int().min(1).optional(),
61
- /**
62
- * Fix rounds for an open pull request before the job is needs-human with
63
- * reason `feedback-exhausted`. Default 2. Kiln does not merge.
64
- */
65
- maxFixRounds: z.number().int().min(1).optional(),
121
+ stallTimeoutMs,
122
+ maxAttempts,
123
+ maxFixRounds,
66
124
  adapter: z.enum(["mock", "claude-code"]).optional(),
67
125
  sandbox: SandboxSettingsSchema.optional(),
68
126
  /** Per-workflow sandbox override. A set field replaces the global one; an omitted field inherits. */
69
127
  workflows: z.record(z.string().regex(WORKFLOW_KEY), z.object({ sandbox: SandboxSettingsSchema.optional() })).optional(),
70
- repos: z.record(z.string(), RepoEntrySchema),
128
+ repos: KilnReposSchema,
71
129
  jira: JiraConfigSchema.optional(),
72
130
  });
73
131
  /**
@@ -101,6 +159,8 @@ export const JobSchema = z.object({
101
159
  * a branch.
102
160
  */
103
161
  stack: z.boolean().optional(),
162
+ /** Issue id such as `ABC-123`. Omitted when intake had no key. */
163
+ issue: z.string().regex(ISSUE_KEY).optional(),
104
164
  source: JobSourceSchema.optional(),
105
165
  createdAt: z.string().min(1),
106
166
  });