firstlight 0.0.0-stage → 0.3.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,56 @@
1
+ // Process liveness and process-group signalling for pids firstlight started.
2
+ // Nothing here looks processes up by name or pattern: every call takes a pid
3
+ // that a run record or an event recorded.
4
+ import { readFileSync } from "node:fs";
5
+ export function isPidAlive(pid) {
6
+ if (pid === null || !Number.isInteger(pid) || pid <= 0)
7
+ return false;
8
+ try {
9
+ process.kill(pid, 0);
10
+ }
11
+ catch (err) {
12
+ return err.code === "EPERM";
13
+ }
14
+ return !isZombie(pid);
15
+ }
16
+ // A detached supervisor is reparented when the CLI exits. If the new parent
17
+ // never reaps (a container whose pid 1 is not an init), a dead supervisor
18
+ // stays a zombie and kill(pid, 0) keeps succeeding, which would hide an
19
+ // orphan forever. Linux exposes the state in /proc; elsewhere we fall back
20
+ // to kill(pid, 0) alone.
21
+ function isZombie(pid) {
22
+ let stat;
23
+ try {
24
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ const state = stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3);
30
+ return state === "Z" || state === "X";
31
+ }
32
+ export function signalGroup(pgid, signal) {
33
+ if (!Number.isInteger(pgid) || pgid <= 0)
34
+ return false;
35
+ try {
36
+ process.kill(-pgid, signal);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export function signalPid(pid, signal) {
44
+ if (!Number.isInteger(pid) || pid <= 0)
45
+ return false;
46
+ try {
47
+ process.kill(pid, signal);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export function sleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
package/dist/report.js ADDED
@@ -0,0 +1,299 @@
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 { FALLBACK_SCRIPTS } from "./config.js";
11
+ import { formatDiagnostic, isParserChoice, parseDiagnostics, parserList } from "./diagnostics.js";
12
+ import { formatSeconds } from "./eta.js";
13
+ import { logPaths, readLogEnd, tailLines } from "./store.js";
14
+ export const TAIL_LINES = 40;
15
+ // With diagnostics, they carry the substance, and the tail only shows the
16
+ // context the parser did not read, such as a summary.
17
+ export const DIAGNOSTIC_TAIL_LINES = 10;
18
+ export const DIAGNOSTIC_LIMIT = 20;
19
+ // Every diagnostic in the log of one check of a run, through the parser
20
+ // that run recorded for it.
21
+ export function checkDiagnostics(run, dir, name) {
22
+ const parser = run.checks.find((check) => check.name === name)?.parser;
23
+ if (!isParserChoice(parser))
24
+ return [];
25
+ const log = readLogEnd(logPaths(dir, run.checks).get(name));
26
+ return parserList(parser).flatMap((one) => parseDiagnostics(one, log, run.root));
27
+ }
28
+ // `shown` narrows the diagnostics printed, as `results --new` does; the
29
+ // tail stays short whenever the log has any.
30
+ function logReport(snapshot, view, file, shown) {
31
+ const all = checkDiagnostics(snapshot.run, snapshot.dir, view.name);
32
+ const printed = shown ?? all;
33
+ return {
34
+ diagnostics: printed.slice(0, DIAGNOSTIC_LIMIT),
35
+ more: Math.max(0, printed.length - DIAGNOSTIC_LIMIT),
36
+ tail: tailLines(file, all.length > 0 ? DIAGNOSTIC_TAIL_LINES : TAIL_LINES),
37
+ };
38
+ }
39
+ function comparedOf(comparison, view) {
40
+ return { baseline: comparison.baselines.get(view.name) ?? null, comparison: comparison.checks.get(view.name) };
41
+ }
42
+ export function summarize(snapshot, next, eta, notRun = []) {
43
+ const summary = {
44
+ type: "summary",
45
+ run: snapshot.run.id,
46
+ failed: [],
47
+ running: [],
48
+ passed: [],
49
+ cancelled: [],
50
+ next,
51
+ eta_ms: roundMs(eta.eta_ms),
52
+ };
53
+ for (const view of snapshot.checks)
54
+ summary[bucketOf(view.status)].push(view.name);
55
+ if (notRun.length > 0)
56
+ summary.not_run = notRun;
57
+ return summary;
58
+ }
59
+ function showsLog(view) {
60
+ return view.status === "failed" || view.status === "timed_out";
61
+ }
62
+ export function jsonLines(snapshot, next, eta, notRun = []) {
63
+ const lines = snapshot.checks.map((view) => checkJson(snapshot, view, eta));
64
+ lines.push(JSON.stringify(summarize(snapshot, next, eta, notRun)));
65
+ return `${lines.join("\n")}\n`;
66
+ }
67
+ // One line for the check, then, for a failed check, one line per
68
+ // diagnostic, without a trailing newline. With `compared`, the check line
69
+ // gains `new` and `baseline` (and `old_diagnostics` when it failed), and
70
+ // only the new diagnostics follow it.
71
+ export function checkJson(snapshot, view, eta, compared) {
72
+ const log = logPaths(snapshot.dir, snapshot.run.checks).get(view.name);
73
+ const checkEta = etaOf(eta, view);
74
+ const line = {
75
+ type: "check",
76
+ name: view.name,
77
+ status: view.status,
78
+ exit_code: view.exit_code,
79
+ elapsed_ms: view.elapsed_ms,
80
+ log,
81
+ eta_ms: roundMs(checkEta.eta_ms),
82
+ overdue: checkEta.overdue,
83
+ cached_from: view.cached_from,
84
+ };
85
+ if (view.orphan)
86
+ line.orphan = true;
87
+ if (compared !== undefined) {
88
+ line.new = compared.comparison?.isNew ?? false;
89
+ line.baseline = compared.baseline;
90
+ }
91
+ if (!showsLog(view))
92
+ return JSON.stringify(line);
93
+ if (compared !== undefined)
94
+ line.old_diagnostics = compared.comparison?.oldDiagnostics ?? 0;
95
+ const report = logReport(snapshot, view, log, compared?.comparison?.newDiagnostics);
96
+ if (report.more > 0)
97
+ line.more_diagnostics = report.more;
98
+ line.tail = report.tail;
99
+ const diagnostics = report.diagnostics.map((diagnostic) => JSON.stringify({
100
+ type: "diagnostic",
101
+ check: view.name,
102
+ file: diagnostic.file,
103
+ line: diagnostic.line,
104
+ col: diagnostic.col,
105
+ rule: diagnostic.rule,
106
+ message: diagnostic.message,
107
+ ...depugFields(diagnostic),
108
+ }));
109
+ return [JSON.stringify(line), ...diagnostics].join("\n");
110
+ }
111
+ // `focus` puts one check first with its log, as `run` does for the check
112
+ // that failed first; the others get one status line each.
113
+ export function text(snapshot, next, eta, focus = null, notRun = []) {
114
+ const out = [];
115
+ const ordered = focus === null
116
+ ? snapshot.checks
117
+ : [...snapshot.checks.filter((v) => v.name === focus), ...snapshot.checks.filter((v) => v.name !== focus)];
118
+ for (const view of ordered) {
119
+ out.push(statusLine(snapshot, view, eta));
120
+ if (showsLog(view) && (focus === null || view.name === focus))
121
+ out.push(...logBlock(snapshot, view));
122
+ }
123
+ return `${out.join("\n")}\n${notRunText(notRun)}${summaryText(snapshot, next, eta)}`;
124
+ }
125
+ // The lines after the check lines: the counts, then the next command.
126
+ export function summaryText(snapshot, next, eta) {
127
+ const out = [countsLine(snapshot, eta)];
128
+ if (next !== null) {
129
+ const waiting = !snapshot.checks.every(isSettled) && eta.eta_ms !== null;
130
+ out.push(waiting ? `next: ${next} (about ${formatSeconds(eta.eta_ms)} left)` : `next: ${next}`);
131
+ }
132
+ return `${out.join("\n")}\n`;
133
+ }
134
+ // One status change, as `follow` prints it. A running check gets no
135
+ // elapsed time: when `follow` replays history, the time since it started
136
+ // is not the time it had run when it changed.
137
+ export function changeText(snapshot, view, eta) {
138
+ if (view.status === "running") {
139
+ const checkEta = etaOf(eta, view);
140
+ return checkEta.eta_ms === null
141
+ ? `${view.name} running\n`
142
+ : `${view.name} running (about ${formatSeconds(checkEta.eta_ms)} left)\n`;
143
+ }
144
+ const out = [statusLine(snapshot, view, eta)];
145
+ if (showsLog(view))
146
+ out.push(...logBlock(snapshot, view));
147
+ return `${out.join("\n")}\n`;
148
+ }
149
+ function logBlock(snapshot, view, compared) {
150
+ const file = logPaths(snapshot.dir, snapshot.run.checks).get(view.name);
151
+ const report = logReport(snapshot, view, file, compared?.comparison?.newDiagnostics);
152
+ const more = report.more === 0 ? [] : [` (${report.more} more ${plural(report.more, "diagnostic")} in the log)`];
153
+ const old = compared?.comparison?.oldDiagnostics ?? 0;
154
+ const known = old === 0 ? [] : [` (${old} ${plural(old, "diagnostic")} already in run ${compared.baseline})`];
155
+ return [
156
+ ...report.diagnostics.flatMap((diagnostic) => [` ${formatDiagnostic(diagnostic)}`, ...depugLines(diagnostic)]),
157
+ ...more,
158
+ ...known,
159
+ ` log: ${file}`,
160
+ ...(report.tail.length === 0 ? [" (no output)"] : report.tail.map((line) => ` ${line}`)),
161
+ ];
162
+ }
163
+ function depugFields(diagnostic) {
164
+ return diagnostic.evidence === undefined ? {} : { evidence: diagnostic.evidence, rerun: diagnostic.rerun };
165
+ }
166
+ function depugLines(diagnostic) {
167
+ return [
168
+ ...(diagnostic.evidence === undefined ? [] : [` evidence: ${diagnostic.evidence}`]),
169
+ ...(diagnostic.rerun === undefined ? [] : [` rerun: ${diagnostic.rerun}`]),
170
+ ];
171
+ }
172
+ export function summarizeNew(snapshot, comparison, next, eta) {
173
+ const counts = countFailures([...comparison.checks.values()]);
174
+ return {
175
+ ...summarize(snapshot, next, eta),
176
+ new: [...comparison.checks.values()].filter((check) => check.isNew).map((check) => check.name),
177
+ new_failures: counts.new,
178
+ old_failures: counts.old,
179
+ no_baseline: snapshot.checks.filter((view) => !comparison.baselines.has(view.name)).map((view) => view.name),
180
+ };
181
+ }
182
+ // Every check, as `results --json` prints it with the fields of `compared`,
183
+ // then only the new diagnostics, then the summary.
184
+ export function newJsonLines(snapshot, comparison, next, eta) {
185
+ const lines = snapshot.checks.map((view) => checkJson(snapshot, view, eta, comparedOf(comparison, view)));
186
+ lines.push(JSON.stringify(summarizeNew(snapshot, comparison, next, eta)));
187
+ return `${lines.join("\n")}\n`;
188
+ }
189
+ // Only the checks with a new failure, with their new diagnostics, and the
190
+ // checks that proved nothing yet; passes and known failures are counts.
191
+ export function newText(snapshot, comparison, next, eta) {
192
+ const out = [];
193
+ for (const view of snapshot.checks) {
194
+ const compared = comparedOf(comparison, view);
195
+ if (compared.comparison?.isNew === true) {
196
+ out.push(statusLine(snapshot, view, eta), ...logBlock(snapshot, view, compared));
197
+ }
198
+ else if (isUnproven(view.status)) {
199
+ out.push(statusLine(snapshot, view, eta));
200
+ }
201
+ }
202
+ const summary = summarizeNew(snapshot, comparison, next, eta);
203
+ if (comparison.baselines.size === 0) {
204
+ out.push("no earlier run to compare with, so every failure counts as new");
205
+ }
206
+ else {
207
+ const missing = [...comparison.checks.keys()].filter((name) => !comparison.baselines.has(name));
208
+ if (missing.length > 0) {
209
+ out.push(`no earlier result for ${missing.join(", ")}, so ${missing.length === 1 ? "its" : "their"} failures count as new`);
210
+ }
211
+ }
212
+ const skipped = snapshot.checks.filter((view) => view.status === "skipped").length;
213
+ const notNew = [
214
+ summary.old_failures > 0 ? `${summary.old_failures} ${plural(summary.old_failures, "failure")} already in the previous run` : "",
215
+ summary.passed.length > 0 ? `${summary.passed.length} passed` : "",
216
+ skipped > 0 ? `${skipped} skipped` : "",
217
+ ].filter((part) => part !== "");
218
+ if (notNew.length > 0)
219
+ out.push(`not new: ${notNew.join(", ")}`);
220
+ const fresh = summary.new_failures === 0 ? "no new failures" : `${summary.new_failures} new ${plural(summary.new_failures, "failure")}`;
221
+ const unproven = [
222
+ ["running", summary.running.length],
223
+ ["cancelled", summary.cancelled.length],
224
+ ["unknown", snapshot.checks.filter((view) => view.status === "unknown").length],
225
+ ]
226
+ .filter(([, count]) => count !== 0)
227
+ .map(([kind, count]) => `${count} ${kind}`);
228
+ out.push(`run ${snapshot.run.id}: ${[fresh, ...unproven].join(", ")}`);
229
+ if (next !== null) {
230
+ const waiting = !snapshot.checks.every(isSettled) && eta.eta_ms !== null;
231
+ out.push(waiting ? `next: ${next} (about ${formatSeconds(eta.eta_ms)} left)` : `next: ${next}`);
232
+ }
233
+ return `${out.join("\n")}\n`;
234
+ }
235
+ function plural(count, word) {
236
+ return count === 1 ? word : `${word}s`;
237
+ }
238
+ // Only a run without firstlight.json has these: the check-like scripts the
239
+ // package.json fallback did not run.
240
+ export function notRunText(notRun) {
241
+ if (notRun.length === 0)
242
+ return "";
243
+ return `not run: ${notRun.join(", ")} (without firstlight.json, only ${FALLBACK_SCRIPTS.join(", ")} run; name a script, like firstlight run ${notRun[0]}, or write firstlight.json with firstlight init)\n`;
244
+ }
245
+ export function allPassedText(snapshot, notRun = []) {
246
+ const seconds = (Date.now() - Date.parse(snapshot.run.started_at)) / 1000;
247
+ const count = snapshot.checks.length;
248
+ const cached = cachedCount(snapshot);
249
+ const notes = cached === 0 ? `run ${snapshot.run.id}` : `${cached} cached, run ${snapshot.run.id}`;
250
+ return `passed ${count} check${count === 1 ? "" : "s"} in ${seconds.toFixed(1)}s (${notes})\n${notRunText(notRun)}`;
251
+ }
252
+ function statusLine(snapshot, view, eta) {
253
+ const details = [];
254
+ if (view.orphan) {
255
+ details.push("orphan: the supervisor is gone, so the result is unknown");
256
+ }
257
+ else if (view.cached_from !== null) {
258
+ details.push(`cached from run ${view.cached_from}`);
259
+ if (view.elapsed_ms !== null)
260
+ details.push(`${(view.elapsed_ms / 1000).toFixed(1)}s originally`);
261
+ }
262
+ else if (view.status === "skipped") {
263
+ const spec = snapshot.run.checks.find((check) => check.name === view.name);
264
+ details.push(`needs ${spec?.after.join(", ") ?? "?"}`);
265
+ }
266
+ else {
267
+ if (view.exit_code !== null)
268
+ details.push(`exit ${view.exit_code}`);
269
+ const elapsed = view.elapsed_ms ?? (view.status === "running" && view.started_at !== null ? Date.now() - Date.parse(view.started_at) : null);
270
+ if (elapsed !== null)
271
+ details.push(`${(elapsed / 1000).toFixed(1)}s`);
272
+ const checkEta = etaOf(eta, view);
273
+ const usual = eta.typical.get(view.name);
274
+ if (checkEta.overdue && usual !== undefined) {
275
+ details.push(`past its usual ${(usual / 1000).toFixed(1)}s`);
276
+ }
277
+ else if (!isSettled(view) && checkEta.eta_ms !== null) {
278
+ details.push(`about ${formatSeconds(checkEta.eta_ms)} left`);
279
+ }
280
+ }
281
+ return details.length === 0 ? `${view.name} ${view.status}` : `${view.name} ${view.status} (${details.join(", ")})`;
282
+ }
283
+ function countsLine(snapshot, eta) {
284
+ const summary = summarize(snapshot, null, eta);
285
+ const parts = ["failed", "running", "cancelled", "passed"]
286
+ .filter((bucket) => summary[bucket].length > 0)
287
+ .map((bucket) => `${summary[bucket].length} ${bucket}`);
288
+ const cached = cachedCount(snapshot);
289
+ return `run ${snapshot.run.id}: ${parts.join(", ")}${cached === 0 ? "" : ` (${cached} cached)`}`;
290
+ }
291
+ function cachedCount(snapshot) {
292
+ return snapshot.checks.filter((view) => view.cached_from !== null).length;
293
+ }
294
+ function etaOf(eta, view) {
295
+ return eta.checks.get(view.name) ?? { eta_ms: null, overdue: false };
296
+ }
297
+ function roundMs(ms) {
298
+ return ms === null ? null : Math.round(ms);
299
+ }