firstlight 0.0.0-stage → 0.2.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +386 -2
- package/dist/baseline.js +86 -0
- package/dist/cache.js +54 -0
- package/dist/checks.js +125 -0
- package/dist/cli.js +452 -0
- package/dist/config.js +327 -0
- package/dist/diagnostics.js +459 -0
- package/dist/errors.js +25 -0
- package/dist/eta.js +76 -0
- package/dist/names.js +67 -0
- package/dist/processes.js +56 -0
- package/dist/report.js +288 -0
- package/dist/store.js +360 -0
- package/dist/supervisor.js +188 -0
- package/dist/tree.js +60 -0
- package/package.json +58 -4
package/dist/report.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// How a run looks to the agent that asked: short plain text by default, or
|
|
2
|
+
// JSON Lines (one object per check, then one summary) with --json. It only
|
|
3
|
+
// formats a RunSnapshot and its estimate; deciding exit codes and next
|
|
4
|
+
// commands is the CLI's. A failed check's log is read through its parser,
|
|
5
|
+
// when it has one, so its diagnostics come before the tail, each with the
|
|
6
|
+
// evidence file and rerun command depug printed for it, when there are. `results --new`
|
|
7
|
+
// gets its own text and JSON here too, from a comparison the CLI made.
|
|
8
|
+
import { countFailures, isUnproven } from "./baseline.js";
|
|
9
|
+
import { bucketOf, isSettled } from "./checks.js";
|
|
10
|
+
import { formatDiagnostic, isParserName, parseDiagnostics } from "./diagnostics.js";
|
|
11
|
+
import { formatSeconds } from "./eta.js";
|
|
12
|
+
import { logPaths, readLogEnd, tailLines } from "./store.js";
|
|
13
|
+
export const TAIL_LINES = 40;
|
|
14
|
+
// With diagnostics, they carry the substance, and the tail only shows the
|
|
15
|
+
// context the parser did not read, such as a summary.
|
|
16
|
+
export const DIAGNOSTIC_TAIL_LINES = 10;
|
|
17
|
+
export const DIAGNOSTIC_LIMIT = 20;
|
|
18
|
+
// Every diagnostic in the log of one check of a run, through the parser
|
|
19
|
+
// that run recorded for it.
|
|
20
|
+
export function checkDiagnostics(run, dir, name) {
|
|
21
|
+
const parser = run.checks.find((check) => check.name === name)?.parser;
|
|
22
|
+
if (!isParserName(parser))
|
|
23
|
+
return [];
|
|
24
|
+
return parseDiagnostics(parser, readLogEnd(logPaths(dir, run.checks).get(name)), run.root);
|
|
25
|
+
}
|
|
26
|
+
// `shown` narrows the diagnostics printed, as `results --new` does; the
|
|
27
|
+
// tail stays short whenever the log has any.
|
|
28
|
+
function logReport(snapshot, view, file, shown) {
|
|
29
|
+
const all = checkDiagnostics(snapshot.run, snapshot.dir, view.name);
|
|
30
|
+
const printed = shown ?? all;
|
|
31
|
+
return {
|
|
32
|
+
diagnostics: printed.slice(0, DIAGNOSTIC_LIMIT),
|
|
33
|
+
more: Math.max(0, printed.length - DIAGNOSTIC_LIMIT),
|
|
34
|
+
tail: tailLines(file, all.length > 0 ? DIAGNOSTIC_TAIL_LINES : TAIL_LINES),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function comparedOf(comparison, view) {
|
|
38
|
+
return { baseline: comparison.baselines.get(view.name) ?? null, comparison: comparison.checks.get(view.name) };
|
|
39
|
+
}
|
|
40
|
+
export function summarize(snapshot, next, eta) {
|
|
41
|
+
const summary = {
|
|
42
|
+
type: "summary",
|
|
43
|
+
run: snapshot.run.id,
|
|
44
|
+
failed: [],
|
|
45
|
+
running: [],
|
|
46
|
+
passed: [],
|
|
47
|
+
cancelled: [],
|
|
48
|
+
next,
|
|
49
|
+
eta_ms: roundMs(eta.eta_ms),
|
|
50
|
+
};
|
|
51
|
+
for (const view of snapshot.checks)
|
|
52
|
+
summary[bucketOf(view.status)].push(view.name);
|
|
53
|
+
return summary;
|
|
54
|
+
}
|
|
55
|
+
function showsLog(view) {
|
|
56
|
+
return view.status === "failed" || view.status === "timed_out";
|
|
57
|
+
}
|
|
58
|
+
export function jsonLines(snapshot, next, eta) {
|
|
59
|
+
const lines = snapshot.checks.map((view) => checkJson(snapshot, view, eta));
|
|
60
|
+
lines.push(JSON.stringify(summarize(snapshot, next, eta)));
|
|
61
|
+
return `${lines.join("\n")}\n`;
|
|
62
|
+
}
|
|
63
|
+
// One line for the check, then, for a failed check, one line per
|
|
64
|
+
// diagnostic, without a trailing newline. With `compared`, the check line
|
|
65
|
+
// gains `new` and `baseline` (and `old_diagnostics` when it failed), and
|
|
66
|
+
// only the new diagnostics follow it.
|
|
67
|
+
export function checkJson(snapshot, view, eta, compared) {
|
|
68
|
+
const log = logPaths(snapshot.dir, snapshot.run.checks).get(view.name);
|
|
69
|
+
const checkEta = etaOf(eta, view);
|
|
70
|
+
const line = {
|
|
71
|
+
type: "check",
|
|
72
|
+
name: view.name,
|
|
73
|
+
status: view.status,
|
|
74
|
+
exit_code: view.exit_code,
|
|
75
|
+
elapsed_ms: view.elapsed_ms,
|
|
76
|
+
log,
|
|
77
|
+
eta_ms: roundMs(checkEta.eta_ms),
|
|
78
|
+
overdue: checkEta.overdue,
|
|
79
|
+
cached_from: view.cached_from,
|
|
80
|
+
};
|
|
81
|
+
if (view.orphan)
|
|
82
|
+
line.orphan = true;
|
|
83
|
+
if (compared !== undefined) {
|
|
84
|
+
line.new = compared.comparison?.isNew ?? false;
|
|
85
|
+
line.baseline = compared.baseline;
|
|
86
|
+
}
|
|
87
|
+
if (!showsLog(view))
|
|
88
|
+
return JSON.stringify(line);
|
|
89
|
+
if (compared !== undefined)
|
|
90
|
+
line.old_diagnostics = compared.comparison?.oldDiagnostics ?? 0;
|
|
91
|
+
const report = logReport(snapshot, view, log, compared?.comparison?.newDiagnostics);
|
|
92
|
+
if (report.more > 0)
|
|
93
|
+
line.more_diagnostics = report.more;
|
|
94
|
+
line.tail = report.tail;
|
|
95
|
+
const diagnostics = report.diagnostics.map((diagnostic) => JSON.stringify({
|
|
96
|
+
type: "diagnostic",
|
|
97
|
+
check: view.name,
|
|
98
|
+
file: diagnostic.file,
|
|
99
|
+
line: diagnostic.line,
|
|
100
|
+
col: diagnostic.col,
|
|
101
|
+
rule: diagnostic.rule,
|
|
102
|
+
message: diagnostic.message,
|
|
103
|
+
...depugFields(diagnostic),
|
|
104
|
+
}));
|
|
105
|
+
return [JSON.stringify(line), ...diagnostics].join("\n");
|
|
106
|
+
}
|
|
107
|
+
// `focus` puts one check first with its log, as `run` does for the check
|
|
108
|
+
// that failed first; the others get one status line each.
|
|
109
|
+
export function text(snapshot, next, eta, focus = null) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const ordered = focus === null
|
|
112
|
+
? snapshot.checks
|
|
113
|
+
: [...snapshot.checks.filter((v) => v.name === focus), ...snapshot.checks.filter((v) => v.name !== focus)];
|
|
114
|
+
for (const view of ordered) {
|
|
115
|
+
out.push(statusLine(snapshot, view, eta));
|
|
116
|
+
if (showsLog(view) && (focus === null || view.name === focus))
|
|
117
|
+
out.push(...logBlock(snapshot, view));
|
|
118
|
+
}
|
|
119
|
+
return `${out.join("\n")}\n${summaryText(snapshot, next, eta)}`;
|
|
120
|
+
}
|
|
121
|
+
// The lines after the check lines: the counts, then the next command.
|
|
122
|
+
export function summaryText(snapshot, next, eta) {
|
|
123
|
+
const out = [countsLine(snapshot, eta)];
|
|
124
|
+
if (next !== null) {
|
|
125
|
+
const waiting = !snapshot.checks.every(isSettled) && eta.eta_ms !== null;
|
|
126
|
+
out.push(waiting ? `next: ${next} (about ${formatSeconds(eta.eta_ms)} left)` : `next: ${next}`);
|
|
127
|
+
}
|
|
128
|
+
return `${out.join("\n")}\n`;
|
|
129
|
+
}
|
|
130
|
+
// One status change, as `follow` prints it. A running check gets no
|
|
131
|
+
// elapsed time: when `follow` replays history, the time since it started
|
|
132
|
+
// is not the time it had run when it changed.
|
|
133
|
+
export function changeText(snapshot, view, eta) {
|
|
134
|
+
if (view.status === "running") {
|
|
135
|
+
const checkEta = etaOf(eta, view);
|
|
136
|
+
return checkEta.eta_ms === null
|
|
137
|
+
? `${view.name} running\n`
|
|
138
|
+
: `${view.name} running (about ${formatSeconds(checkEta.eta_ms)} left)\n`;
|
|
139
|
+
}
|
|
140
|
+
const out = [statusLine(snapshot, view, eta)];
|
|
141
|
+
if (showsLog(view))
|
|
142
|
+
out.push(...logBlock(snapshot, view));
|
|
143
|
+
return `${out.join("\n")}\n`;
|
|
144
|
+
}
|
|
145
|
+
function logBlock(snapshot, view, compared) {
|
|
146
|
+
const file = logPaths(snapshot.dir, snapshot.run.checks).get(view.name);
|
|
147
|
+
const report = logReport(snapshot, view, file, compared?.comparison?.newDiagnostics);
|
|
148
|
+
const more = report.more === 0 ? [] : [` (${report.more} more ${plural(report.more, "diagnostic")} in the log)`];
|
|
149
|
+
const old = compared?.comparison?.oldDiagnostics ?? 0;
|
|
150
|
+
const known = old === 0 ? [] : [` (${old} ${plural(old, "diagnostic")} already in run ${compared.baseline})`];
|
|
151
|
+
return [
|
|
152
|
+
...report.diagnostics.flatMap((diagnostic) => [` ${formatDiagnostic(diagnostic)}`, ...depugLines(diagnostic)]),
|
|
153
|
+
...more,
|
|
154
|
+
...known,
|
|
155
|
+
` log: ${file}`,
|
|
156
|
+
...(report.tail.length === 0 ? [" (no output)"] : report.tail.map((line) => ` ${line}`)),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
function depugFields(diagnostic) {
|
|
160
|
+
return diagnostic.evidence === undefined ? {} : { evidence: diagnostic.evidence, rerun: diagnostic.rerun };
|
|
161
|
+
}
|
|
162
|
+
function depugLines(diagnostic) {
|
|
163
|
+
return [
|
|
164
|
+
...(diagnostic.evidence === undefined ? [] : [` evidence: ${diagnostic.evidence}`]),
|
|
165
|
+
...(diagnostic.rerun === undefined ? [] : [` rerun: ${diagnostic.rerun}`]),
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
export function summarizeNew(snapshot, comparison, next, eta) {
|
|
169
|
+
const counts = countFailures([...comparison.checks.values()]);
|
|
170
|
+
return {
|
|
171
|
+
...summarize(snapshot, next, eta),
|
|
172
|
+
new: [...comparison.checks.values()].filter((check) => check.isNew).map((check) => check.name),
|
|
173
|
+
new_failures: counts.new,
|
|
174
|
+
old_failures: counts.old,
|
|
175
|
+
no_baseline: snapshot.checks.filter((view) => !comparison.baselines.has(view.name)).map((view) => view.name),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Every check, as `results --json` prints it with the fields of `compared`,
|
|
179
|
+
// then only the new diagnostics, then the summary.
|
|
180
|
+
export function newJsonLines(snapshot, comparison, next, eta) {
|
|
181
|
+
const lines = snapshot.checks.map((view) => checkJson(snapshot, view, eta, comparedOf(comparison, view)));
|
|
182
|
+
lines.push(JSON.stringify(summarizeNew(snapshot, comparison, next, eta)));
|
|
183
|
+
return `${lines.join("\n")}\n`;
|
|
184
|
+
}
|
|
185
|
+
// Only the checks with a new failure, with their new diagnostics, and the
|
|
186
|
+
// checks that proved nothing yet; passes and known failures are counts.
|
|
187
|
+
export function newText(snapshot, comparison, next, eta) {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const view of snapshot.checks) {
|
|
190
|
+
const compared = comparedOf(comparison, view);
|
|
191
|
+
if (compared.comparison?.isNew === true) {
|
|
192
|
+
out.push(statusLine(snapshot, view, eta), ...logBlock(snapshot, view, compared));
|
|
193
|
+
}
|
|
194
|
+
else if (isUnproven(view.status)) {
|
|
195
|
+
out.push(statusLine(snapshot, view, eta));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const summary = summarizeNew(snapshot, comparison, next, eta);
|
|
199
|
+
if (comparison.baselines.size === 0) {
|
|
200
|
+
out.push("no earlier run to compare with, so every failure counts as new");
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const missing = [...comparison.checks.keys()].filter((name) => !comparison.baselines.has(name));
|
|
204
|
+
if (missing.length > 0) {
|
|
205
|
+
out.push(`no earlier result for ${missing.join(", ")}, so ${missing.length === 1 ? "its" : "their"} failures count as new`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const skipped = snapshot.checks.filter((view) => view.status === "skipped").length;
|
|
209
|
+
const notNew = [
|
|
210
|
+
summary.old_failures > 0 ? `${summary.old_failures} ${plural(summary.old_failures, "failure")} already in the previous run` : "",
|
|
211
|
+
summary.passed.length > 0 ? `${summary.passed.length} passed` : "",
|
|
212
|
+
skipped > 0 ? `${skipped} skipped` : "",
|
|
213
|
+
].filter((part) => part !== "");
|
|
214
|
+
if (notNew.length > 0)
|
|
215
|
+
out.push(`not new: ${notNew.join(", ")}`);
|
|
216
|
+
const fresh = summary.new_failures === 0 ? "no new failures" : `${summary.new_failures} new ${plural(summary.new_failures, "failure")}`;
|
|
217
|
+
const unproven = [
|
|
218
|
+
["running", summary.running.length],
|
|
219
|
+
["cancelled", summary.cancelled.length],
|
|
220
|
+
["unknown", snapshot.checks.filter((view) => view.status === "unknown").length],
|
|
221
|
+
]
|
|
222
|
+
.filter(([, count]) => count !== 0)
|
|
223
|
+
.map(([kind, count]) => `${count} ${kind}`);
|
|
224
|
+
out.push(`run ${snapshot.run.id}: ${[fresh, ...unproven].join(", ")}`);
|
|
225
|
+
if (next !== null) {
|
|
226
|
+
const waiting = !snapshot.checks.every(isSettled) && eta.eta_ms !== null;
|
|
227
|
+
out.push(waiting ? `next: ${next} (about ${formatSeconds(eta.eta_ms)} left)` : `next: ${next}`);
|
|
228
|
+
}
|
|
229
|
+
return `${out.join("\n")}\n`;
|
|
230
|
+
}
|
|
231
|
+
function plural(count, word) {
|
|
232
|
+
return count === 1 ? word : `${word}s`;
|
|
233
|
+
}
|
|
234
|
+
export function allPassedText(snapshot) {
|
|
235
|
+
const seconds = (Date.now() - Date.parse(snapshot.run.started_at)) / 1000;
|
|
236
|
+
const count = snapshot.checks.length;
|
|
237
|
+
const cached = cachedCount(snapshot);
|
|
238
|
+
const notes = cached === 0 ? `run ${snapshot.run.id}` : `${cached} cached, run ${snapshot.run.id}`;
|
|
239
|
+
return `passed ${count} check${count === 1 ? "" : "s"} in ${seconds.toFixed(1)}s (${notes})\n`;
|
|
240
|
+
}
|
|
241
|
+
function statusLine(snapshot, view, eta) {
|
|
242
|
+
const details = [];
|
|
243
|
+
if (view.orphan) {
|
|
244
|
+
details.push("orphan: the supervisor is gone, so the result is unknown");
|
|
245
|
+
}
|
|
246
|
+
else if (view.cached_from !== null) {
|
|
247
|
+
details.push(`cached from run ${view.cached_from}`);
|
|
248
|
+
if (view.elapsed_ms !== null)
|
|
249
|
+
details.push(`${(view.elapsed_ms / 1000).toFixed(1)}s originally`);
|
|
250
|
+
}
|
|
251
|
+
else if (view.status === "skipped") {
|
|
252
|
+
const spec = snapshot.run.checks.find((check) => check.name === view.name);
|
|
253
|
+
details.push(`needs ${spec?.after.join(", ") ?? "?"}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
if (view.exit_code !== null)
|
|
257
|
+
details.push(`exit ${view.exit_code}`);
|
|
258
|
+
const elapsed = view.elapsed_ms ?? (view.status === "running" && view.started_at !== null ? Date.now() - Date.parse(view.started_at) : null);
|
|
259
|
+
if (elapsed !== null)
|
|
260
|
+
details.push(`${(elapsed / 1000).toFixed(1)}s`);
|
|
261
|
+
const checkEta = etaOf(eta, view);
|
|
262
|
+
const usual = eta.typical.get(view.name);
|
|
263
|
+
if (checkEta.overdue && usual !== undefined) {
|
|
264
|
+
details.push(`past its usual ${(usual / 1000).toFixed(1)}s`);
|
|
265
|
+
}
|
|
266
|
+
else if (!isSettled(view) && checkEta.eta_ms !== null) {
|
|
267
|
+
details.push(`about ${formatSeconds(checkEta.eta_ms)} left`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return details.length === 0 ? `${view.name} ${view.status}` : `${view.name} ${view.status} (${details.join(", ")})`;
|
|
271
|
+
}
|
|
272
|
+
function countsLine(snapshot, eta) {
|
|
273
|
+
const summary = summarize(snapshot, null, eta);
|
|
274
|
+
const parts = ["failed", "running", "cancelled", "passed"]
|
|
275
|
+
.filter((bucket) => summary[bucket].length > 0)
|
|
276
|
+
.map((bucket) => `${summary[bucket].length} ${bucket}`);
|
|
277
|
+
const cached = cachedCount(snapshot);
|
|
278
|
+
return `run ${snapshot.run.id}: ${parts.join(", ")}${cached === 0 ? "" : ` (${cached} cached)`}`;
|
|
279
|
+
}
|
|
280
|
+
function cachedCount(snapshot) {
|
|
281
|
+
return snapshot.checks.filter((view) => view.cached_from !== null).length;
|
|
282
|
+
}
|
|
283
|
+
function etaOf(eta, view) {
|
|
284
|
+
return eta.checks.get(view.name) ?? { eta_ms: null, overdue: false };
|
|
285
|
+
}
|
|
286
|
+
function roundMs(ms) {
|
|
287
|
+
return ms === null ? null : Math.round(ms);
|
|
288
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The on-disk state of runs under XDG_STATE_HOME, never inside the checked
|
|
2
|
+
// repository: repository identity, run ids, run.json, events.jsonl, the
|
|
3
|
+
// `current` pointer, check logs, and pruning, and the earlier runs read back
|
|
4
|
+
// for the time-left estimate, for reused passes, and for the baselines of
|
|
5
|
+
// `results --new`. The CLI writes run.json and `current`; only the
|
|
6
|
+
// supervisor appends to events.jsonl.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { CHECK_STATUSES, foldEvents, IllegalTransition, presentChecks, } from "./checks.js";
|
|
13
|
+
import { findBaselines } from "./baseline.js";
|
|
14
|
+
import { findReuse } from "./cache.js";
|
|
15
|
+
import { AgentError, messageOf } from "./errors.js";
|
|
16
|
+
import { typicalDurations } from "./eta.js";
|
|
17
|
+
import { logFileNames } from "./names.js";
|
|
18
|
+
import { isPidAlive } from "./processes.js";
|
|
19
|
+
export const KEEP_RUNS = 20;
|
|
20
|
+
const RUN_ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
21
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
22
|
+
export function resolveRoot(cwd) {
|
|
23
|
+
let dir;
|
|
24
|
+
try {
|
|
25
|
+
dir = realpathSync(cwd);
|
|
26
|
+
if (!statSync(dir).isDirectory())
|
|
27
|
+
throw new Error("not a directory");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new AgentError(`not a directory: ${cwd}`, "pass an existing directory to --cwd");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
delete env.GIT_DIR;
|
|
35
|
+
delete env.GIT_WORK_TREE;
|
|
36
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
37
|
+
cwd: dir,
|
|
38
|
+
env,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
timeout: 5_000,
|
|
42
|
+
}).trim();
|
|
43
|
+
if (top.length > 0)
|
|
44
|
+
return top;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a git repository, or no git: the directory itself is the root.
|
|
48
|
+
}
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
export function stateBase() {
|
|
52
|
+
const fromEnv = process.env.XDG_STATE_HOME?.trim();
|
|
53
|
+
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(homedir(), ".local", "state");
|
|
54
|
+
return path.resolve(base, "firstlight");
|
|
55
|
+
}
|
|
56
|
+
export function repoKey(root) {
|
|
57
|
+
return createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
58
|
+
}
|
|
59
|
+
export class RepoState {
|
|
60
|
+
root;
|
|
61
|
+
dir;
|
|
62
|
+
constructor(root) {
|
|
63
|
+
this.root = root;
|
|
64
|
+
this.dir = path.join(stateBase(), repoKey(root));
|
|
65
|
+
}
|
|
66
|
+
runDir(id) {
|
|
67
|
+
return path.join(this.dir, "runs", id);
|
|
68
|
+
}
|
|
69
|
+
currentId() {
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
text = readFileSync(path.join(this.dir, "current"), "utf8").trim();
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (isEnoent(err))
|
|
76
|
+
return null;
|
|
77
|
+
throw new AgentError(`could not read the current run: ${messageOf(err)}`, `check permissions on ${this.dir}`);
|
|
78
|
+
}
|
|
79
|
+
return RUN_ID_RE.test(text) ? text : null;
|
|
80
|
+
}
|
|
81
|
+
setCurrent(id) {
|
|
82
|
+
atomicWrite(path.join(this.dir, "current"), `${id}\n`);
|
|
83
|
+
}
|
|
84
|
+
createRun(checks, cacheKeys, reused) {
|
|
85
|
+
const id = newRunId();
|
|
86
|
+
mkdirSync(this.runDir(id), { recursive: true });
|
|
87
|
+
const run = {
|
|
88
|
+
id,
|
|
89
|
+
root: this.root,
|
|
90
|
+
supervisor_pid: null,
|
|
91
|
+
started_at: new Date().toISOString(),
|
|
92
|
+
checks,
|
|
93
|
+
cache_keys: Object.fromEntries(cacheKeys),
|
|
94
|
+
reused: Object.fromEntries(reused),
|
|
95
|
+
};
|
|
96
|
+
this.writeRun(run);
|
|
97
|
+
return run;
|
|
98
|
+
}
|
|
99
|
+
writeRun(run) {
|
|
100
|
+
atomicWrite(path.join(this.runDir(run.id), "run.json"), `${JSON.stringify(run, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
readRun(id) {
|
|
103
|
+
const file = path.join(this.runDir(id), "run.json");
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
throw new AgentError(`could not read ${file}: ${messageOf(err)}`, "start a new run with `firstlight run`");
|
|
110
|
+
}
|
|
111
|
+
if (!isRunRecord(raw)) {
|
|
112
|
+
throw new AgentError(`unreadable run record: ${file}`, "start a new run with `firstlight run`");
|
|
113
|
+
}
|
|
114
|
+
return raw;
|
|
115
|
+
}
|
|
116
|
+
snapshot(id) {
|
|
117
|
+
const run = this.readRun(id);
|
|
118
|
+
const dir = this.runDir(id);
|
|
119
|
+
const names = run.checks.map((check) => check.name);
|
|
120
|
+
let folded = this.fold(dir, names);
|
|
121
|
+
let supervisorAlive = true;
|
|
122
|
+
if (folded.outcome === null) {
|
|
123
|
+
supervisorAlive = isPidAlive(run.supervisor_pid);
|
|
124
|
+
// The supervisor writes its final event and then exits; read again so
|
|
125
|
+
// that ordering is never mistaken for an orphan.
|
|
126
|
+
if (!supervisorAlive)
|
|
127
|
+
folded = this.fold(dir, names);
|
|
128
|
+
}
|
|
129
|
+
return { run, dir, outcome: folded.outcome, supervisorAlive, checks: presentChecks(folded, supervisorAlive) };
|
|
130
|
+
}
|
|
131
|
+
fold(dir, names) {
|
|
132
|
+
try {
|
|
133
|
+
return foldEvents(names, readEvents(path.join(dir, "events.jsonl")));
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof IllegalTransition) {
|
|
137
|
+
throw new AgentError(`inconsistent events in ${path.join(dir, "events.jsonl")}: ${err.message}`, "start a new run with `firstlight run`");
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
typicalDurations(run) {
|
|
143
|
+
const past = this.pastRuns()
|
|
144
|
+
.filter((files) => files.record.id < run.id)
|
|
145
|
+
.map((files) => ({ checks: files.record.checks, states: files.states }));
|
|
146
|
+
return typicalDurations(run.checks, past);
|
|
147
|
+
}
|
|
148
|
+
// The run each check of `run` is compared with, keyed by check name, with
|
|
149
|
+
// that run's record, whose parser and log the comparison reads.
|
|
150
|
+
baselines(run) {
|
|
151
|
+
const past = this.pastRuns().filter((files) => files.record.id < run.id);
|
|
152
|
+
const records = new Map(past.map((files) => [files.record.id, files.record]));
|
|
153
|
+
const found = findBaselines(run.checks, past.map((files) => ({ id: files.record.id, checks: files.record.checks, states: files.states })));
|
|
154
|
+
return new Map([...found].map(([name, baseline]) => [name, { ...baseline, record: records.get(baseline.run) }]));
|
|
155
|
+
}
|
|
156
|
+
// Called before the new run exists, so every kept run is earlier.
|
|
157
|
+
reusable(cacheKeys) {
|
|
158
|
+
const past = this.pastRuns().map((files) => ({
|
|
159
|
+
id: files.record.id,
|
|
160
|
+
keys: files.record.cache_keys ?? {},
|
|
161
|
+
states: files.states,
|
|
162
|
+
}));
|
|
163
|
+
return findReuse(cacheKeys, past);
|
|
164
|
+
}
|
|
165
|
+
// Newest first. Only the kept runs are read, so the history costs no file
|
|
166
|
+
// of its own. An earlier run that no longer reads cleanly is history with
|
|
167
|
+
// nothing to say, not a reason to fail the current command.
|
|
168
|
+
pastRuns() {
|
|
169
|
+
const past = [];
|
|
170
|
+
for (const id of this.runIds()) {
|
|
171
|
+
try {
|
|
172
|
+
const record = this.readRun(id);
|
|
173
|
+
const names = record.checks.map((check) => check.name);
|
|
174
|
+
const folded = foldEvents(names, readEvents(eventsPath(this.runDir(id))));
|
|
175
|
+
past.push({ record, states: folded.checks });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return past;
|
|
182
|
+
}
|
|
183
|
+
// Newest first. Run ids sort by creation time.
|
|
184
|
+
runIds() {
|
|
185
|
+
let names;
|
|
186
|
+
try {
|
|
187
|
+
names = readdirSync(path.join(this.dir, "runs"));
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
if (isEnoent(err))
|
|
191
|
+
return [];
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
return names.filter((name) => RUN_ID_RE.test(name)).sort().reverse();
|
|
195
|
+
}
|
|
196
|
+
prune(keep = KEEP_RUNS) {
|
|
197
|
+
for (const id of this.runIds().slice(keep)) {
|
|
198
|
+
rmSync(this.runDir(id), { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Derived from the run's own check list on every read, so run.json needs no
|
|
203
|
+
// field for it and a run written before script and command checks existed
|
|
204
|
+
// keeps `<name>.log`.
|
|
205
|
+
export function logPaths(runDir, checks) {
|
|
206
|
+
const files = logFileNames(checks.map((check) => check.name));
|
|
207
|
+
return new Map([...files].map(([name, file]) => [name, path.join(runDir, file)]));
|
|
208
|
+
}
|
|
209
|
+
export function eventsPath(runDir) {
|
|
210
|
+
return path.join(runDir, "events.jsonl");
|
|
211
|
+
}
|
|
212
|
+
// One appendFileSync per event is one write(2) with O_APPEND, so a reader
|
|
213
|
+
// never sees two events interleaved.
|
|
214
|
+
export function appendEvent(runDir, event) {
|
|
215
|
+
appendFileSync(eventsPath(runDir), `${JSON.stringify(event)}\n`);
|
|
216
|
+
}
|
|
217
|
+
export function readEvents(file) {
|
|
218
|
+
let text;
|
|
219
|
+
try {
|
|
220
|
+
text = readFileSync(file, "utf8");
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (isEnoent(err))
|
|
224
|
+
return [];
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
const lines = text.split("\n");
|
|
228
|
+
// A line without its newline may still be in the middle of being written.
|
|
229
|
+
lines.pop();
|
|
230
|
+
const events = [];
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (line.trim().length === 0)
|
|
233
|
+
continue;
|
|
234
|
+
let raw;
|
|
235
|
+
try {
|
|
236
|
+
raw = JSON.parse(line);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
throw new AgentError(`unreadable line in ${file}: ${line}`, "start a new run with `firstlight run`");
|
|
240
|
+
}
|
|
241
|
+
if (!isEvent(raw)) {
|
|
242
|
+
throw new AgentError(`unexpected event in ${file}: ${line}`, "start a new run with `firstlight run`");
|
|
243
|
+
}
|
|
244
|
+
events.push(raw);
|
|
245
|
+
}
|
|
246
|
+
return events;
|
|
247
|
+
}
|
|
248
|
+
const TAIL_BYTES = 256 * 1024;
|
|
249
|
+
export function tailLines(file, count) {
|
|
250
|
+
let fd;
|
|
251
|
+
try {
|
|
252
|
+
fd = openSync(file, "r");
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (isEnoent(err))
|
|
256
|
+
return [];
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const size = statSync(file).size;
|
|
261
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
262
|
+
const buffer = Buffer.alloc(length);
|
|
263
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
264
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
265
|
+
if (lines.at(-1) === "")
|
|
266
|
+
lines.pop();
|
|
267
|
+
// The first line is partial when the read started mid-file.
|
|
268
|
+
if (length < size)
|
|
269
|
+
lines.shift();
|
|
270
|
+
return lines.slice(-count);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
closeSync(fd);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Enough for any log a parser reads in practice. A longer log keeps its
|
|
277
|
+
// end, where test runners print their failures.
|
|
278
|
+
const PARSE_BYTES = 8 * 1024 * 1024;
|
|
279
|
+
export function readLogEnd(file) {
|
|
280
|
+
let fd;
|
|
281
|
+
try {
|
|
282
|
+
fd = openSync(file, "r");
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
if (isEnoent(err))
|
|
286
|
+
return "";
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const size = statSync(file).size;
|
|
291
|
+
const length = Math.min(size, PARSE_BYTES);
|
|
292
|
+
const buffer = Buffer.alloc(length);
|
|
293
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
294
|
+
return buffer.toString("utf8");
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
closeSync(fd);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function newRunId(now = Date.now()) {
|
|
301
|
+
let time = now;
|
|
302
|
+
let timeChars = "";
|
|
303
|
+
for (let i = 0; i < 10; i += 1) {
|
|
304
|
+
timeChars = CROCKFORD[time % 32] + timeChars;
|
|
305
|
+
time = Math.floor(time / 32);
|
|
306
|
+
}
|
|
307
|
+
let randomChars = "";
|
|
308
|
+
for (const byte of randomBytes(16))
|
|
309
|
+
randomChars += CROCKFORD[byte % 32];
|
|
310
|
+
return timeChars + randomChars;
|
|
311
|
+
}
|
|
312
|
+
function atomicWrite(file, text) {
|
|
313
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
314
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
315
|
+
writeFileSync(tmp, text, "utf8");
|
|
316
|
+
renameSync(tmp, file);
|
|
317
|
+
}
|
|
318
|
+
function isRunRecord(value) {
|
|
319
|
+
if (!isRecord(value))
|
|
320
|
+
return false;
|
|
321
|
+
if (typeof value.id !== "string" || typeof value.root !== "string" || typeof value.started_at !== "string")
|
|
322
|
+
return false;
|
|
323
|
+
if (!(value.supervisor_pid === null || Number.isInteger(value.supervisor_pid)))
|
|
324
|
+
return false;
|
|
325
|
+
if (!Array.isArray(value.checks))
|
|
326
|
+
return false;
|
|
327
|
+
if (value.cache_keys !== undefined && !isStringRecord(value.cache_keys))
|
|
328
|
+
return false;
|
|
329
|
+
if (value.reused !== undefined && !isReuseRecord(value.reused))
|
|
330
|
+
return false;
|
|
331
|
+
return value.checks.every((check) => isRecord(check) &&
|
|
332
|
+
typeof check.name === "string" &&
|
|
333
|
+
typeof check.run === "string" &&
|
|
334
|
+
Array.isArray(check.after) &&
|
|
335
|
+
typeof check.timeoutSec === "number" &&
|
|
336
|
+
(check.parser === undefined || check.parser === null || typeof check.parser === "string"));
|
|
337
|
+
}
|
|
338
|
+
function isEvent(value) {
|
|
339
|
+
if (!isRecord(value) || typeof value.at !== "string")
|
|
340
|
+
return false;
|
|
341
|
+
if (value.type === "run")
|
|
342
|
+
return value.status === "done" || value.status === "cancelled";
|
|
343
|
+
return (value.type === "check" &&
|
|
344
|
+
typeof value.check === "string" &&
|
|
345
|
+
CHECK_STATUSES.includes(value.status) &&
|
|
346
|
+
(value.cached_from === undefined || typeof value.cached_from === "string"));
|
|
347
|
+
}
|
|
348
|
+
function isStringRecord(value) {
|
|
349
|
+
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
350
|
+
}
|
|
351
|
+
function isReuseRecord(value) {
|
|
352
|
+
return (isRecord(value) &&
|
|
353
|
+
Object.values(value).every((entry) => isRecord(entry) && typeof entry.run === "string" && typeof entry.elapsed_ms === "number"));
|
|
354
|
+
}
|
|
355
|
+
function isRecord(value) {
|
|
356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
357
|
+
}
|
|
358
|
+
function isEnoent(err) {
|
|
359
|
+
return err.code === "ENOENT";
|
|
360
|
+
}
|