@lchase/kiln 0.10.0 → 0.12.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 +27 -0
- package/assets/serve.css +210 -480
- package/assets/serve.js +125 -3
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- package/dist/cli.js +7 -1
- package/dist/config.js +5 -0
- package/dist/driver.js +48 -13
- package/dist/events.js +59 -2
- package/dist/host.js +53 -0
- package/dist/intake/jira.js +87 -0
- package/dist/jobs.js +4 -3
- package/dist/reconcile.js +7 -3
- package/dist/schemas.js +51 -3
- package/dist/serve-page.js +415 -324
- package/dist/serve.js +196 -15
- package/dist/settings.js +40 -1
- package/dist/triggers.js +787 -0
- package/dist/workflow.js +18 -15
- package/package.json +3 -2
- package/prompts/kiln/review.md +13 -0
- package/workflows/review.yaml +64 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { attentionReason, scheduledRetry } from "./attention.js";
|
|
3
|
+
import { feedbackRoundLabel } from "./feedback.js";
|
|
4
|
+
import { ledgerRows } from "./ledger.js";
|
|
5
|
+
import { localDay, spendSnapshot } from "./rails.js";
|
|
6
|
+
/**
|
|
7
|
+
* Lists, stats, and job pages for the serve shell. Job state still comes
|
|
8
|
+
* from `jobState` (via the status view). Everything else is read off the
|
|
9
|
+
* event log. Last-seen is not stored here: "since you last looked" is one
|
|
10
|
+
* browser, so the page keeps that map in localStorage and this module only
|
|
11
|
+
* counts events after a timestamp the caller supplies.
|
|
12
|
+
*/
|
|
13
|
+
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
14
|
+
const PHASE_OF = {
|
|
15
|
+
spec: "spec",
|
|
16
|
+
build: "build",
|
|
17
|
+
verify: "build",
|
|
18
|
+
security: "review",
|
|
19
|
+
ux: "review",
|
|
20
|
+
ui: "review",
|
|
21
|
+
code: "review",
|
|
22
|
+
approver: "approve",
|
|
23
|
+
approve: "approve",
|
|
24
|
+
land: "land",
|
|
25
|
+
explore: "explore",
|
|
26
|
+
};
|
|
27
|
+
const PHASE_LABEL = {
|
|
28
|
+
spec: "Spec",
|
|
29
|
+
build: "Build",
|
|
30
|
+
verify: "Verify",
|
|
31
|
+
security: "Review · security",
|
|
32
|
+
ux: "Review · ux",
|
|
33
|
+
ui: "Review · ui",
|
|
34
|
+
code: "Review · code",
|
|
35
|
+
approver: "Approve",
|
|
36
|
+
approve: "Gate",
|
|
37
|
+
land: "Land",
|
|
38
|
+
explore: "Explore",
|
|
39
|
+
};
|
|
40
|
+
export const STAT_WINDOWS = ["today", "7d", "30d"];
|
|
41
|
+
export function parseStatsWindow(raw) {
|
|
42
|
+
if (raw === null || raw === "" || raw === "today")
|
|
43
|
+
return "today";
|
|
44
|
+
if (raw === "7d" || raw === "30d")
|
|
45
|
+
return raw;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** `jobId|ISO` pairs from `seen` query params. A bad pair is ignored. */
|
|
49
|
+
export function parseSeenQuery(values) {
|
|
50
|
+
const seen = {};
|
|
51
|
+
for (const value of values) {
|
|
52
|
+
const split = value.indexOf("|");
|
|
53
|
+
if (split <= 0)
|
|
54
|
+
continue;
|
|
55
|
+
const jobId = value.slice(0, split);
|
|
56
|
+
const ts = value.slice(split + 1);
|
|
57
|
+
if (!JOB_ID.test(jobId) || !Number.isFinite(Date.parse(ts)))
|
|
58
|
+
continue;
|
|
59
|
+
seen[jobId] = ts;
|
|
60
|
+
}
|
|
61
|
+
return seen;
|
|
62
|
+
}
|
|
63
|
+
export function isInbox(job) {
|
|
64
|
+
if (job.canApprove || job.canReject || job.canRevise)
|
|
65
|
+
return true;
|
|
66
|
+
return job.state === "needs-human" && job.gates.length > 0 && !job.stopReason && !job.feedbackReason;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Needs a person, and not an open gate. A failure that still has a scheduled
|
|
70
|
+
* retry is in flight, so it stays off this list.
|
|
71
|
+
*/
|
|
72
|
+
export function isHand(job) {
|
|
73
|
+
if (isInbox(job) || scheduledRetry(job))
|
|
74
|
+
return false;
|
|
75
|
+
return job.state === "needs-human" || job.state === "failed" || job.state === "dead" || job.state === "invalid";
|
|
76
|
+
}
|
|
77
|
+
export function isFinished(job) {
|
|
78
|
+
return job.state === "done" || job.state === "rejected";
|
|
79
|
+
}
|
|
80
|
+
export function buildBoard(jobs, events) {
|
|
81
|
+
const rows = jobs.map((job) => toBoardJob(job, events));
|
|
82
|
+
const inbox = rows.filter((job) => job.inbox);
|
|
83
|
+
const hand = rows.filter((job) => job.hand);
|
|
84
|
+
const history = rows.filter((job) => job.finished);
|
|
85
|
+
const byRepo = new Map();
|
|
86
|
+
for (const job of rows) {
|
|
87
|
+
const list = byRepo.get(job.repo) ?? [];
|
|
88
|
+
list.push(job);
|
|
89
|
+
byRepo.set(job.repo, list);
|
|
90
|
+
}
|
|
91
|
+
const repos = [...byRepo.keys()]
|
|
92
|
+
.sort((left, right) => left.localeCompare(right))
|
|
93
|
+
.map((repo) => ({ repo, jobs: byRepo.get(repo) ?? [] }));
|
|
94
|
+
return { inbox, hand, history, repos, inboxCount: inbox.length, handCount: hand.length };
|
|
95
|
+
}
|
|
96
|
+
/** Events for this job strictly after `since`. No timestamp counts as zero. */
|
|
97
|
+
export function changesSince(events, jobId, since) {
|
|
98
|
+
if (!since)
|
|
99
|
+
return 0;
|
|
100
|
+
const cut = Date.parse(since);
|
|
101
|
+
if (!Number.isFinite(cut))
|
|
102
|
+
return 0;
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
if (event.job_id !== jobId)
|
|
106
|
+
continue;
|
|
107
|
+
const ts = Date.parse(event.ts);
|
|
108
|
+
if (Number.isFinite(ts) && ts > cut)
|
|
109
|
+
count += 1;
|
|
110
|
+
}
|
|
111
|
+
return count;
|
|
112
|
+
}
|
|
113
|
+
export function unseenChanges(events, seen, inboxIds) {
|
|
114
|
+
const ids = new Set(Object.keys(seen));
|
|
115
|
+
for (const event of events)
|
|
116
|
+
if (event.job_id)
|
|
117
|
+
ids.add(event.job_id);
|
|
118
|
+
const jobs = [];
|
|
119
|
+
let inbox = 0;
|
|
120
|
+
for (const jobId of [...ids].sort()) {
|
|
121
|
+
const count = changesSince(events, jobId, seen[jobId]);
|
|
122
|
+
if (count > 0)
|
|
123
|
+
jobs.push({ jobId, count });
|
|
124
|
+
if (inboxIds.has(jobId))
|
|
125
|
+
inbox += count;
|
|
126
|
+
}
|
|
127
|
+
return { jobs, inbox };
|
|
128
|
+
}
|
|
129
|
+
export function phaseFeed(events, jobId) {
|
|
130
|
+
const cards = [];
|
|
131
|
+
for (const event of events) {
|
|
132
|
+
if (event.job_id !== jobId)
|
|
133
|
+
continue;
|
|
134
|
+
if (event.event === "phase.started" && event.step) {
|
|
135
|
+
cards.push({
|
|
136
|
+
step: event.step,
|
|
137
|
+
label: PHASE_LABEL[event.step] ?? event.step,
|
|
138
|
+
outcome: "started",
|
|
139
|
+
ts: event.ts,
|
|
140
|
+
...(event.properties.iteration !== undefined ? { iteration: event.properties.iteration } : {}),
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (event.event === "phase.finished" && event.step) {
|
|
145
|
+
const open = [...cards].reverse().find((card) => card.step === event.step && card.outcome === "started");
|
|
146
|
+
const card = open ?? {
|
|
147
|
+
step: event.step,
|
|
148
|
+
label: PHASE_LABEL[event.step] ?? event.step,
|
|
149
|
+
outcome: "started",
|
|
150
|
+
ts: event.ts,
|
|
151
|
+
};
|
|
152
|
+
if (!open)
|
|
153
|
+
cards.push(card);
|
|
154
|
+
card.outcome = event.properties.outcome === "failed" ? "failed" : "completed";
|
|
155
|
+
card.ts = event.ts;
|
|
156
|
+
if (event.properties.iteration !== undefined)
|
|
157
|
+
card.iteration = event.properties.iteration;
|
|
158
|
+
if (event.properties.verdict)
|
|
159
|
+
card.verdict = event.properties.verdict;
|
|
160
|
+
if (event.properties.detail)
|
|
161
|
+
card.detail = event.properties.detail;
|
|
162
|
+
if (event.properties.test_command)
|
|
163
|
+
card.testCommand = event.properties.test_command;
|
|
164
|
+
if (event.properties.exit_code !== undefined)
|
|
165
|
+
card.exitCode = event.properties.exit_code;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (event.event === "gate.resolved" && event.properties.decision === "revise" && event.properties.note) {
|
|
169
|
+
cards.push({
|
|
170
|
+
step: event.step ?? "revise",
|
|
171
|
+
label: "Revise",
|
|
172
|
+
outcome: "completed",
|
|
173
|
+
ts: event.ts,
|
|
174
|
+
note: event.properties.note,
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (event.event === "pr.feedback") {
|
|
179
|
+
const trigger = event.properties.trigger === "ci" ? "CI" : "review";
|
|
180
|
+
cards.push({
|
|
181
|
+
step: "fix",
|
|
182
|
+
label: `Fix round ${event.properties.round} · ${trigger}`,
|
|
183
|
+
outcome: event.properties.outcome ? "completed" : "started",
|
|
184
|
+
ts: event.ts,
|
|
185
|
+
iteration: event.properties.round,
|
|
186
|
+
...(event.properties.outcome ? { detail: event.properties.outcome } : {}),
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (event.event === "run.failed") {
|
|
191
|
+
const card = [...cards].reverse().find((item) => item.outcome === "failed") ?? cards[cards.length - 1];
|
|
192
|
+
const klass = event.properties.class;
|
|
193
|
+
const fingerprint = shortFingerprint(event.properties.fingerprint);
|
|
194
|
+
if (card) {
|
|
195
|
+
if (klass)
|
|
196
|
+
card.failureClass = klass;
|
|
197
|
+
if (fingerprint)
|
|
198
|
+
card.fingerprint = fingerprint;
|
|
199
|
+
if (event.properties.detail && !card.detail)
|
|
200
|
+
card.detail = event.properties.detail;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
cards.push({
|
|
204
|
+
step: "run",
|
|
205
|
+
label: "Run failed",
|
|
206
|
+
outcome: "failed",
|
|
207
|
+
ts: event.ts,
|
|
208
|
+
...(klass ? { failureClass: klass } : {}),
|
|
209
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
210
|
+
...(event.properties.detail ? { detail: event.properties.detail } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return cards;
|
|
216
|
+
}
|
|
217
|
+
export function jobChanges(job, events, commits, diffStat) {
|
|
218
|
+
let branch = job.branch;
|
|
219
|
+
let base;
|
|
220
|
+
let prNumber = job.prNumber;
|
|
221
|
+
let prUrl = job.prUrl;
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
if (event.job_id !== job.jobId || event.event !== "pr.opened")
|
|
224
|
+
continue;
|
|
225
|
+
branch = event.properties.branch;
|
|
226
|
+
base = event.properties.base;
|
|
227
|
+
prNumber = event.properties.number;
|
|
228
|
+
prUrl = event.properties.url;
|
|
229
|
+
}
|
|
230
|
+
const diff = diffStat?.trim() || job.diffSummary?.trim() || undefined;
|
|
231
|
+
return {
|
|
232
|
+
...(branch ? { branch } : {}),
|
|
233
|
+
...(base ? { base } : {}),
|
|
234
|
+
...(prNumber ? { prNumber } : {}),
|
|
235
|
+
...(prUrl ? { prUrl } : {}),
|
|
236
|
+
...(diff ? { diffStat: diff } : {}),
|
|
237
|
+
commits: [...commits],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
export function jobRail(job, events, options) {
|
|
241
|
+
const failure = latestFailure(events, job.jobId);
|
|
242
|
+
const cost = jobCost(events, job.jobId);
|
|
243
|
+
let ci;
|
|
244
|
+
let risk;
|
|
245
|
+
for (const event of events) {
|
|
246
|
+
if (event.job_id !== job.jobId)
|
|
247
|
+
continue;
|
|
248
|
+
if (event.event === "pr.feedback" && event.properties.trigger === "ci") {
|
|
249
|
+
ci = event.properties.outcome === "fixed" || event.properties.outcome === "pushed" ? "recovered" : "failing";
|
|
250
|
+
}
|
|
251
|
+
if (event.event === "phase.finished" && event.properties.verdict && !passed(event.properties.verdict)) {
|
|
252
|
+
risk = event.properties.verdict;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const fixRounds = Math.max(fixRoundCount(events, job.jobId), job.feedbackRounds?.length ?? 0);
|
|
256
|
+
return {
|
|
257
|
+
repo: job.repo,
|
|
258
|
+
...(options.issue ? { issue: options.issue } : {}),
|
|
259
|
+
...(options.branch ? { branch: options.branch } : {}),
|
|
260
|
+
...(job.prUrl ? { prUrl: job.prUrl } : {}),
|
|
261
|
+
...(job.prNumber ? { prNumber: job.prNumber } : {}),
|
|
262
|
+
...(ci ? { ci } : {}),
|
|
263
|
+
...(risk ? { risk } : {}),
|
|
264
|
+
costUsd: cost,
|
|
265
|
+
attempts: { used: job.attemptCount, cap: job.maxAttempts },
|
|
266
|
+
fixRounds: { used: fixRounds, cap: options.maxFixRounds },
|
|
267
|
+
...(failure?.class ? { failureClass: failure.class } : {}),
|
|
268
|
+
...(failure?.fingerprint ? { fingerprint: shortFingerprint(failure.fingerprint) } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
export function jobDetail(input) {
|
|
272
|
+
const jobEvents = input.events.filter((event) => event.job_id === input.job.jobId);
|
|
273
|
+
const changes = jobChanges(input.job, input.events, input.commits ?? [], input.diffStat);
|
|
274
|
+
return {
|
|
275
|
+
job: toBoardJob(input.job, input.events),
|
|
276
|
+
overview: phaseFeed(input.events, input.job.jobId),
|
|
277
|
+
changes,
|
|
278
|
+
timeline: jobEvents,
|
|
279
|
+
rail: jobRail(input.job, input.events, {
|
|
280
|
+
...(input.issue ? { issue: input.issue } : {}),
|
|
281
|
+
maxFixRounds: input.maxFixRounds,
|
|
282
|
+
...(changes.branch ? { branch: changes.branch } : {}),
|
|
283
|
+
}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
export function statsFor(events, options) {
|
|
287
|
+
const now = options.now ?? new Date();
|
|
288
|
+
const since = windowStart(options.window, now);
|
|
289
|
+
const spend = spendSnapshot(events, options.cap, now);
|
|
290
|
+
const repos = new Map();
|
|
291
|
+
const phases = new Map();
|
|
292
|
+
const classes = new Map();
|
|
293
|
+
const repoOf = repoByJob(events);
|
|
294
|
+
let started = 0;
|
|
295
|
+
let finished = 0;
|
|
296
|
+
let failed = 0;
|
|
297
|
+
let windowUsd = 0;
|
|
298
|
+
for (const event of events) {
|
|
299
|
+
const ts = Date.parse(event.ts);
|
|
300
|
+
if (!Number.isFinite(ts) || ts < since.getTime())
|
|
301
|
+
continue;
|
|
302
|
+
if (event.event === "run.started")
|
|
303
|
+
started += 1;
|
|
304
|
+
if (event.event === "run.finished")
|
|
305
|
+
finished += 1;
|
|
306
|
+
if (event.event === "run.failed") {
|
|
307
|
+
failed += 1;
|
|
308
|
+
const name = event.properties.class ?? "unclassified";
|
|
309
|
+
classes.set(name, (classes.get(name) ?? 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
const usd = dollarCost(event);
|
|
312
|
+
if (usd === undefined)
|
|
313
|
+
continue;
|
|
314
|
+
windowUsd += usd;
|
|
315
|
+
const repo = (event.job_id && repoOf.get(event.job_id)) || "(no repo)";
|
|
316
|
+
repos.set(repo, (repos.get(repo) ?? 0) + usd);
|
|
317
|
+
const phase = phaseOfRun(events, event.run_id);
|
|
318
|
+
phases.set(phase, (phases.get(phase) ?? 0) + usd);
|
|
319
|
+
}
|
|
320
|
+
const rows = ledgerRows(events, { since, now });
|
|
321
|
+
return {
|
|
322
|
+
window: options.window,
|
|
323
|
+
since: since.toISOString(),
|
|
324
|
+
spend,
|
|
325
|
+
windowUsd,
|
|
326
|
+
costByRepo: [...repos.entries()].map(([repo, usd]) => ({ repo, usd })).sort((a, b) => a.repo.localeCompare(b.repo)),
|
|
327
|
+
costByPhase: [...phases.entries()].map(([phase, usd]) => ({ phase, usd })).sort((a, b) => a.phase.localeCompare(b.phase)),
|
|
328
|
+
runs: { started, finished, failed },
|
|
329
|
+
failuresByClass: [...classes.entries()]
|
|
330
|
+
.map(([name, count]) => ({ class: name, count }))
|
|
331
|
+
.sort((a, b) => a.class.localeCompare(b.class)),
|
|
332
|
+
interventions: { total: rows.reduce((sum, row) => sum + row.total, 0), rows },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
export function dayReport(events, options = {}) {
|
|
336
|
+
const now = options.now ?? new Date();
|
|
337
|
+
const day = localDay(now);
|
|
338
|
+
const spend = spendSnapshot(events, options.cap, now);
|
|
339
|
+
const titles = new Map();
|
|
340
|
+
const repos = repoByJob(events);
|
|
341
|
+
const runs = new Map();
|
|
342
|
+
for (const event of events) {
|
|
343
|
+
if (event.event === "job.added" && event.job_id)
|
|
344
|
+
titles.set(event.job_id, event.properties.title);
|
|
345
|
+
}
|
|
346
|
+
for (const event of events) {
|
|
347
|
+
if (!event.job_id)
|
|
348
|
+
continue;
|
|
349
|
+
if (event.event !== "run.started" && event.event !== "run.finished" && event.event !== "run.failed")
|
|
350
|
+
continue;
|
|
351
|
+
if (localDay(new Date(event.ts)) !== day)
|
|
352
|
+
continue;
|
|
353
|
+
const key = `${event.job_id}\0${event.run_id ?? ""}`;
|
|
354
|
+
const row = runs.get(key) ?? {
|
|
355
|
+
jobId: event.job_id,
|
|
356
|
+
repo: repos.get(event.job_id) ?? "(no repo)",
|
|
357
|
+
title: titles.get(event.job_id) ?? event.job_id,
|
|
358
|
+
runId: event.run_id,
|
|
359
|
+
outcome: "running",
|
|
360
|
+
usd: null,
|
|
361
|
+
at: event.ts,
|
|
362
|
+
};
|
|
363
|
+
if (event.event === "run.started" && event.properties.repo)
|
|
364
|
+
row.repo = event.properties.repo;
|
|
365
|
+
if (event.event === "run.finished") {
|
|
366
|
+
row.outcome = "ok";
|
|
367
|
+
const usd = dollarCost(event);
|
|
368
|
+
if (usd !== undefined)
|
|
369
|
+
row.usd = usd;
|
|
370
|
+
}
|
|
371
|
+
if (event.event === "run.failed") {
|
|
372
|
+
row.outcome = "failed";
|
|
373
|
+
if (event.properties.class)
|
|
374
|
+
row.failureClass = event.properties.class;
|
|
375
|
+
const usd = dollarCost(event);
|
|
376
|
+
if (usd !== undefined)
|
|
377
|
+
row.usd = usd;
|
|
378
|
+
}
|
|
379
|
+
if (event.ts > row.at)
|
|
380
|
+
row.at = event.ts;
|
|
381
|
+
runs.set(key, row);
|
|
382
|
+
}
|
|
383
|
+
const since = windowStart("today", now);
|
|
384
|
+
const ledger = ledgerRows(events, { since, now });
|
|
385
|
+
return {
|
|
386
|
+
day,
|
|
387
|
+
spend,
|
|
388
|
+
runs: [...runs.values()].sort((left, right) => (left.at < right.at ? 1 : left.at > right.at ? -1 : 0)),
|
|
389
|
+
interventions: { total: ledger.reduce((sum, row) => sum + row.total, 0), rows: ledger },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
export function commitsFromLog(text) {
|
|
393
|
+
const rows = [];
|
|
394
|
+
for (const record of text.split("\x1e")) {
|
|
395
|
+
const trimmed = record.trim();
|
|
396
|
+
if (!trimmed)
|
|
397
|
+
continue;
|
|
398
|
+
const [sha, subject, body] = trimmed.split("\x1f");
|
|
399
|
+
if (!sha || subject === undefined)
|
|
400
|
+
continue;
|
|
401
|
+
const row = { sha, subject };
|
|
402
|
+
const round = trailer(body ?? "", "Kiln-Round");
|
|
403
|
+
const kilnJob = trailer(body ?? "", "Kiln-Job");
|
|
404
|
+
if (round && /^\d+$/.test(round))
|
|
405
|
+
row.round = Number(round);
|
|
406
|
+
if (kilnJob)
|
|
407
|
+
row.kilnJob = kilnJob;
|
|
408
|
+
rows.push(row);
|
|
409
|
+
}
|
|
410
|
+
return rows;
|
|
411
|
+
}
|
|
412
|
+
/** `git log` of `branch` in `repoPath`. A missing branch is an empty list. */
|
|
413
|
+
export function readBranchCommits(repoPath, branch) {
|
|
414
|
+
if (!safeRef(branch))
|
|
415
|
+
return [];
|
|
416
|
+
const result = spawnSync("git", ["log", "--format=%H%x1f%s%x1f%b%x1e", branch], {
|
|
417
|
+
cwd: repoPath,
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
});
|
|
420
|
+
if (result.status !== 0)
|
|
421
|
+
return [];
|
|
422
|
+
return commitsFromLog(result.stdout ?? "");
|
|
423
|
+
}
|
|
424
|
+
/** `git diff --shortstat base...branch`. Failure is omitted, not invented. */
|
|
425
|
+
export function readDiffStat(repoPath, base, branch) {
|
|
426
|
+
if (!safeRef(base) || !safeRef(branch))
|
|
427
|
+
return undefined;
|
|
428
|
+
const result = spawnSync("git", ["diff", "--shortstat", `${base}...${branch}`], {
|
|
429
|
+
cwd: repoPath,
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
});
|
|
432
|
+
if (result.status !== 0)
|
|
433
|
+
return undefined;
|
|
434
|
+
const text = (result.stdout ?? "").trim();
|
|
435
|
+
return text || undefined;
|
|
436
|
+
}
|
|
437
|
+
export function branchOf(job, events) {
|
|
438
|
+
let branch = job.branch;
|
|
439
|
+
for (const event of events) {
|
|
440
|
+
if (event.job_id === job.jobId && event.event === "pr.opened")
|
|
441
|
+
branch = event.properties.branch;
|
|
442
|
+
}
|
|
443
|
+
return branch;
|
|
444
|
+
}
|
|
445
|
+
function toBoardJob(job, events) {
|
|
446
|
+
const inbox = isInbox(job);
|
|
447
|
+
const hand = isHand(job);
|
|
448
|
+
const finished = isFinished(job);
|
|
449
|
+
const failure = latestFailure(events, job.jobId);
|
|
450
|
+
const activity = jobActivity(events, job.jobId);
|
|
451
|
+
const waiting = scheduledRetry(job);
|
|
452
|
+
const reason = hand ? handLine(job, failure) : attentionReason(job);
|
|
453
|
+
const notes = [];
|
|
454
|
+
if (job.launchError?.trim())
|
|
455
|
+
notes.push(job.launchError.trim());
|
|
456
|
+
if (job.failureDetail?.trim())
|
|
457
|
+
notes.push(job.failureDetail.trim());
|
|
458
|
+
if (job.waitReason)
|
|
459
|
+
notes.push(job.waitReason);
|
|
460
|
+
const feedback = [];
|
|
461
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
462
|
+
const url = round.items.find((item) => item.url)?.url;
|
|
463
|
+
feedback.push({ label: feedbackRoundLabel(round), ...(url ? { url } : {}) });
|
|
464
|
+
}
|
|
465
|
+
let historyLine;
|
|
466
|
+
if (finished)
|
|
467
|
+
historyLine = finishedLine(job);
|
|
468
|
+
return {
|
|
469
|
+
jobId: job.jobId,
|
|
470
|
+
title: job.title || job.jobId,
|
|
471
|
+
repo: job.repo,
|
|
472
|
+
workflow: job.workflow,
|
|
473
|
+
state: job.state,
|
|
474
|
+
reason,
|
|
475
|
+
inbox,
|
|
476
|
+
hand,
|
|
477
|
+
finished,
|
|
478
|
+
retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) && job.gates.length === 0,
|
|
479
|
+
sandboxed: job.sandboxed === true,
|
|
480
|
+
canApprove: job.canApprove,
|
|
481
|
+
canReject: job.canReject,
|
|
482
|
+
canRevise: job.canRevise,
|
|
483
|
+
attemptCount: job.attemptCount,
|
|
484
|
+
maxAttempts: job.maxAttempts,
|
|
485
|
+
scheduledRetry: waiting,
|
|
486
|
+
...(waiting && job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
487
|
+
...(attemptLine(job) ? { attemptLine: attemptLine(job) } : {}),
|
|
488
|
+
...(failure?.class ? { failureClass: failure.class } : {}),
|
|
489
|
+
...(failure?.fingerprint ? { fingerprint: shortFingerprint(failure.fingerprint) } : {}),
|
|
490
|
+
...(activity.latest ? { latestEventAt: activity.latest } : {}),
|
|
491
|
+
eventCount: activity.count,
|
|
492
|
+
...(job.prUrl ? { prUrl: job.prUrl } : {}),
|
|
493
|
+
...(job.prNumber ? { prNumber: job.prNumber } : {}),
|
|
494
|
+
dependsOn: job.dependsOn.map((dep) => ({
|
|
495
|
+
jobId: dep.jobId,
|
|
496
|
+
state: dep.state,
|
|
497
|
+
...(dep.blockedDetail ? { detail: dep.blockedDetail } : {}),
|
|
498
|
+
})),
|
|
499
|
+
evidence: evidenceTexts(job),
|
|
500
|
+
feedback,
|
|
501
|
+
notes,
|
|
502
|
+
...(historyLine ? { historyLine } : {}),
|
|
503
|
+
findings: Boolean(job.findingsPath),
|
|
504
|
+
spec: Boolean(job.specPath),
|
|
505
|
+
log: Boolean(job.logPath),
|
|
506
|
+
...(job.logTail?.trim() ? { logTail: job.logTail.trim() } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function finishedLine(job) {
|
|
510
|
+
if (job.state === "rejected")
|
|
511
|
+
return "Rejected";
|
|
512
|
+
if (job.prNumber)
|
|
513
|
+
return `merged · PR #${job.prNumber}`;
|
|
514
|
+
if (job.prUrl && httpUrl(job.prUrl))
|
|
515
|
+
return "merged · PR";
|
|
516
|
+
return job.branch ? `landed locally · ${job.branch}` : "landed locally";
|
|
517
|
+
}
|
|
518
|
+
function httpUrl(url) {
|
|
519
|
+
try {
|
|
520
|
+
const parsed = new URL(url);
|
|
521
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function handLine(job, failure) {
|
|
528
|
+
let label = "Needs a person";
|
|
529
|
+
if (job.stopReason === "attempt-cap")
|
|
530
|
+
label = "Attempt cap";
|
|
531
|
+
else if (job.stopReason === "fingerprint")
|
|
532
|
+
label = "Same failure";
|
|
533
|
+
else if (job.feedbackReason === "feedback-exhausted")
|
|
534
|
+
label = "Fix round cap";
|
|
535
|
+
else if (job.feedbackReason === "feedback-ambiguous")
|
|
536
|
+
label = "Feedback unclear";
|
|
537
|
+
else if (job.state === "failed" || job.state === "dead" || job.state === "invalid")
|
|
538
|
+
label = attentionReason(job);
|
|
539
|
+
const parts = [label];
|
|
540
|
+
if (failure?.class)
|
|
541
|
+
parts.push(failure.class);
|
|
542
|
+
const fingerprint = shortFingerprint(failure?.fingerprint);
|
|
543
|
+
if (fingerprint)
|
|
544
|
+
parts.push(fingerprint);
|
|
545
|
+
return parts.join(" · ");
|
|
546
|
+
}
|
|
547
|
+
function evidenceTexts(job) {
|
|
548
|
+
const texts = [];
|
|
549
|
+
if (job.workflow !== "investigation" && job.diffSummary?.trim())
|
|
550
|
+
texts.push(job.diffSummary.trim());
|
|
551
|
+
for (const gate of job.gates) {
|
|
552
|
+
const body = gate.rendered?.trim() || gate.prompt?.trim();
|
|
553
|
+
if (body)
|
|
554
|
+
texts.push(body);
|
|
555
|
+
for (const review of gate.reviews ?? [])
|
|
556
|
+
if (review.summary?.trim())
|
|
557
|
+
texts.push(review.summary.trim());
|
|
558
|
+
for (const artifact of gate.artifacts ?? [])
|
|
559
|
+
if (artifact.summary?.trim())
|
|
560
|
+
texts.push(artifact.summary.trim());
|
|
561
|
+
}
|
|
562
|
+
return texts;
|
|
563
|
+
}
|
|
564
|
+
function attemptLine(job) {
|
|
565
|
+
if (job.attemptCount <= 0 && !job.lastFailureReason)
|
|
566
|
+
return undefined;
|
|
567
|
+
const parts = [];
|
|
568
|
+
if (job.attemptCount > 0)
|
|
569
|
+
parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
|
|
570
|
+
if (job.lastFailureReason)
|
|
571
|
+
parts.push(job.lastFailureReason);
|
|
572
|
+
return parts.join(" · ");
|
|
573
|
+
}
|
|
574
|
+
function jobActivity(events, jobId) {
|
|
575
|
+
let count = 0;
|
|
576
|
+
let latest;
|
|
577
|
+
for (const event of events) {
|
|
578
|
+
if (event.job_id !== jobId)
|
|
579
|
+
continue;
|
|
580
|
+
count += 1;
|
|
581
|
+
if (!latest || event.ts > latest)
|
|
582
|
+
latest = event.ts;
|
|
583
|
+
}
|
|
584
|
+
return { count, ...(latest ? { latest } : {}) };
|
|
585
|
+
}
|
|
586
|
+
function latestFailure(events, jobId) {
|
|
587
|
+
let found;
|
|
588
|
+
for (const event of events) {
|
|
589
|
+
if (event.job_id !== jobId || event.event !== "run.failed")
|
|
590
|
+
continue;
|
|
591
|
+
found = {
|
|
592
|
+
...(event.properties.class ? { class: event.properties.class } : {}),
|
|
593
|
+
...(event.properties.fingerprint ? { fingerprint: event.properties.fingerprint } : {}),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return found;
|
|
597
|
+
}
|
|
598
|
+
function jobCost(events, jobId) {
|
|
599
|
+
let usd = 0;
|
|
600
|
+
let saw = false;
|
|
601
|
+
for (const event of events) {
|
|
602
|
+
if (event.job_id !== jobId)
|
|
603
|
+
continue;
|
|
604
|
+
const amount = dollarCost(event);
|
|
605
|
+
if (amount === undefined)
|
|
606
|
+
continue;
|
|
607
|
+
usd += amount;
|
|
608
|
+
saw = true;
|
|
609
|
+
}
|
|
610
|
+
return saw ? usd : null;
|
|
611
|
+
}
|
|
612
|
+
function fixRoundCount(events, jobId) {
|
|
613
|
+
let count = 0;
|
|
614
|
+
for (const event of events) {
|
|
615
|
+
if (event.job_id === jobId && event.event === "pr.feedback")
|
|
616
|
+
count += 1;
|
|
617
|
+
}
|
|
618
|
+
return count;
|
|
619
|
+
}
|
|
620
|
+
function shortFingerprint(value) {
|
|
621
|
+
if (!value)
|
|
622
|
+
return undefined;
|
|
623
|
+
return value.slice(0, 12);
|
|
624
|
+
}
|
|
625
|
+
function passed(verdict) {
|
|
626
|
+
return verdict === "PASS" || verdict === "APPROVE";
|
|
627
|
+
}
|
|
628
|
+
function dollarCost(event) {
|
|
629
|
+
if (event.event !== "run.finished" && event.event !== "run.failed")
|
|
630
|
+
return undefined;
|
|
631
|
+
const usd = event.properties.usd;
|
|
632
|
+
if (typeof usd !== "number" || !Number.isFinite(usd) || usd < 0)
|
|
633
|
+
return undefined;
|
|
634
|
+
return usd;
|
|
635
|
+
}
|
|
636
|
+
function phaseOfRun(events, runId) {
|
|
637
|
+
if (!runId)
|
|
638
|
+
return "run";
|
|
639
|
+
let workflow = "";
|
|
640
|
+
let phase = "run";
|
|
641
|
+
for (const event of events) {
|
|
642
|
+
if (event.run_id !== runId)
|
|
643
|
+
continue;
|
|
644
|
+
if (event.event === "run.started") {
|
|
645
|
+
workflow = event.properties.workflow;
|
|
646
|
+
if (workflow === "fix")
|
|
647
|
+
phase = "fix";
|
|
648
|
+
else if (workflow === "investigation")
|
|
649
|
+
phase = "explore";
|
|
650
|
+
}
|
|
651
|
+
if (workflow === "fix")
|
|
652
|
+
continue;
|
|
653
|
+
if ((event.event === "phase.started" || event.event === "phase.finished") && event.step && PHASE_OF[event.step]) {
|
|
654
|
+
phase = PHASE_OF[event.step];
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return phase;
|
|
658
|
+
}
|
|
659
|
+
function repoByJob(events) {
|
|
660
|
+
const repos = new Map();
|
|
661
|
+
for (const event of events) {
|
|
662
|
+
if (event.event === "job.added" && event.job_id)
|
|
663
|
+
repos.set(event.job_id, event.properties.repo);
|
|
664
|
+
}
|
|
665
|
+
for (const event of events) {
|
|
666
|
+
if (!event.job_id || repos.has(event.job_id))
|
|
667
|
+
continue;
|
|
668
|
+
if (event.event === "run.started" || event.event === "job.retried")
|
|
669
|
+
repos.set(event.job_id, event.properties.repo);
|
|
670
|
+
}
|
|
671
|
+
return repos;
|
|
672
|
+
}
|
|
673
|
+
function windowStart(window, now) {
|
|
674
|
+
if (window === "today") {
|
|
675
|
+
const day = localDay(now);
|
|
676
|
+
const [year, month, date] = day.split("-").map(Number);
|
|
677
|
+
return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1, 0, 0, 0, 0);
|
|
678
|
+
}
|
|
679
|
+
const days = window === "7d" ? 7 : 30;
|
|
680
|
+
return new Date(now.getTime() - days * 86_400_000);
|
|
681
|
+
}
|
|
682
|
+
function trailer(body, name) {
|
|
683
|
+
const match = new RegExp(`^${name}:\\s*(.+)$`, "m").exec(body);
|
|
684
|
+
return match?.[1]?.trim() || undefined;
|
|
685
|
+
}
|
|
686
|
+
function safeRef(ref) {
|
|
687
|
+
return ref.length > 0 && !ref.startsWith("-") && !ref.includes("..") && !/[\0\n]/.test(ref);
|
|
688
|
+
}
|