@lchase/kiln 0.1.0 → 0.4.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 +57 -17
- package/assets/serve.css +452 -0
- package/assets/serve.js +361 -0
- package/dist/attention.js +254 -0
- package/dist/board.js +326 -0
- package/dist/cli.js +105 -6
- package/dist/config.js +48 -28
- package/dist/driver.js +499 -34
- package/dist/findings.js +79 -0
- package/dist/intake/jira.js +4 -1
- package/dist/intake/tickets.js +23 -18
- package/dist/jobs.js +6 -1
- package/dist/reconcile.js +78 -21
- package/dist/reliability.js +441 -0
- package/dist/report.js +274 -73
- package/dist/schemas.js +23 -5
- package/dist/serve-page.js +352 -0
- package/dist/serve.js +373 -0
- package/dist/workflow.js +95 -10
- package/package.json +2 -1
- package/prompts/kiln/investigation.md +31 -0
- package/workflows/investigation.yaml +67 -0
package/dist/findings.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { jobBasename } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Workflow name for a spike, probe, or research job.
|
|
6
|
+
* An approved investigation is `done` with no pull request and no land.
|
|
7
|
+
*/
|
|
8
|
+
export const INVESTIGATION_WORKFLOW = "investigation";
|
|
9
|
+
/**
|
|
10
|
+
* Headings the findings markdown must contain. The investigation prompt
|
|
11
|
+
* asks for these spellings. Kiln stores the agent's markdown only when
|
|
12
|
+
* every one of them is present.
|
|
13
|
+
*/
|
|
14
|
+
export const FINDINGS_HEADINGS = [
|
|
15
|
+
"## Questions",
|
|
16
|
+
"## Answers",
|
|
17
|
+
"## Unconfirmed",
|
|
18
|
+
"## Implications for dependent tickets",
|
|
19
|
+
];
|
|
20
|
+
/** `<kilnDir>/findings/<jobId>.md`. The job file does not store this path. */
|
|
21
|
+
export function findingsFilePath(kilnDir, jobId) {
|
|
22
|
+
return join(kilnDir, "findings", `${jobBasename(jobId)}.md`);
|
|
23
|
+
}
|
|
24
|
+
/** Absolute path when the findings file is already on disk. */
|
|
25
|
+
export function existingFindingsPath(kilnDir, jobId) {
|
|
26
|
+
const path = findingsFilePath(kilnDir, jobId);
|
|
27
|
+
return existsSync(path) ? path : undefined;
|
|
28
|
+
}
|
|
29
|
+
export function findingsMarkdownComplete(markdown) {
|
|
30
|
+
return FINDINGS_HEADINGS.every((heading) => markdown.includes(heading));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Store the approved findings document. Writing the same text again does
|
|
34
|
+
* not touch the file. A document missing a required heading is refused.
|
|
35
|
+
*/
|
|
36
|
+
export function writeFindings(kilnDir, jobId, markdown) {
|
|
37
|
+
if (!findingsMarkdownComplete(markdown)) {
|
|
38
|
+
throw new Error(`Findings for ${jobId} are missing a required section. Expected ${FINDINGS_HEADINGS.join(", ")}.`);
|
|
39
|
+
}
|
|
40
|
+
const path = findingsFilePath(kilnDir, jobId);
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const body = markdown.endsWith("\n") ? markdown : `${markdown}\n`;
|
|
43
|
+
if (existsSync(path) && readFileSync(path, "utf8") === body)
|
|
44
|
+
return path;
|
|
45
|
+
writeFileSync(path, body);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
/** The explore step's `findings` artifact, when it is a complete markdown document. */
|
|
49
|
+
export function readFindingsMarkdown(checkout, runId) {
|
|
50
|
+
const path = join(checkout, ".runs", runId, "artifacts", "findings.json");
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
if (typeof parsed !== "object" || parsed === null || !("markdown" in parsed))
|
|
56
|
+
return undefined;
|
|
57
|
+
const markdown = parsed.markdown;
|
|
58
|
+
if (typeof markdown !== "string")
|
|
59
|
+
return undefined;
|
|
60
|
+
const text = markdown.trim();
|
|
61
|
+
if (!text || !findingsMarkdownComplete(text))
|
|
62
|
+
return undefined;
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Spec text handed to yak. Investigation dependencies contribute a
|
|
71
|
+
* `## Findings from <title>` section. The stored spec file is not modified.
|
|
72
|
+
*/
|
|
73
|
+
export function appendInvestigationFindings(specText, sections) {
|
|
74
|
+
if (sections.length === 0)
|
|
75
|
+
return specText;
|
|
76
|
+
const base = specText.endsWith("\n") ? specText : `${specText}\n`;
|
|
77
|
+
const blocks = sections.map((section) => `## Findings from ${section.title}\n\n${section.markdown.trim()}\n`);
|
|
78
|
+
return `${base}\n${blocks.join("\n")}`;
|
|
79
|
+
}
|
package/dist/intake/jira.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertCanStack, listValidJobs, renderAddedJob, writeJob } from "../jobs.js";
|
|
2
2
|
import { assertWorkflowName } from "../workflow.js";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
3
4
|
import { typeLooksLikeSpike, workflowForSpike } from "./tickets.js";
|
|
4
5
|
/** Jira issue keys, stored and matched in uppercase (`PROJ-123`). */
|
|
5
6
|
const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
@@ -29,7 +30,9 @@ export async function addFromJira(config, input) {
|
|
|
29
30
|
}
|
|
30
31
|
const issue = parseIssue(key, parseJson(issueResponse.body, key), acceptanceField);
|
|
31
32
|
const warnings = [];
|
|
32
|
-
const workflow = workflowForSpike(
|
|
33
|
+
const workflow = workflowForSpike(typeLooksLikeSpike(issue.issueType), input.workflow);
|
|
34
|
+
if (workflow === INVESTIGATION_WORKFLOW)
|
|
35
|
+
assertWorkflowName(workflow);
|
|
33
36
|
const dependsOn = mergeDepends(dependencyIds(config, issue.blockedBy, warnings), input.dependsOn);
|
|
34
37
|
assertDependsExist(config, dependsOn);
|
|
35
38
|
const specText = jiraIssueSpec({
|
package/dist/intake/tickets.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
3
4
|
import { listValidJobs, newJobId, writeJob } from "../jobs.js";
|
|
4
5
|
import { assertWorkflowName } from "../workflow.js";
|
|
5
|
-
import { packageRoot } from "../paths.js";
|
|
6
6
|
/** Context files larger than this are named in the spec instead of copied in. */
|
|
7
7
|
export const CONTEXT_INLINE_LIMIT = 40 * 1024;
|
|
8
8
|
const TICKET_FILE = /^(\d+)-.+\.md$/;
|
|
@@ -21,21 +21,30 @@ export function importTickets(config, input) {
|
|
|
21
21
|
assertWorkflowName(requested);
|
|
22
22
|
const context = resolveContext(dirAbs, input.contextPath, input.cwd);
|
|
23
23
|
const tickets = readTickets(dirAbs);
|
|
24
|
-
if (input.stack) {
|
|
25
|
-
const offender = tickets.find((ticket) => ticket.blockedBy.length > 1) ??
|
|
26
|
-
tickets.find((ticket) => ticket.blockedBy.length !== 1);
|
|
27
|
-
if (offender) {
|
|
28
|
-
throw new Error(`Ticket ${offender.file} has ${offender.blockedBy.length} dependencies. stack needs exactly one.`);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
24
|
const warnings = [];
|
|
32
25
|
const workflows = new Map();
|
|
33
26
|
for (const ticket of tickets) {
|
|
34
|
-
const workflow = workflowFor(ticket, requested
|
|
27
|
+
const workflow = workflowFor(ticket, requested);
|
|
35
28
|
if (workflow !== requested)
|
|
36
29
|
assertWorkflowName(workflow);
|
|
37
30
|
workflows.set(ticket.path, workflow);
|
|
38
31
|
}
|
|
32
|
+
if (input.stack) {
|
|
33
|
+
const offender = tickets.find((ticket) => ticket.blockedBy.length > 1) ??
|
|
34
|
+
tickets.find((ticket) => ticket.blockedBy.length !== 1);
|
|
35
|
+
if (offender) {
|
|
36
|
+
throw new Error(`Ticket ${offender.file} has ${offender.blockedBy.length} dependencies. stack needs exactly one.`);
|
|
37
|
+
}
|
|
38
|
+
for (const ticket of tickets) {
|
|
39
|
+
const dep = ticket.blockedBy[0];
|
|
40
|
+
if (!dep)
|
|
41
|
+
continue;
|
|
42
|
+
const upstream = tickets.find((candidate) => candidate.number === dep.number);
|
|
43
|
+
if (!upstream || workflows.get(upstream.path) !== INVESTIGATION_WORKFLOW)
|
|
44
|
+
continue;
|
|
45
|
+
throw new Error(`Ticket ${ticket.file} stacks on ${upstream.file}, which is an investigation. An investigation does not land a branch.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
39
48
|
const existing = new Map();
|
|
40
49
|
for (const job of listValidJobs(config)) {
|
|
41
50
|
if (job.source?.kind !== "tickets")
|
|
@@ -170,21 +179,17 @@ function composeSpec(ticketText, context) {
|
|
|
170
179
|
const body = context.text.endsWith("\n") ? context.text : `${context.text}\n`;
|
|
171
180
|
return `${base}\n## Background\n\n${body}`;
|
|
172
181
|
}
|
|
173
|
-
function workflowFor(ticket, requested
|
|
174
|
-
return workflowForSpike(ticket.
|
|
182
|
+
function workflowFor(ticket, requested) {
|
|
183
|
+
return workflowForSpike(ticket.spike, requested);
|
|
175
184
|
}
|
|
176
185
|
/**
|
|
177
|
-
* Spike and research work uses
|
|
178
|
-
* Otherwise the requested workflow is kept and the same warning is recorded.
|
|
186
|
+
* Spike and research work uses the investigation workflow.
|
|
179
187
|
* Jira intake calls this for issue types Spike and Research.
|
|
180
188
|
*/
|
|
181
|
-
export function workflowForSpike(
|
|
189
|
+
export function workflowForSpike(spike, requested) {
|
|
182
190
|
if (!spike)
|
|
183
191
|
return requested;
|
|
184
|
-
|
|
185
|
-
return "investigation";
|
|
186
|
-
warnings.push(`warning: ${label} looks like a spike or says no product code; workflows/investigation.yaml is missing, using ${requested}.`);
|
|
187
|
-
return requested;
|
|
192
|
+
return INVESTIGATION_WORKFLOW;
|
|
188
193
|
}
|
|
189
194
|
function readTickets(dirAbs) {
|
|
190
195
|
const names = readdirSync(dirAbs)
|
package/dist/jobs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
4
5
|
import { jobBasename } from "./paths.js";
|
|
5
6
|
import { JobSchema } from "./schemas.js";
|
|
6
7
|
import { assertWorkflowName } from "./workflow.js";
|
|
@@ -119,7 +120,8 @@ export function listValidJobs(config) {
|
|
|
119
120
|
/**
|
|
120
121
|
* `stack` needs one upstream in the same repo: the pull request has one base
|
|
121
122
|
* branch, and the worktree is cut from that branch. Zero dependencies and
|
|
122
|
-
* two or more are both rejected. An upstream
|
|
123
|
+
* two or more are both rejected. An investigation upstream is rejected: that
|
|
124
|
+
* workflow does not land a branch. An upstream that is not on disk yet is
|
|
123
125
|
* allowed here so an import batch can validate the count before every job
|
|
124
126
|
* exists; the same-repo check runs when the upstream file is already there.
|
|
125
127
|
*/
|
|
@@ -131,6 +133,9 @@ export function assertCanStack(repo, dependsOn, stack, known = []) {
|
|
|
131
133
|
}
|
|
132
134
|
const upstreamId = dependsOn[0];
|
|
133
135
|
const upstream = known.find((job) => job.id === upstreamId);
|
|
136
|
+
if (upstream?.workflow === INVESTIGATION_WORKFLOW) {
|
|
137
|
+
throw new Error(`stack cannot target investigation job ${upstreamId}. An investigation does not land a branch.`);
|
|
138
|
+
}
|
|
134
139
|
if (upstream && upstream.repo !== repo) {
|
|
135
140
|
throw new Error(`stack needs upstream ${upstreamId} to be in repo ${repo}. That job is in repo ${upstream.repo}.`);
|
|
136
141
|
}
|
package/dist/reconcile.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
+
/** Why a transient attempt failed. Written under `<kilnDir>/attempts`, not on the job. */
|
|
3
|
+
export const FAILURE_REASONS = ["stalled", "orphaned", "crashed", "failed"];
|
|
1
4
|
export function inFlightCount(observation) {
|
|
2
5
|
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
3
6
|
}
|
|
@@ -14,35 +17,51 @@ function inFlight(job, observation) {
|
|
|
14
17
|
* `approved` is the approve decision while the run is still alive. It holds
|
|
15
18
|
* a slot and does not unblock dependents. An ok run with no readable
|
|
16
19
|
* decision is `failed`, so a missing artifact cannot unblock the next job.
|
|
20
|
+
* An investigation is `done` on approve and never `in-review`.
|
|
17
21
|
*/
|
|
18
22
|
export function jobState(job, observation) {
|
|
19
|
-
if (job.
|
|
20
|
-
return "failed";
|
|
21
|
-
if (job.run) {
|
|
22
|
-
if (isLanded(job))
|
|
23
|
-
return landedState(job);
|
|
24
|
-
if (job.run.status === "ok" && job.run.gateDecision === "reject")
|
|
25
|
-
return "rejected";
|
|
26
|
-
if (job.run.status === "ok")
|
|
27
|
-
return "failed";
|
|
28
|
-
if (job.run.status === "failed")
|
|
29
|
-
return "failed";
|
|
30
|
-
if (job.run.status === "dead")
|
|
31
|
-
return "dead";
|
|
32
|
-
if (job.run.status === "suspended")
|
|
33
|
-
return "needs-human";
|
|
34
|
-
if (job.run.gateDecision === "approve")
|
|
35
|
-
return "approved";
|
|
23
|
+
if (job.launching && (!job.run || job.runRetired))
|
|
36
24
|
return "running";
|
|
37
|
-
|
|
25
|
+
if (job.run && !job.runRetired)
|
|
26
|
+
return activeRunState(job);
|
|
27
|
+
if (job.run && isLanded(job))
|
|
28
|
+
return landedState(job);
|
|
29
|
+
if (job.run?.status === "ok" && job.run.gateDecision === "reject")
|
|
30
|
+
return "rejected";
|
|
31
|
+
if (job.run?.status === "suspended")
|
|
32
|
+
return "needs-human";
|
|
33
|
+
if (job.retriesExhausted)
|
|
34
|
+
return "dead";
|
|
35
|
+
if (job.runRetired || job.run?.status === "failed" || job.run?.status === "dead")
|
|
36
|
+
return "failed";
|
|
38
37
|
if (job.launching)
|
|
39
38
|
return "running";
|
|
40
39
|
if (!dependenciesMet(job, observation))
|
|
41
40
|
return "blocked";
|
|
42
|
-
if (job.launchError)
|
|
41
|
+
if (job.launchError || job.launchFailed)
|
|
43
42
|
return "failed";
|
|
44
43
|
return "queued";
|
|
45
44
|
}
|
|
45
|
+
/** State of the current run, before the attempt ledger retires it. */
|
|
46
|
+
function activeRunState(job) {
|
|
47
|
+
if (!job.run)
|
|
48
|
+
return "queued";
|
|
49
|
+
if (isLanded(job))
|
|
50
|
+
return landedState(job);
|
|
51
|
+
if (job.run.status === "ok" && job.run.gateDecision === "reject")
|
|
52
|
+
return "rejected";
|
|
53
|
+
if (job.run.status === "ok")
|
|
54
|
+
return "failed";
|
|
55
|
+
if (job.run.status === "failed")
|
|
56
|
+
return "failed";
|
|
57
|
+
if (job.run.status === "dead")
|
|
58
|
+
return "dead";
|
|
59
|
+
if (job.run.status === "suspended")
|
|
60
|
+
return "needs-human";
|
|
61
|
+
if (job.run.gateDecision === "approve")
|
|
62
|
+
return "approved";
|
|
63
|
+
return "running";
|
|
64
|
+
}
|
|
46
65
|
/**
|
|
47
66
|
* After-merged (the default) needs every upstream `done`.
|
|
48
67
|
* Stacked needs its one upstream landed (approved and the run finished ok),
|
|
@@ -60,15 +79,22 @@ export function dependenciesMet(job, observation) {
|
|
|
60
79
|
export function isLanded(job) {
|
|
61
80
|
return job.run?.status === "ok" && job.run.gateDecision === "approve";
|
|
62
81
|
}
|
|
63
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged.
|
|
84
|
+
* An investigation is done once the gate says approve. There is no pull request.
|
|
85
|
+
*/
|
|
64
86
|
export function isDone(job) {
|
|
65
87
|
if (!isLanded(job))
|
|
66
88
|
return false;
|
|
89
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
90
|
+
return true;
|
|
67
91
|
if ((job.deliver ?? "local") !== "pr")
|
|
68
92
|
return true;
|
|
69
93
|
return job.delivery?.merged === true || job.delivery?.prState === "MERGED";
|
|
70
94
|
}
|
|
71
95
|
function landedState(job) {
|
|
96
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
97
|
+
return "done";
|
|
72
98
|
if ((job.deliver ?? "local") !== "pr")
|
|
73
99
|
return "done";
|
|
74
100
|
const delivery = job.delivery;
|
|
@@ -85,6 +111,8 @@ function landedState(job) {
|
|
|
85
111
|
function needsDelivery(job) {
|
|
86
112
|
if (job.error)
|
|
87
113
|
return false;
|
|
114
|
+
if (job.workflow === INVESTIGATION_WORKFLOW)
|
|
115
|
+
return false;
|
|
88
116
|
if ((job.deliver ?? "local") !== "pr")
|
|
89
117
|
return false;
|
|
90
118
|
if (!isLanded(job))
|
|
@@ -102,11 +130,35 @@ function canLaunch(job, observation) {
|
|
|
102
130
|
return false;
|
|
103
131
|
return dependenciesMet(job, observation);
|
|
104
132
|
}
|
|
105
|
-
|
|
133
|
+
/** A recorded transient failure whose backoff has elapsed. One retry per tick, ahead of a new job. */
|
|
134
|
+
function canRetry(job, observation, now) {
|
|
135
|
+
if (job.error || job.launching || job.retriesExhausted || job.ledgerError)
|
|
136
|
+
return false;
|
|
137
|
+
if (job.run && !job.runRetired)
|
|
138
|
+
return false;
|
|
139
|
+
if (!job.nextRetryAt)
|
|
140
|
+
return false;
|
|
141
|
+
const due = Date.parse(job.nextRetryAt);
|
|
142
|
+
if (!Number.isFinite(due) || due > now.getTime())
|
|
143
|
+
return false;
|
|
144
|
+
if (job.run?.gateDecision === "reject" || isLanded(job))
|
|
145
|
+
return false;
|
|
146
|
+
if (job.run?.status === "suspended")
|
|
147
|
+
return false;
|
|
148
|
+
return dependenciesMet(job, observation);
|
|
149
|
+
}
|
|
150
|
+
export function decide(observation, now = new Date()) {
|
|
106
151
|
const actions = observation.breadcrumbsToClear.map((crumb) => ({
|
|
107
152
|
type: "clear-breadcrumb",
|
|
108
153
|
path: crumb.path,
|
|
109
154
|
}));
|
|
155
|
+
for (const job of observation.jobs) {
|
|
156
|
+
if (job.error || job.workflow !== INVESTIGATION_WORKFLOW)
|
|
157
|
+
continue;
|
|
158
|
+
if (!isLanded(job) || job.findingsPath)
|
|
159
|
+
continue;
|
|
160
|
+
actions.push({ type: "record-findings", jobId: job.jobId });
|
|
161
|
+
}
|
|
110
162
|
for (const job of observation.jobs) {
|
|
111
163
|
if (!needsDelivery(job))
|
|
112
164
|
continue;
|
|
@@ -114,6 +166,11 @@ export function decide(observation) {
|
|
|
114
166
|
}
|
|
115
167
|
if (inFlightCount(observation) >= observation.maxConcurrent)
|
|
116
168
|
return actions;
|
|
169
|
+
const retry = observation.jobs.find((job) => canRetry(job, observation, now));
|
|
170
|
+
if (retry) {
|
|
171
|
+
actions.push({ type: "retry", jobId: retry.jobId, repo: retry.repo });
|
|
172
|
+
return actions;
|
|
173
|
+
}
|
|
117
174
|
const ready = observation.jobs.filter((job) => canLaunch(job, observation));
|
|
118
175
|
const healthy = ready.filter((job) => !job.launchError);
|
|
119
176
|
const picked = healthy[0] ?? ready[0];
|