@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.
@@ -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
+ }
@@ -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(key, typeLooksLikeSpike(issue.issueType), input.workflow, warnings);
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({
@@ -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, warnings);
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, warnings) {
174
- return workflowForSpike(ticket.file, ticket.spike, requested, warnings);
182
+ function workflowFor(ticket, requested) {
183
+ return workflowForSpike(ticket.spike, requested);
175
184
  }
176
185
  /**
177
- * Spike and research work uses `investigation` when that workflow file exists.
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(label, spike, requested, warnings) {
189
+ export function workflowForSpike(spike, requested) {
182
190
  if (!spike)
183
191
  return requested;
184
- if (existsSync(join(packageRoot(), "workflows", "investigation.yaml")))
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 that is not on disk yet is
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.launchFailed && !job.run)
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
- /** `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged. */
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
- export function decide(observation) {
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];