@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.
- package/LICENSE +21 -0
- package/README.md +58 -21
- package/assets/serve.css +93 -0
- package/assets/serve.js +844 -0
- package/dist/attention.js +5 -0
- package/dist/auth.js +48 -0
- package/dist/board.js +10 -5
- package/dist/cli.js +31 -7
- package/dist/config.js +33 -10
- package/dist/delivery.js +53 -15
- package/dist/driver.js +29 -5
- package/dist/errors.js +9 -0
- package/dist/fs-browse.js +113 -0
- package/dist/host.js +7 -1
- package/dist/intake/file.js +1 -0
- package/dist/intake/jira.js +19 -4
- package/dist/intake/prompt.js +1 -0
- package/dist/intake/tickets.js +41 -1
- package/dist/intake-http.js +201 -0
- package/dist/jobs.js +5 -1
- package/dist/pr-names.js +116 -0
- package/dist/reconcile.js +5 -2
- package/dist/reliability.js +83 -22
- package/dist/report.js +7 -2
- package/dist/sandbox.js +18 -10
- package/dist/schemas.js +76 -16
- package/dist/serve-page.js +157 -13
- package/dist/serve.js +254 -16
- package/dist/settings.js +204 -0
- package/dist/workflow.js +17 -1
- package/package.json +3 -2
- package/workflows/feature.yaml +7 -4
package/dist/attention.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
3
|
+
import { AUTH_FAILED_LINE } from "./auth.js";
|
|
3
4
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
4
5
|
/**
|
|
5
6
|
* Attention order for `kiln serve`. Built from the same derived jobs as
|
|
@@ -104,8 +105,12 @@ export function attentionReason(job, tail = errorTail(job)) {
|
|
|
104
105
|
return question ? `Gate: ${oneLine(question)}` : "Waiting on a person";
|
|
105
106
|
}
|
|
106
107
|
case "dead":
|
|
108
|
+
if (job.lastFailureReason === "auth-failed")
|
|
109
|
+
return AUTH_FAILED_LINE;
|
|
107
110
|
return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
|
|
108
111
|
case "failed": {
|
|
112
|
+
if (job.lastFailureReason === "auth-failed")
|
|
113
|
+
return AUTH_FAILED_LINE;
|
|
109
114
|
if (job.sandboxError)
|
|
110
115
|
return `Sandbox: ${oneLine(job.sandboxError)}`;
|
|
111
116
|
const why = failureLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude credential shape, checked before tick or serve launches yak.
|
|
3
|
+
* A missing token is allowed: Claude Code can use a macOS keychain login.
|
|
4
|
+
* A set-but-unusable value is not, because Claude prefers it and then 401s.
|
|
5
|
+
* The text names the variable and never includes the value.
|
|
6
|
+
*/
|
|
7
|
+
export const AUTH_PREFLIGHT_PREFIX = "Claude credentials are not usable:";
|
|
8
|
+
/** Printed on an authentication failure. The attempt is not retried. */
|
|
9
|
+
export const AUTH_FAILED_LINE = "Claude login rejected (401) — check CLAUDE_CODE_OAUTH_TOKEN / claude login, then Retry";
|
|
10
|
+
const OAUTH_PREFIX = "sk-ant-oat";
|
|
11
|
+
export function claudeCredentialPreflight(env, adapter) {
|
|
12
|
+
if (adapter !== "claude-code")
|
|
13
|
+
return undefined;
|
|
14
|
+
const lines = [];
|
|
15
|
+
if (Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_OAUTH_TOKEN")) {
|
|
16
|
+
const value = (env.CLAUDE_CODE_OAUTH_TOKEN ?? "").trim();
|
|
17
|
+
if (!value) {
|
|
18
|
+
lines.push("CLAUDE_CODE_OAUTH_TOKEN is set but empty. Unset it, or export a token from `claude setup-token` (it starts with sk-ant-oat). Kiln will not launch.");
|
|
19
|
+
}
|
|
20
|
+
else if (!value.startsWith(OAUTH_PREFIX)) {
|
|
21
|
+
lines.push("CLAUDE_CODE_OAUTH_TOKEN is set but does not look like a Claude OAuth token (expected a value starting with sk-ant-oat). Kiln will not launch.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (Object.prototype.hasOwnProperty.call(env, "ANTHROPIC_API_KEY")) {
|
|
25
|
+
const value = (env.ANTHROPIC_API_KEY ?? "").trim();
|
|
26
|
+
if (!value) {
|
|
27
|
+
lines.push("ANTHROPIC_API_KEY is set but empty. Unset it, or export a key. Kiln will not launch.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (lines.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
return `${AUTH_PREFLIGHT_PREFIX}\n${lines.join("\n")}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* True when yak's failure detail or the run log shows a rejected Claude login.
|
|
36
|
+
* `401`, `Invalid bearer token`, and `Failed to authenticate` are the strings
|
|
37
|
+
* the Claude CLI and the API put on stderr. Yak copies that stderr tail into
|
|
38
|
+
* the `step.failed` detail and prints the same line on its own stderr, which
|
|
39
|
+
* Kiln stores under `<kiln-dir>/logs`.
|
|
40
|
+
*/
|
|
41
|
+
export function authenticationFailureLine(texts) {
|
|
42
|
+
const haystack = texts.join("\n");
|
|
43
|
+
if (!haystack)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (/Invalid bearer token|Failed to authenticate|\b401\b/.test(haystack))
|
|
46
|
+
return AUTH_FAILED_LINE;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
package/dist/board.js
CHANGED
|
@@ -11,7 +11,8 @@ const CATALOG = [
|
|
|
11
11
|
{ id: "explore", label: "Explore", who: "investigator · opus" },
|
|
12
12
|
{ id: "land", label: "Land", who: "git commit" },
|
|
13
13
|
{ id: "pr-review", label: "PR review", who: "pr-sync" },
|
|
14
|
-
{ id: "
|
|
14
|
+
{ id: "done", label: "Done", who: "local", tone: "merged" },
|
|
15
|
+
{ id: "merged", label: "Merged", who: "pr", tone: "merged" },
|
|
15
16
|
{ id: "needs-human", label: "Needs human", who: "approve · retry", tone: "human" },
|
|
16
17
|
];
|
|
17
18
|
const STEP_STAGE = {
|
|
@@ -41,7 +42,7 @@ export function stageOf(job) {
|
|
|
41
42
|
if (job.state === "in-review")
|
|
42
43
|
return "pr-review";
|
|
43
44
|
if (job.state === "done")
|
|
44
|
-
return "merged";
|
|
45
|
+
return job.prUrl || job.prNumber ? "merged" : "done";
|
|
45
46
|
const current = job.steps.find((step) => step.phase === "current" || step.phase === "failed");
|
|
46
47
|
if (job.state === "queued" || job.state === "blocked")
|
|
47
48
|
return "queued";
|
|
@@ -86,10 +87,14 @@ export function stagesFor(jobs) {
|
|
|
86
87
|
if (workflows.has("investigation"))
|
|
87
88
|
wanted.add("explore");
|
|
88
89
|
wanted.add("queued");
|
|
89
|
-
wanted.add("merged");
|
|
90
90
|
wanted.add("needs-human");
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
const reached = new Set(jobs.map((job) => stageOf(job)));
|
|
92
|
+
if (reached.has("done") || !reached.has("merged"))
|
|
93
|
+
wanted.add("done");
|
|
94
|
+
if (reached.has("merged"))
|
|
95
|
+
wanted.add("merged");
|
|
96
|
+
for (const id of reached)
|
|
97
|
+
wanted.add(id);
|
|
93
98
|
return CATALOG.filter((stage) => wanted.has(stage.id));
|
|
94
99
|
}
|
|
95
100
|
const REVIEW_FILE = /^(security|ux|ui|code)(?:\.(\d+))?\.json$/;
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ function usage() {
|
|
|
12
12
|
console.error([
|
|
13
13
|
"Usage: kiln repo add <name> <path> [--test <cmd>] [--deliver local|pr] [--base <branch>] [--config <file>]",
|
|
14
14
|
" kiln repo list [--config <file>]",
|
|
15
|
-
" kiln add <workflow> --repo <name> (--spec <file.md> | --prompt <text> | --jira <KEY>) [--title <text>] [--after <jobId>]... [--stack] [--dry-run] [--config <file>]",
|
|
15
|
+
" kiln add <workflow> --repo <name> (--spec <file.md> | --prompt <text> | --jira <KEY>) [--title <text>] [--issue <KEY>] [--after <jobId>]... [--stack] [--dry-run] [--config <file>]",
|
|
16
16
|
" kiln import <dir> --repo <name> [--workflow <name>] [--context <file.md>] [--stack] [--dry-run] [--config <file>]",
|
|
17
17
|
" kiln tick [--config <file>]",
|
|
18
18
|
" kiln status [jobId] [--config <file>]",
|
|
@@ -22,8 +22,10 @@ function usage() {
|
|
|
22
22
|
" kiln sandbox [build] [--config <file>]",
|
|
23
23
|
" kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
|
|
24
24
|
"",
|
|
25
|
-
"repo add registers a checkout
|
|
26
|
-
"
|
|
25
|
+
"repo add registers a checkout and prints the deliver mode (default local)",
|
|
26
|
+
"and how to change it. Adding an existing name updates that repo in place.",
|
|
27
|
+
"A relative path is stored absolute, resolved from the working directory.",
|
|
28
|
+
"~ is expanded from the home directory.",
|
|
27
29
|
"",
|
|
28
30
|
"Config, jobs, logs, and deliveries live in one kiln home: --config <file>",
|
|
29
31
|
"if given, otherwise $KILN_HOME/kiln.config.json when KILN_HOME is set,",
|
|
@@ -34,6 +36,8 @@ function usage() {
|
|
|
34
36
|
"",
|
|
35
37
|
"add writes one job from a spec file, from --prompt text (--prompt - reads stdin),",
|
|
36
38
|
"or from a Jira Cloud issue (--jira PROJ-123). Pass one of those, not more.",
|
|
39
|
+
"--issue KEY stores an issue id (KEY-123). With deliver=pr the draft title",
|
|
40
|
+
"defaults to [KEY] title. A job with no issue keeps its title.",
|
|
37
41
|
"The repo and the workflow (workflows/<name>.yaml) must already exist.",
|
|
38
42
|
"--after names jobs that must be done before this one launches. --stack waits",
|
|
39
43
|
"for one upstream to land and opens the pull request against that branch.",
|
|
@@ -59,6 +63,7 @@ function usage() {
|
|
|
59
63
|
"15 minutes; 0 disables it) is killed and retried. A crash, a non-zero exit,",
|
|
60
64
|
"or a process that is already gone when tick or serve starts (orphaned) retries",
|
|
61
65
|
"after 10s, 20s, 40s, and so on, capped at 5 minutes, up to maxAttempts",
|
|
66
|
+
"A rejected Claude login (401 or an invalid bearer token) is auth-failed and is not retried.",
|
|
62
67
|
"(default 3). The job is then dead. A rejected gate is not retried.",
|
|
63
68
|
"Attempts are files under the kiln home, not fields on the job.",
|
|
64
69
|
"Once a pull request is open, tick watches it. A failed check or a review",
|
|
@@ -67,10 +72,17 @@ function usage() {
|
|
|
67
72
|
"job is needs-human with reason feedback-exhausted.",
|
|
68
73
|
"",
|
|
69
74
|
"serve listens on 127.0.0.1. The page is a board of workflow stages:",
|
|
70
|
-
"queued, spec, build, review, approve, land, PR review,
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
75
|
+
"queued, spec, build, review, approve, land, PR review, Done (landed locally),",
|
|
76
|
+
"Merged (pull request), and needs human. An empty board shows Done.",
|
|
77
|
+
"Empty stages collapse. Approve, reject, retry, and Add job call the same",
|
|
78
|
+
"functions as the CLI. Settings edits repos and the global limits in the",
|
|
79
|
+
"config file and writes it without a restart. POST and PUT send the page's",
|
|
80
|
+
"CSRF token. A request whose",
|
|
81
|
+
"Origin or Host is not this localhost port is rejected. Before each",
|
|
82
|
+
"request and each tick, serve re-reads the kiln config when the file's",
|
|
83
|
+
"mtime or size changed. A file that fails to load keeps the last good",
|
|
84
|
+
"config. A tick runs every 30 seconds unless --no-tick is set, using the",
|
|
85
|
+
"tick lock.",
|
|
74
86
|
"",
|
|
75
87
|
"sandbox prints the resolved mode, allowlist, and anything missing before a",
|
|
76
88
|
"sandboxed run can start. sandbox build builds the yak agent and proxy images",
|
|
@@ -151,6 +163,13 @@ function parseFlags(argv) {
|
|
|
151
163
|
i += 1;
|
|
152
164
|
continue;
|
|
153
165
|
}
|
|
166
|
+
if (flag === "--issue") {
|
|
167
|
+
if (!next || next.startsWith("--"))
|
|
168
|
+
usage();
|
|
169
|
+
bag.issue = next;
|
|
170
|
+
i += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
154
173
|
if (flag === "--after") {
|
|
155
174
|
if (!next || next.startsWith("--"))
|
|
156
175
|
usage();
|
|
@@ -263,6 +282,7 @@ async function addCommand(argv) {
|
|
|
263
282
|
repo: flags.repo,
|
|
264
283
|
key: flags.jira,
|
|
265
284
|
...(flags.title !== undefined ? { title: flags.title } : {}),
|
|
285
|
+
...(flags.issue !== undefined ? { issue: flags.issue } : {}),
|
|
266
286
|
dependsOn: flags.after,
|
|
267
287
|
...(flags.stack ? { stack: true } : {}),
|
|
268
288
|
dryRun: flags.dryRun,
|
|
@@ -278,6 +298,7 @@ async function addCommand(argv) {
|
|
|
278
298
|
repo: flags.repo,
|
|
279
299
|
specPath: flags.spec,
|
|
280
300
|
...(flags.title !== undefined ? { title: flags.title } : {}),
|
|
301
|
+
...(flags.issue !== undefined ? { issue: flags.issue } : {}),
|
|
281
302
|
dependsOn: flags.after,
|
|
282
303
|
...(flags.stack ? { stack: true } : {}),
|
|
283
304
|
cwd: process.cwd(),
|
|
@@ -287,6 +308,7 @@ async function addCommand(argv) {
|
|
|
287
308
|
repo: flags.repo,
|
|
288
309
|
prompt: flags.prompt ?? "",
|
|
289
310
|
...(flags.title !== undefined ? { title: flags.title } : {}),
|
|
311
|
+
...(flags.issue !== undefined ? { issue: flags.issue } : {}),
|
|
290
312
|
dependsOn: flags.after,
|
|
291
313
|
...(flags.stack ? { stack: true } : {}),
|
|
292
314
|
});
|
|
@@ -302,6 +324,7 @@ function importCommand(argv) {
|
|
|
302
324
|
flags.spec ||
|
|
303
325
|
flags.prompt ||
|
|
304
326
|
flags.title ||
|
|
327
|
+
flags.issue ||
|
|
305
328
|
flags.after.length > 0 ||
|
|
306
329
|
flags.test ||
|
|
307
330
|
flags.deliver ||
|
|
@@ -401,6 +424,7 @@ function hasIntakeFlags(flags) {
|
|
|
401
424
|
flags.prompt ||
|
|
402
425
|
flags.jira ||
|
|
403
426
|
flags.title ||
|
|
427
|
+
flags.issue ||
|
|
404
428
|
flags.workflow ||
|
|
405
429
|
flags.context ||
|
|
406
430
|
flags.dryRun ||
|
package/dist/config.js
CHANGED
|
@@ -2,9 +2,8 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { KilnConfigError } from "./errors.js";
|
|
5
|
-
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, } from "./schemas.js";
|
|
5
|
+
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, REPO_NAME, } from "./schemas.js";
|
|
6
6
|
const CONFIG_NAME = "kiln.config.json";
|
|
7
|
-
const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8
7
|
export function loadKilnConfig(configPath, options = {}) {
|
|
9
8
|
const home = options.home ?? homedir();
|
|
10
9
|
const resolvedPath = resolve(configPath);
|
|
@@ -24,15 +23,14 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
24
23
|
const repos = {};
|
|
25
24
|
for (const [name, entry] of Object.entries(parsed.data.repos)) {
|
|
26
25
|
assertRepoName(name);
|
|
27
|
-
const checkout =
|
|
28
|
-
if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
|
|
29
|
-
throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
|
|
30
|
-
}
|
|
26
|
+
const checkout = assertCheckoutDirectory(name, configDir, entry.path, home);
|
|
31
27
|
repos[name] = {
|
|
32
28
|
path: checkout,
|
|
33
29
|
...(entry.test ? { test: entry.test } : {}),
|
|
34
30
|
deliver: entry.deliver ?? "local",
|
|
35
31
|
...(entry.base ? { base: entry.base } : {}),
|
|
32
|
+
...(entry.prTitle ? { prTitle: entry.prTitle } : {}),
|
|
33
|
+
...(entry.branch ? { branchTemplate: entry.branch } : {}),
|
|
36
34
|
};
|
|
37
35
|
}
|
|
38
36
|
const kilnDir = configDir;
|
|
@@ -108,6 +106,12 @@ export function addRepo(options) {
|
|
|
108
106
|
const base = options.base ?? previousString(previous, "base");
|
|
109
107
|
if (base)
|
|
110
108
|
entry.base = base;
|
|
109
|
+
const prTitle = previousString(previous, "prTitle");
|
|
110
|
+
if (prTitle)
|
|
111
|
+
entry.prTitle = prTitle;
|
|
112
|
+
const branchTemplate = previousString(previous, "branch");
|
|
113
|
+
if (branchTemplate)
|
|
114
|
+
entry.branch = branchTemplate;
|
|
111
115
|
repos[options.name] = entry;
|
|
112
116
|
raw.repos = repos;
|
|
113
117
|
const parsed = KilnConfigFileSchema.safeParse(raw);
|
|
@@ -116,13 +120,21 @@ export function addRepo(options) {
|
|
|
116
120
|
}
|
|
117
121
|
mkdirSync(configDir, { recursive: true });
|
|
118
122
|
writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
123
|
+
const effectiveDeliver = options.deliver ?? previousDeliver(previous) ?? "local";
|
|
119
124
|
const lines = [`added ${options.name}`, `path ${checkout}`];
|
|
120
125
|
if (entry.test)
|
|
121
126
|
lines.push(`test ${entry.test}`);
|
|
122
|
-
|
|
123
|
-
lines.push(`deliver ${entry.deliver}`);
|
|
127
|
+
lines.push(`deliver ${effectiveDeliver}`);
|
|
124
128
|
if (entry.base)
|
|
125
129
|
lines.push(`base ${entry.base}`);
|
|
130
|
+
if (entry.prTitle)
|
|
131
|
+
lines.push(`prTitle ${entry.prTitle}`);
|
|
132
|
+
if (entry.branch)
|
|
133
|
+
lines.push(`branch ${entry.branch}`);
|
|
134
|
+
const change = effectiveDeliver === "pr"
|
|
135
|
+
? `kiln repo add ${options.name} ${options.repoPath} --deliver local`
|
|
136
|
+
: `kiln repo add ${options.name} ${options.repoPath} --deliver pr --base main`;
|
|
137
|
+
lines.push(`Change delivery with: ${change}`);
|
|
126
138
|
lines.push(`wrote ${configPath}`);
|
|
127
139
|
return `${lines.join("\n")}\n`;
|
|
128
140
|
}
|
|
@@ -137,10 +149,13 @@ export function renderRepoList(config) {
|
|
|
137
149
|
const bits = [name, repo.path];
|
|
138
150
|
if (repo.test)
|
|
139
151
|
bits.push(`test ${repo.test}`);
|
|
140
|
-
|
|
141
|
-
bits.push("deliver pr");
|
|
152
|
+
bits.push(`deliver ${repo.deliver}`);
|
|
142
153
|
if (repo.base)
|
|
143
154
|
bits.push(`base ${repo.base}`);
|
|
155
|
+
if (repo.prTitle)
|
|
156
|
+
bits.push(`prTitle ${repo.prTitle}`);
|
|
157
|
+
if (repo.branchTemplate)
|
|
158
|
+
bits.push(`branch ${repo.branchTemplate}`);
|
|
144
159
|
return bits.join(" ");
|
|
145
160
|
});
|
|
146
161
|
return `${lines.join("\n")}\n`;
|
|
@@ -230,6 +245,14 @@ export function resolveConfiguredPath(configDir, value, home) {
|
|
|
230
245
|
const expanded = expandHome(value, home);
|
|
231
246
|
return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
|
|
232
247
|
}
|
|
248
|
+
/** Resolved checkout. The loader and the settings API both refuse a missing directory. */
|
|
249
|
+
export function assertCheckoutDirectory(name, configDir, value, home) {
|
|
250
|
+
const checkout = resolveConfiguredPath(configDir, value, home);
|
|
251
|
+
if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
|
|
252
|
+
throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
|
|
253
|
+
}
|
|
254
|
+
return checkout;
|
|
255
|
+
}
|
|
233
256
|
function pathToStore(userPath, cwd, home) {
|
|
234
257
|
if (userPath === "~" || userPath.startsWith("~/"))
|
|
235
258
|
return userPath;
|
package/dist/delivery.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { HostError } from "./host.js";
|
|
5
5
|
import { readJobSpec } from "./jobs.js";
|
|
6
6
|
import { jobBasename } from "./paths.js";
|
|
7
|
+
import { chooseRemoteBranch, defaultRemoteBranch, formatBranchTemplate, isPublishableBranch, pullRequestTitle } from "./pr-names.js";
|
|
7
8
|
/**
|
|
8
9
|
* Derived per run, under `<kilnDir>/deliveries/<jobId>.json`. Not the job file.
|
|
9
10
|
* `pushed` is true once origin has this branch at `sha`. Re-tick uses it, and
|
|
@@ -69,7 +70,8 @@ export function clearLaunchError(kilnDir, jobId) {
|
|
|
69
70
|
*
|
|
70
71
|
* `deliver=local` without `--stack` is checkout HEAD.
|
|
71
72
|
* `deliver=pr` fetches and uses `origin/<base>` (configured, or origin's default).
|
|
72
|
-
* A stacked job uses the upstream `yak/<run-id>` tip
|
|
73
|
+
* A stacked job uses the local upstream `yak/<run-id>` tip. The caller passes
|
|
74
|
+
* that ref, not the remote pull request head.
|
|
73
75
|
*/
|
|
74
76
|
export function resolveLaunchBase(input) {
|
|
75
77
|
if (input.stack) {
|
|
@@ -86,36 +88,72 @@ export function resolveLaunchBase(input) {
|
|
|
86
88
|
return input.git.revParse(input.checkout, "HEAD");
|
|
87
89
|
}
|
|
88
90
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
+
* Remote branch this job would publish, before a delivery record exists.
|
|
92
|
+
* A branch template that is not a safe ref falls back to the default name.
|
|
93
|
+
*/
|
|
94
|
+
export function plannedRemoteBranch(job, template) {
|
|
95
|
+
const input = {
|
|
96
|
+
title: job.title,
|
|
97
|
+
jobId: job.id,
|
|
98
|
+
...(job.issue ? { issue: job.issue } : {}),
|
|
99
|
+
};
|
|
100
|
+
if (template?.trim()) {
|
|
101
|
+
const name = formatBranchTemplate(template, input);
|
|
102
|
+
if (isPublishableBranch(name))
|
|
103
|
+
return name;
|
|
104
|
+
}
|
|
105
|
+
return defaultRemoteBranch(input);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Push local `yak/<run-id>` to the remote branch and open one draft pull request.
|
|
109
|
+
* The delivery record stores the remote name. A later tick looks that name up
|
|
110
|
+
* and skips both the push and `gh pr create` when origin already has this commit.
|
|
111
|
+
* A recorded name, including an older `yak/<run-id>` record, is reused.
|
|
91
112
|
* Failures stay on this job (`HostError` text) and do not escape.
|
|
92
113
|
*/
|
|
93
114
|
export function deliverJob(request) {
|
|
94
|
-
const
|
|
115
|
+
const localBranch = `yak/${request.runId}`;
|
|
95
116
|
const prior = readDelivery(request.config.kilnDir, request.job.id);
|
|
96
117
|
const sameRun = prior?.runId === request.runId ? prior : undefined;
|
|
118
|
+
const repo = request.config.repos[request.job.repo];
|
|
119
|
+
let remoteName = sameRun?.branch;
|
|
97
120
|
try {
|
|
98
|
-
const localSha = request.host.git.revParse(request.checkout,
|
|
121
|
+
const localSha = request.host.git.revParse(request.checkout, localBranch);
|
|
99
122
|
if (sameRun?.prNumber && sameRun.prUrl && sameRun.sha === localSha) {
|
|
100
123
|
return { ok: true, detail: sameRun.prUrl, prUrl: sameRun.prUrl, prNumber: sameRun.prNumber };
|
|
101
124
|
}
|
|
125
|
+
if (!remoteName) {
|
|
126
|
+
remoteName = chooseRemoteBranch({
|
|
127
|
+
title: request.job.title,
|
|
128
|
+
jobId: request.job.id,
|
|
129
|
+
...(request.job.issue ? { issue: request.job.issue } : {}),
|
|
130
|
+
...(repo?.branchTemplate ? { template: repo.branchTemplate } : {}),
|
|
131
|
+
localSha,
|
|
132
|
+
remoteSha: (name) => request.host.git.remoteBranchSha(request.checkout, name),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
102
135
|
// Ask gh before pushing so a missing or logged-out CLI does not publish the branch.
|
|
103
|
-
let existing = request.host.github.findByHead(request.checkout,
|
|
104
|
-
const remoteSha = request.host.git.remoteBranchSha(request.checkout,
|
|
136
|
+
let existing = request.host.github.findByHead(request.checkout, remoteName);
|
|
137
|
+
const remoteSha = request.host.git.remoteBranchSha(request.checkout, remoteName);
|
|
105
138
|
if (remoteSha !== localSha) {
|
|
106
|
-
request.host.git.
|
|
139
|
+
request.host.git.publishBranch(request.checkout, localBranch, remoteName, request.baseBranch);
|
|
107
140
|
}
|
|
141
|
+
const title = pullRequestTitle({
|
|
142
|
+
title: request.job.title,
|
|
143
|
+
...(request.job.issue ? { issue: request.job.issue } : {}),
|
|
144
|
+
...(repo?.prTitle ? { template: repo.prTitle } : {}),
|
|
145
|
+
});
|
|
108
146
|
writeDelivery(request.config.kilnDir, {
|
|
109
147
|
jobId: request.job.id,
|
|
110
148
|
runId: request.runId,
|
|
111
|
-
branch,
|
|
149
|
+
branch: remoteName,
|
|
112
150
|
base: request.baseBranch,
|
|
113
151
|
sha: localSha,
|
|
114
152
|
pushed: true,
|
|
115
153
|
...(sameRun?.prNumber ? { prNumber: sameRun.prNumber, prUrl: sameRun.prUrl } : {}),
|
|
116
154
|
});
|
|
117
155
|
if (!existing)
|
|
118
|
-
existing = request.host.github.findByHead(request.checkout,
|
|
156
|
+
existing = request.host.github.findByHead(request.checkout, remoteName);
|
|
119
157
|
const pr = existing ??
|
|
120
158
|
(sameRun?.prNumber && sameRun.prUrl
|
|
121
159
|
? {
|
|
@@ -123,20 +161,20 @@ export function deliverJob(request) {
|
|
|
123
161
|
url: sameRun.prUrl,
|
|
124
162
|
state: "OPEN",
|
|
125
163
|
mergedAt: null,
|
|
126
|
-
head:
|
|
164
|
+
head: remoteName,
|
|
127
165
|
base: request.baseBranch,
|
|
128
166
|
}
|
|
129
167
|
: request.host.github.createDraft({
|
|
130
168
|
checkout: request.checkout,
|
|
131
|
-
head:
|
|
169
|
+
head: remoteName,
|
|
132
170
|
base: request.baseBranch,
|
|
133
|
-
title
|
|
171
|
+
title,
|
|
134
172
|
body: pullRequestBody(request),
|
|
135
173
|
}));
|
|
136
174
|
writeDelivery(request.config.kilnDir, {
|
|
137
175
|
jobId: request.job.id,
|
|
138
176
|
runId: request.runId,
|
|
139
|
-
branch,
|
|
177
|
+
branch: remoteName,
|
|
140
178
|
base: request.baseBranch,
|
|
141
179
|
sha: localSha,
|
|
142
180
|
pushed: true,
|
|
@@ -152,7 +190,7 @@ export function deliverJob(request) {
|
|
|
152
190
|
writeDelivery(request.config.kilnDir, {
|
|
153
191
|
jobId: request.job.id,
|
|
154
192
|
runId: request.runId,
|
|
155
|
-
branch,
|
|
193
|
+
branch: remoteName ?? plannedRemoteBranch(request.job, repo?.branchTemplate),
|
|
156
194
|
base: request.baseBranch,
|
|
157
195
|
...(base?.sha ? { sha: base.sha } : {}),
|
|
158
196
|
pushed: base?.pushed ?? false,
|
package/dist/driver.js
CHANGED
|
@@ -2,8 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
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 { claudeCredentialPreflight } from "./auth.js";
|
|
5
6
|
import { buildAttention } from "./attention.js";
|
|
6
|
-
import { clearLaunchError, deliverJob, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
7
|
+
import { clearLaunchError, deliverJob, plannedRemoteBranch, readDelivery, readLaunchError, resolveLaunchBase, writeDelivery, writeLaunchError, isMerged, } from "./delivery.js";
|
|
7
8
|
import { feedbackSpec, openFeedbackRound, planFeedback, readFeedbackLedger, writeFeedbackLedger, } from "./feedback.js";
|
|
8
9
|
import { appendInvestigationFindings, existingFindingsPath, findingsFilePath, INVESTIGATION_WORKFLOW, readFindingsMarkdown, writeFindings, } from "./findings.js";
|
|
9
10
|
import { defaultHost } from "./host.js";
|
|
@@ -50,8 +51,11 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
50
51
|
const feedbackNotes = watchPullRequests(config, world, host);
|
|
51
52
|
const actions = decide(world.observation, now);
|
|
52
53
|
const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
|
|
53
|
-
const
|
|
54
|
-
|
|
54
|
+
const heads = [
|
|
55
|
+
claudeCredentialPreflight(process.env, config.adapter),
|
|
56
|
+
facts ? sandboxPreflight(config, facts) : undefined,
|
|
57
|
+
].filter((line) => Boolean(line));
|
|
58
|
+
return heads.length > 0 ? `${heads.join("\n")}\n${body}` : body;
|
|
55
59
|
});
|
|
56
60
|
}
|
|
57
61
|
/** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
|
|
@@ -366,7 +370,7 @@ function deliverObserved(config, world, jobId, host) {
|
|
|
366
370
|
writeDelivery(config.kilnDir, {
|
|
367
371
|
jobId,
|
|
368
372
|
runId: observed.run.runId,
|
|
369
|
-
branch:
|
|
373
|
+
branch: plannedRemoteBranch(job, repo.branchTemplate),
|
|
370
374
|
base: repo.base ?? "unknown",
|
|
371
375
|
pushed: false,
|
|
372
376
|
error: message,
|
|
@@ -415,6 +419,7 @@ function specHandedToYak(config, world, job) {
|
|
|
415
419
|
}
|
|
416
420
|
return appendInvestigationFindings(base, sections);
|
|
417
421
|
}
|
|
422
|
+
/** Remote name the stacked pull request targets. Falls back to the local yak ref. */
|
|
418
423
|
function upstreamBranch(world, job) {
|
|
419
424
|
const upstreamId = job.dependsOn?.[0];
|
|
420
425
|
if (!upstreamId)
|
|
@@ -424,6 +429,16 @@ function upstreamBranch(world, job) {
|
|
|
424
429
|
return undefined;
|
|
425
430
|
return upstream.delivery?.branch ?? `yak/${upstream.run.runId}`;
|
|
426
431
|
}
|
|
432
|
+
/** Local `yak/<run-id>` the stacked worktree is cut from. Yak owns that ref. */
|
|
433
|
+
function upstreamLocalBranch(world, job) {
|
|
434
|
+
const upstreamId = job.dependsOn?.[0];
|
|
435
|
+
if (!upstreamId)
|
|
436
|
+
return undefined;
|
|
437
|
+
const upstream = world.observation.jobs.find((candidate) => candidate.jobId === upstreamId);
|
|
438
|
+
if (!upstream?.run)
|
|
439
|
+
return undefined;
|
|
440
|
+
return `yak/${upstream.run.runId}`;
|
|
441
|
+
}
|
|
427
442
|
function observedDelivery(kilnDir, host, checkout, jobId, runId) {
|
|
428
443
|
const record = readDelivery(kilnDir, jobId);
|
|
429
444
|
if (!record)
|
|
@@ -633,6 +648,9 @@ function markFeedback(kilnDir, jobId, reason) {
|
|
|
633
648
|
* retries on the next tick without consuming a round.
|
|
634
649
|
*/
|
|
635
650
|
function startFix(config, world, job, host, now, sandboxFacts) {
|
|
651
|
+
const refused = claudeCredentialPreflight(process.env, config.adapter);
|
|
652
|
+
if (refused)
|
|
653
|
+
return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
|
|
636
654
|
const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
|
|
637
655
|
const plan = observed?.feedbackPlan;
|
|
638
656
|
const repo = config.repos[job.repo];
|
|
@@ -707,6 +725,9 @@ function resolveCommit(host, checkout, sha) {
|
|
|
707
725
|
}
|
|
708
726
|
/** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
|
|
709
727
|
function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
|
|
728
|
+
const refused = claudeCredentialPreflight(process.env, config.adapter);
|
|
729
|
+
if (refused)
|
|
730
|
+
return { jobId: job.id, repo: job.repo, kind: "fix", output: refused };
|
|
710
731
|
const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
|
|
711
732
|
const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
|
|
712
733
|
const repo = config.repos[job.repo];
|
|
@@ -827,6 +848,9 @@ function spawnAgent(host, spec) {
|
|
|
827
848
|
return { pid: child.pid ?? 0 };
|
|
828
849
|
}
|
|
829
850
|
function launchJob(config, world, job, host, sandboxFacts) {
|
|
851
|
+
const refused = claudeCredentialPreflight(process.env, config.adapter);
|
|
852
|
+
if (refused)
|
|
853
|
+
return { jobId: job.id, repo: job.repo, output: refused };
|
|
830
854
|
const repo = config.repos[job.repo];
|
|
831
855
|
if (!repo) {
|
|
832
856
|
throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
|
|
@@ -857,7 +881,7 @@ function launchJob(config, world, job, host, sandboxFacts) {
|
|
|
857
881
|
deliver: repo.deliver,
|
|
858
882
|
stack: job.stack === true,
|
|
859
883
|
...(repo.base ? { configuredBase: repo.base } : {}),
|
|
860
|
-
...(job.stack ? { upstreamBranch:
|
|
884
|
+
...(job.stack ? { upstreamBranch: upstreamLocalBranch(world, job) } : {}),
|
|
861
885
|
git: host.git,
|
|
862
886
|
});
|
|
863
887
|
specText = specHandedToYak(config, world, job);
|
package/dist/errors.js
CHANGED
|
@@ -4,3 +4,12 @@ export class KilnConfigError extends Error {
|
|
|
4
4
|
this.name = "KilnConfigError";
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
/** A request the serve process should answer with `statusCode` and the message. */
|
|
8
|
+
export class HttpError extends Error {
|
|
9
|
+
statusCode;
|
|
10
|
+
constructor(statusCode, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "HttpError";
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
}
|
|
15
|
+
}
|