@lchase/kiln 0.6.1 → 0.8.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 +47 -18
- 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/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 +2 -1
- package/workflows/feature.yaml +7 -4
package/dist/reliability.js
CHANGED
|
@@ -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
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
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 (
|
|
325
|
+
if (worktreeDirty(worktree))
|
|
265
326
|
return "kept";
|
|
266
|
-
if (
|
|
327
|
+
if (worktreeListed(root, worktree)) {
|
|
267
328
|
gitOk(root, ["worktree", "remove", worktree]);
|
|
268
329
|
}
|
|
269
|
-
|
|
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
|
-
/**
|
|
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", "--
|
|
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/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
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
});
|
package/dist/serve-page.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
|
5
5
|
import { feedbackRoundLabel } from "./feedback.js";
|
|
6
6
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
7
7
|
import { packageRoot } from "./paths.js";
|
|
8
|
+
import { listWorkflowNames } from "./workflow.js";
|
|
8
9
|
const REVIEWERS = [
|
|
9
10
|
["security", "sec"],
|
|
10
11
|
["ux", "ux"],
|
|
@@ -22,7 +23,8 @@ function kilnVersion() {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
const VERSION = kilnVersion();
|
|
25
|
-
export function renderConsole(css, attention) {
|
|
26
|
+
export function renderConsole(css, attention, page) {
|
|
27
|
+
const intake = page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
|
|
26
28
|
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
27
29
|
const stages = stagesFor(items.map((item) => item.job));
|
|
28
30
|
const grouped = new Map();
|
|
@@ -46,27 +48,34 @@ export function renderConsole(css, attention) {
|
|
|
46
48
|
<div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><h1>Kiln</h1>${version}</div>
|
|
47
49
|
<div class="status" id="status">${items.length === 1 ? "1 job" : `${items.length} jobs`}</div>
|
|
48
50
|
</header>
|
|
51
|
+
<p class="meta config-error" id="config-error" role="status"${intake.configError ? "" : " hidden"}>${escapeHtml(intake.configError ?? "")}</p>
|
|
49
52
|
<nav class="repos" aria-label="Filter by repo">${chips}</nav>
|
|
50
53
|
<main class="board" id="board" style="--cols: ${tracks}">
|
|
51
54
|
${columns}
|
|
52
55
|
</main>
|
|
53
56
|
<nav class="toolbar" aria-label="Kiln commands">
|
|
57
|
+
<button class="tool" type="button" id="add-job" data-tip="Add job" aria-label="Add job"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
54
58
|
<button class="tool" type="button" data-act="approve" data-tip="Approve" aria-label="Approve"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button>
|
|
55
59
|
<button class="tool" type="button" data-act="retry" data-tip="Retry" aria-label="Retry"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg></button>
|
|
56
60
|
<button class="tool" type="button" data-act="pr" data-tip="Open pull request" aria-label="Open pull request"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V10a3 3 0 0 0-3-3h-3"/></svg></button>
|
|
57
61
|
<button class="tool" type="button" id="notify" data-tip="Notify me" aria-label="Notify me"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 7H3s3 0 3-7"/><path d="M10 19a2 2 0 0 0 4 0"/></svg></button>
|
|
58
62
|
<span class="sep" aria-hidden="true"></span>
|
|
59
63
|
<button class="tool" type="button" id="compact" aria-pressed="true" data-tip="expand empty columns" aria-label="expand empty columns"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/></svg></button>
|
|
64
|
+
<span class="sep" aria-hidden="true"></span>
|
|
65
|
+
<button class="tool" type="button" id="open-settings" data-tip="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/></svg></button>
|
|
60
66
|
</nav>
|
|
61
67
|
<div class="scrim" data-close></div>
|
|
62
68
|
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Job">
|
|
63
|
-
${
|
|
69
|
+
${intakeSheet(intake, items)}
|
|
70
|
+
${settingsSheet()}
|
|
71
|
+
${items.map((item) => sheet(item, intake.csrf)).join("\n")}
|
|
64
72
|
</aside>`;
|
|
65
73
|
return document({
|
|
66
74
|
css,
|
|
67
75
|
title: `(${attention.needsYouCount}) Kiln`,
|
|
68
76
|
needsYou: attention.needsYouCount,
|
|
69
77
|
revision: attention.revision,
|
|
78
|
+
csrf: intake.csrf,
|
|
70
79
|
live: true,
|
|
71
80
|
body,
|
|
72
81
|
});
|
|
@@ -114,7 +123,7 @@ function card(item, stage) {
|
|
|
114
123
|
classes.push("retrying");
|
|
115
124
|
else if (stage === "needs-human")
|
|
116
125
|
classes.push("human");
|
|
117
|
-
if (stage === "merged")
|
|
126
|
+
if (stage === "done" || stage === "merged")
|
|
118
127
|
classes.push("merged");
|
|
119
128
|
if (stage === "queued")
|
|
120
129
|
classes.push("queued");
|
|
@@ -124,7 +133,7 @@ function card(item, stage) {
|
|
|
124
133
|
const branch = branchOf(job);
|
|
125
134
|
return `<div role="button" tabindex="0" class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-deps="${escapeHtml(deps)}" data-repo="${escapeHtml(job.repo)}" data-stage="${escapeHtml(stage)}">
|
|
126
135
|
<div class="card-top">${pulse}<span class="num">${escapeHtml(job.jobId)}</span>${flag}</div>
|
|
127
|
-
<div class="branch repo">${escapeHtml(job
|
|
136
|
+
<div class="branch repo">${escapeHtml(repoLabel(job))}</div>
|
|
128
137
|
<p class="title">${escapeHtml(job.title || job.jobId)}</p>
|
|
129
138
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
130
139
|
${sandboxHtml(job)}
|
|
@@ -135,7 +144,130 @@ ${waiting ? "" : noteHtml(item)}
|
|
|
135
144
|
${dependsHtml(job)}
|
|
136
145
|
</div>`;
|
|
137
146
|
}
|
|
138
|
-
function
|
|
147
|
+
function intakeSheet(intake, items) {
|
|
148
|
+
return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
|
|
149
|
+
<div class="d-head">
|
|
150
|
+
<div class="num">kiln add</div>
|
|
151
|
+
<h3>Add job</h3>
|
|
152
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
153
|
+
</div>
|
|
154
|
+
<form id="add-form" class="intake-form">
|
|
155
|
+
<div class="field"><span class="k">Repo</span><div class="picks" id="repo-pick">${repoButtons(intake.repos)}</div></div>
|
|
156
|
+
<label class="field"><span class="k">Workflow</span><select id="workflow" name="workflow">${workflowOptions(intake.workflows)}</select></label>
|
|
157
|
+
<div class="seg" role="tablist" aria-label="Source">
|
|
158
|
+
<button type="button" data-source="prompt" aria-pressed="true">Prompt</button>
|
|
159
|
+
<button type="button" data-source="spec" aria-pressed="false">Spec file</button>
|
|
160
|
+
<button type="button" data-source="tickets" aria-pressed="false">Tickets folder</button>
|
|
161
|
+
<button type="button" data-source="jira" aria-pressed="false">Jira</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div data-pane="prompt"><label class="field"><span class="k">Prompt</span><textarea id="prompt-text" rows="4" placeholder="What should this job do?"></textarea></label></div>
|
|
164
|
+
<div id="browser-field" hidden>
|
|
165
|
+
<div class="k" id="browser-label">Spec file</div>
|
|
166
|
+
<div class="fs" id="browser"></div>
|
|
167
|
+
<p class="meta" id="picked"></p>
|
|
168
|
+
</div>
|
|
169
|
+
<div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false"></label></div>
|
|
170
|
+
<label class="field" id="issue-field"><span class="k">Issue</span><input id="job-issue" type="text" placeholder="ABC-123" autocomplete="off" spellcheck="false"></label>
|
|
171
|
+
<label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
|
|
172
|
+
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items)}</div></fieldset>
|
|
173
|
+
<pre class="text preview" id="intake-preview" hidden></pre>
|
|
174
|
+
<p class="form-error" id="intake-error" role="alert" hidden></p>
|
|
175
|
+
<div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
|
|
176
|
+
</form>
|
|
177
|
+
</section>`;
|
|
178
|
+
}
|
|
179
|
+
function settingsSheet() {
|
|
180
|
+
return `<section class="sheet intake" id="settings" aria-label="Settings" hidden>
|
|
181
|
+
<div class="d-head">
|
|
182
|
+
<div class="num">kiln.config.json</div>
|
|
183
|
+
<h3>Settings</h3>
|
|
184
|
+
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
185
|
+
</div>
|
|
186
|
+
<form id="settings-form" class="intake-form">
|
|
187
|
+
<p class="meta" id="settings-path"></p>
|
|
188
|
+
<p class="saved" id="settings-saved" role="status" hidden>Saved</p>
|
|
189
|
+
<p class="form-error" id="settings-error" role="alert" hidden></p>
|
|
190
|
+
<div class="seg" role="tablist" aria-label="Settings sections">
|
|
191
|
+
<button type="button" data-settings-tab="repos" aria-pressed="true">Repos</button>
|
|
192
|
+
<button type="button" data-settings-tab="global" aria-pressed="false">Global</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="settings-repos">
|
|
195
|
+
<div id="repo-editor" hidden>
|
|
196
|
+
<label class="field"><span class="k">Name</span><input id="repo-name" type="text" autocomplete="off" spellcheck="false"></label>
|
|
197
|
+
<p class="field-error" data-error="name" hidden></p>
|
|
198
|
+
<div class="field"><span class="k">Path</span><div class="path-row"><input id="repo-path" type="text" autocomplete="off" spellcheck="false" placeholder="~/code/web"><button type="button" class="pick" id="repo-browse">Browse</button></div></div>
|
|
199
|
+
<div class="fs" id="repo-browser" hidden></div>
|
|
200
|
+
<p class="field-error" data-error="path" hidden></p>
|
|
201
|
+
<label class="field"><span class="k">Test command</span><input id="repo-test" type="text" autocomplete="off" spellcheck="false" placeholder="npm test"></label>
|
|
202
|
+
<p class="field-error" data-error="test" hidden></p>
|
|
203
|
+
<div class="field"><span class="k">Deliver</span><div class="seg" id="repo-deliver" role="group" aria-label="Deliver mode">
|
|
204
|
+
<button type="button" data-deliver="local" aria-pressed="true">local</button>
|
|
205
|
+
<button type="button" data-deliver="pr" aria-pressed="false">pr</button>
|
|
206
|
+
</div></div>
|
|
207
|
+
<p class="field-error" data-error="deliver" hidden></p>
|
|
208
|
+
<label class="field"><span class="k">Base branch</span><input id="repo-base" type="text" autocomplete="off" spellcheck="false" placeholder="main"></label>
|
|
209
|
+
<p class="field-error" data-error="base" hidden></p>
|
|
210
|
+
<label class="field"><span class="k">PR title template</span><input id="repo-pr-title" type="text" autocomplete="off" spellcheck="false"></label>
|
|
211
|
+
<p class="hint" id="hint-pr-title">Default [{issue}] {title}, which renders as [ABC-123] Title.</p>
|
|
212
|
+
<p class="field-error" data-error="prTitle" hidden></p>
|
|
213
|
+
<label class="field"><span class="k">Branch template</span><input id="repo-branch" type="text" autocomplete="off" spellcheck="false"></label>
|
|
214
|
+
<p class="hint" id="hint-branch">Default {issue}-{slug}, which renders as ABC-123-slug.</p>
|
|
215
|
+
<p class="field-error" data-error="branch" hidden></p>
|
|
216
|
+
<div class="d-actions"><button type="button" id="repo-done">Done</button></div>
|
|
217
|
+
</div>
|
|
218
|
+
<div id="repo-list"></div>
|
|
219
|
+
<div class="d-actions"><button type="button" id="repo-add">Add repo</button></div>
|
|
220
|
+
</div>
|
|
221
|
+
<div id="settings-global" hidden>
|
|
222
|
+
<div class="field"><span class="k">Sandbox</span><div class="seg" id="sandbox-mode" role="group" aria-label="Sandbox mode">
|
|
223
|
+
<button type="button" data-sandbox="off" aria-pressed="true">Off</button>
|
|
224
|
+
<button type="button" data-sandbox="docker" aria-pressed="false">Docker</button>
|
|
225
|
+
</div>
|
|
226
|
+
<p class="hint">Off leaves the agent unsandboxed. Docker runs agent steps in the sandbox.</p>
|
|
227
|
+
<p class="field-error" data-error="sandbox.mode" hidden></p></div>
|
|
228
|
+
<label class="field"><span class="k">Allow domains</span><textarea id="sandbox-domains" rows="3" placeholder="example.com" spellcheck="false"></textarea></label>
|
|
229
|
+
<p class="hint">One hostname per line. When set, this replaces the built-in host list.</p>
|
|
230
|
+
<p class="field-error" data-error="sandbox.allowDomains" hidden></p>
|
|
231
|
+
<label class="field"><span class="k">Max attempts</span><input id="max-attempts" type="number" min="1" step="1"></label>
|
|
232
|
+
<p class="hint" id="hint-attempts"></p>
|
|
233
|
+
<p class="field-error" data-error="maxAttempts" hidden></p>
|
|
234
|
+
<label class="field"><span class="k">Stall timeout (ms)</span><input id="stall-timeout" type="number" step="1"></label>
|
|
235
|
+
<p class="hint" id="hint-stall"></p>
|
|
236
|
+
<p class="field-error" data-error="stallTimeoutMs" hidden></p>
|
|
237
|
+
<label class="field"><span class="k">Max fix rounds</span><input id="max-fix-rounds" type="number" min="1" step="1"></label>
|
|
238
|
+
<p class="hint" id="hint-fix"></p>
|
|
239
|
+
<p class="field-error" data-error="maxFixRounds" hidden></p>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="d-actions"><button type="submit">Save</button></div>
|
|
242
|
+
</form>
|
|
243
|
+
</section>`;
|
|
244
|
+
}
|
|
245
|
+
function repoButtons(repos) {
|
|
246
|
+
if (repos.length === 0)
|
|
247
|
+
return `<p class="empty">No repos are registered.</p>`;
|
|
248
|
+
return repos
|
|
249
|
+
.map((repo, index) => `<button type="button" class="pick" data-repo="${escapeHtml(repo.name)}" data-path="${escapeHtml(repo.path)}" aria-pressed="${index === 0 ? "true" : "false"}">${escapeHtml(repo.name)}</button>`)
|
|
250
|
+
.join("");
|
|
251
|
+
}
|
|
252
|
+
function workflowOptions(names) {
|
|
253
|
+
if (names.length === 0)
|
|
254
|
+
return `<option value="">none</option>`;
|
|
255
|
+
return names
|
|
256
|
+
.map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
|
|
257
|
+
.join("");
|
|
258
|
+
}
|
|
259
|
+
function afterOptions(items) {
|
|
260
|
+
const open = items.filter((item) => item.job.state !== "done");
|
|
261
|
+
if (open.length === 0)
|
|
262
|
+
return `<p class="empty">No open jobs.</p>`;
|
|
263
|
+
return open
|
|
264
|
+
.map((item) => {
|
|
265
|
+
const job = item.job;
|
|
266
|
+
return `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`;
|
|
267
|
+
})
|
|
268
|
+
.join("");
|
|
269
|
+
}
|
|
270
|
+
function sheet(item, csrf) {
|
|
139
271
|
const job = item.job;
|
|
140
272
|
const files = displayFiles(job);
|
|
141
273
|
const round = lastRound(job);
|
|
@@ -149,7 +281,7 @@ function sheet(item) {
|
|
|
149
281
|
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
150
282
|
${sandboxHtml(job)}
|
|
151
283
|
${prLink(job)}
|
|
152
|
-
${actionsHtml(item)}
|
|
284
|
+
${actionsHtml(item, csrf)}
|
|
153
285
|
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
154
286
|
</div>
|
|
155
287
|
<div class="d-body">${filesNav(files, job)}</div>
|
|
@@ -184,17 +316,18 @@ function routeLinks(job) {
|
|
|
184
316
|
return "";
|
|
185
317
|
return `<div class="group">Open</div>${links.join("")}`;
|
|
186
318
|
}
|
|
187
|
-
function actionsHtml(item) {
|
|
319
|
+
function actionsHtml(item, csrf) {
|
|
188
320
|
const id = encodeURIComponent(item.job.jobId);
|
|
321
|
+
const token = `<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
|
|
189
322
|
const forms = [];
|
|
190
323
|
if (item.job.canApprove) {
|
|
191
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve"
|
|
324
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve">${token}<button type="submit">Approve</button></form>`);
|
|
192
325
|
}
|
|
193
326
|
if (item.job.canReject) {
|
|
194
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject"
|
|
327
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
|
|
195
328
|
}
|
|
196
329
|
if (item.retry) {
|
|
197
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry"
|
|
330
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
|
|
198
331
|
}
|
|
199
332
|
if (forms.length === 0)
|
|
200
333
|
return "";
|
|
@@ -268,10 +401,21 @@ function noteHtml(item) {
|
|
|
268
401
|
}
|
|
269
402
|
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
270
403
|
}
|
|
404
|
+
function repoLabel(job) {
|
|
405
|
+
if (job.deliver === "local" || job.deliver === "pr")
|
|
406
|
+
return `${job.repo} · ${job.deliver}`;
|
|
407
|
+
return job.repo;
|
|
408
|
+
}
|
|
271
409
|
function noteText(item) {
|
|
272
410
|
const job = item.job;
|
|
273
|
-
if (job.state === "done")
|
|
274
|
-
|
|
411
|
+
if (job.state === "done") {
|
|
412
|
+
if (job.prNumber)
|
|
413
|
+
return `merged · PR #${job.prNumber}`;
|
|
414
|
+
if (safeUrl(job.prUrl))
|
|
415
|
+
return "merged · PR";
|
|
416
|
+
const branch = branchOf(job);
|
|
417
|
+
return branch ? `landed locally · ${branch}` : "landed locally";
|
|
418
|
+
}
|
|
275
419
|
return item.reason;
|
|
276
420
|
}
|
|
277
421
|
function dependsHtml(job) {
|
|
@@ -348,7 +492,7 @@ function document(options) {
|
|
|
348
492
|
${options.css}
|
|
349
493
|
</style>
|
|
350
494
|
</head>
|
|
351
|
-
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}">
|
|
495
|
+
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}">
|
|
352
496
|
${options.body}
|
|
353
497
|
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
354
498
|
</body>
|