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.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +455 -2
- package/dist/baseline.js +86 -0
- package/dist/cache.js +54 -0
- package/dist/checks.js +125 -0
- package/dist/cli.js +519 -0
- package/dist/config.js +405 -0
- package/dist/diagnostics.js +482 -0
- package/dist/errors.js +35 -0
- package/dist/eta.js +76 -0
- package/dist/names.js +67 -0
- package/dist/processes.js +56 -0
- package/dist/report.js +299 -0
- package/dist/store.js +369 -0
- package/dist/supervisor.js +188 -0
- package/dist/tree.js +60 -0
- package/package.json +60 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Command-line entry: parses arguments, runs `run`, `results`, `follow`,
|
|
3
|
+
// and `stop` against the state store, picks exit codes and next commands,
|
|
4
|
+
// and starts the detached supervisor (this same file, under a hidden
|
|
5
|
+
// subcommand).
|
|
6
|
+
// It never appends to events.jsonl; it only reads what the supervisor wrote.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { closeSync, existsSync, openSync, writeFileSync, writeSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { compareFailures, isFailure, isUnproven } from "./baseline.js";
|
|
13
|
+
import { cacheKeys } from "./cache.js";
|
|
14
|
+
import { bucketOf, IllegalTransition, isSettled, presentChecks, replay } from "./checks.js";
|
|
15
|
+
import { CONFIG_FILE, configText, configuredChecks, loadConfig, proposeChecks, resolveRun, selectFailed, suggestTimeoutSec, withParsers, } from "./config.js";
|
|
16
|
+
import { AgentError, exitWithEnvelope, nodeVersionError } from "./errors.js";
|
|
17
|
+
import { estimate } from "./eta.js";
|
|
18
|
+
import { shellQuote } from "./names.js";
|
|
19
|
+
import { signalGroup, signalPid, sleep } from "./processes.js";
|
|
20
|
+
import { allPassedText, changeText, checkDiagnostics, checkJson, jsonLines, newJsonLines, newText, summarize, summaryText, text, } from "./report.js";
|
|
21
|
+
import { eventsPath, readEvents, RepoState, resolveRoot } from "./store.js";
|
|
22
|
+
import { supervise, SUPERVISE_COMMAND } from "./supervisor.js";
|
|
23
|
+
import { treeKey } from "./tree.js";
|
|
24
|
+
const POLL_MS = 50;
|
|
25
|
+
const STOP_WAIT_MS = 5_000;
|
|
26
|
+
const HELP = `firstlight: run lint, typecheck, and tests in parallel; return at the first failure.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
firstlight run [name|pattern|"command"...] [-- command...] [--no-cache] [--cwd DIR] [--json]
|
|
30
|
+
firstlight run --failed [--no-cache] [--cwd DIR] [--json]
|
|
31
|
+
firstlight results [--wait] [--new] [--cwd DIR] [--json]
|
|
32
|
+
firstlight follow [--cwd DIR] [--json]
|
|
33
|
+
firstlight stop [--cwd DIR] [--json]
|
|
34
|
+
firstlight init [script|pattern...] [--write [--force]] [--cwd DIR] [--json]
|
|
35
|
+
firstlight --help
|
|
36
|
+
|
|
37
|
+
run Stop the previous run if it is still going, start the checks in
|
|
38
|
+
parallel, and return at the first failed or timed-out check. The
|
|
39
|
+
other checks keep running. Like npm-run-all's run-p:
|
|
40
|
+
firstlight run lint typecheck test
|
|
41
|
+
firstlight run "lint:*" test
|
|
42
|
+
firstlight run "bundle exec rubocop" "bundle exec rspec"
|
|
43
|
+
firstlight run lint -- make rspec
|
|
44
|
+
With no arguments, every check in firstlight.json runs.
|
|
45
|
+
--failed runs only the checks that did not pass in the latest
|
|
46
|
+
run, each resolved like a name given to run (a configured check
|
|
47
|
+
brings its "after" checks); a check removed from firstlight.json
|
|
48
|
+
since is left out.
|
|
49
|
+
results Report the latest run. --wait blocks until every check is done.
|
|
50
|
+
--new reports only the failures that were not there before: each
|
|
51
|
+
check is compared with the newest earlier kept run in which the
|
|
52
|
+
same check (same name, same command) passed, failed, or timed
|
|
53
|
+
out. A diagnostic is new when that run has none with the same
|
|
54
|
+
file, rule, and message (line and column do not count); a failure
|
|
55
|
+
without diagnostics is new when that run passed. Passes and known
|
|
56
|
+
failures become counts. Exit 1 only for a new failure, or for a
|
|
57
|
+
check that is still running, cancelled, or unknown.
|
|
58
|
+
follow Print one line for each status change of the latest run, from its
|
|
59
|
+
first one, as it happens, then the summary of results --wait, and
|
|
60
|
+
exit as results --wait would. Each line is flushed on its own, so
|
|
61
|
+
a log watcher sees it at once.
|
|
62
|
+
stop Cancel the checks still running. Does nothing when none are.
|
|
63
|
+
init Propose a firstlight.json from the package.json scripts and print
|
|
64
|
+
it. --write writes it; --force lets --write replace an existing
|
|
65
|
+
one. With no names, it holds the scripts lint, typecheck, test,
|
|
66
|
+
and archstrict; names and patterns pick other scripts:
|
|
67
|
+
firstlight init typecheck test check:architecture
|
|
68
|
+
Each check runs "npm run <script>", gets the parser inferred from
|
|
69
|
+
the script, and gets a timeoutSec when an earlier run passed it in
|
|
70
|
+
more than a third of the 600s default. It names the scripts that
|
|
71
|
+
look like checks and were left out.
|
|
72
|
+
|
|
73
|
+
Arguments of run:
|
|
74
|
+
word a check in firstlight.json (with its "after" checks), else a
|
|
75
|
+
package.json script, run as "npm run <word>"
|
|
76
|
+
pattern a word with "*": every package.json script it matches, in
|
|
77
|
+
package.json order. "*" stays inside one ":" segment, "**"
|
|
78
|
+
crosses them: "lint:*" matches lint:js but not lint:js:fix
|
|
79
|
+
"command" an argument with whitespace is a shell command, and its name
|
|
80
|
+
is the command itself
|
|
81
|
+
-- ... each argument after -- is a shell command, even one word
|
|
82
|
+
|
|
83
|
+
A running or pending check shows about how long it has left once an
|
|
84
|
+
earlier kept run passed the same check with the same command, for example
|
|
85
|
+
"test running (3.5s, about 8.0s left)". The "next:" line shows the time
|
|
86
|
+
left for the whole run. Without that history, no estimate is shown.
|
|
87
|
+
|
|
88
|
+
A check that passed in a kept run on the same working tree (tracked and
|
|
89
|
+
untracked files, not ignored ones) with the same command, and whose
|
|
90
|
+
"after" checks did the same, is not run again: "test passed (cached from
|
|
91
|
+
run 01K..., 10.4s originally)". Environment variables and files outside
|
|
92
|
+
the repository are not part of that comparison; --no-cache runs every
|
|
93
|
+
check anyway. Only a pass is reused, and only in a git repository.
|
|
94
|
+
|
|
95
|
+
A failed or timed-out check whose log a parser reads prints its
|
|
96
|
+
diagnostics, at most 20, as "file:line:col rule message" lines before a
|
|
97
|
+
10-line log tail, in run, results, and follow; --json prints them as
|
|
98
|
+
{"type":"diagnostic",...} lines after the check. The parsers are tsc,
|
|
99
|
+
vitest, node-test (node --test), and archstrict. Each check's parser comes from its "parser" in firstlight.json,
|
|
100
|
+
or from its command: tsc, vitest, node --test, archstrict, or "npm run
|
|
101
|
+
<script>" (and "npm test") whose script runs one of them; an && list
|
|
102
|
+
gets each of its commands' parsers, like ["tsc", "vitest"]. "parser":
|
|
103
|
+
"none" turns this off for one check. A vitest diagnostic whose test
|
|
104
|
+
depug's reporter named also gets "evidence: <path>" and "rerun:
|
|
105
|
+
<command>" lines under it, and "evidence" and "rerun" fields in --json.
|
|
106
|
+
|
|
107
|
+
Checks come from firstlight.json at the repository root; "run" is any
|
|
108
|
+
shell command:
|
|
109
|
+
{"checks": {"build": {"run": "make build"},
|
|
110
|
+
"test": {"run": "bundle exec rspec", "after": ["build"], "timeoutSec": 600},
|
|
111
|
+
"types": {"run": "make types", "parser": "tsc"}}}
|
|
112
|
+
To run checks in order, give a check "after" in firstlight.json. Without
|
|
113
|
+
firstlight.json, "firstlight run" runs the package.json scripts lint,
|
|
114
|
+
typecheck, test, and archstrict, and names the other scripts that start
|
|
115
|
+
with one of those words or with "check" (check:architecture, test:e2e)
|
|
116
|
+
on a "not run:" line, without running them.
|
|
117
|
+
|
|
118
|
+
Exit codes:
|
|
119
|
+
0 every check passed (run), or no check failed or was cancelled
|
|
120
|
+
(results, follow), or no failure is new and every check finished
|
|
121
|
+
(results --new), or stop finished
|
|
122
|
+
1 a check failed, timed out, was skipped, was cancelled, or its
|
|
123
|
+
result is unknown; or firstlight itself failed, with {"error","do"}
|
|
124
|
+
on stderr
|
|
125
|
+
|
|
126
|
+
State: \${XDG_STATE_HOME:-~/.local/state}/firstlight/<repo-key>/
|
|
127
|
+
`;
|
|
128
|
+
async function main(argv) {
|
|
129
|
+
if (argv[0] === SUPERVISE_COMMAND) {
|
|
130
|
+
const runDir = argv[1];
|
|
131
|
+
if (runDir === undefined) {
|
|
132
|
+
throw new AgentError("the supervisor needs a run directory", "this is an internal command; use `firstlight run`");
|
|
133
|
+
}
|
|
134
|
+
supervise(runDir);
|
|
135
|
+
return new Promise(() => { });
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = parseArgs({
|
|
140
|
+
args: argv,
|
|
141
|
+
allowPositionals: true,
|
|
142
|
+
strict: true,
|
|
143
|
+
tokens: true,
|
|
144
|
+
options: {
|
|
145
|
+
cwd: { type: "string" },
|
|
146
|
+
json: { type: "boolean", default: false },
|
|
147
|
+
wait: { type: "boolean", default: false },
|
|
148
|
+
failed: { type: "boolean", default: false },
|
|
149
|
+
new: { type: "boolean", default: false },
|
|
150
|
+
"no-cache": { type: "boolean", default: false },
|
|
151
|
+
write: { type: "boolean", default: false },
|
|
152
|
+
force: { type: "boolean", default: false },
|
|
153
|
+
help: { type: "boolean", short: "h", default: false },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new AgentError(err.message, "run `firstlight --help` for the usage");
|
|
159
|
+
}
|
|
160
|
+
const { values, tokens } = parsed;
|
|
161
|
+
if (values.help) {
|
|
162
|
+
out(HELP);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
// parseArgs folds the arguments after "--" into the positionals; the
|
|
166
|
+
// tokens keep where the separator was.
|
|
167
|
+
const separator = tokens.find((token) => token.kind === "option-terminator")?.index ?? Infinity;
|
|
168
|
+
const positionals = tokens.filter((token) => token.kind === "positional");
|
|
169
|
+
const [command, ...names] = positionals.filter((token) => token.index < separator).map((token) => token.value);
|
|
170
|
+
const commands = positionals.filter((token) => token.index > separator).map((token) => token.value);
|
|
171
|
+
if (command === undefined && commands.length > 0) {
|
|
172
|
+
throw new AgentError("commands after -- need the run command first", `run \`firstlight run -- ${commands.map(shellQuote).join(" ")}\``);
|
|
173
|
+
}
|
|
174
|
+
if (command === undefined) {
|
|
175
|
+
throw new AgentError("missing command", "run `firstlight run`, or `firstlight --help` for the usage");
|
|
176
|
+
}
|
|
177
|
+
if (!["run", "results", "follow", "stop", "init"].includes(command)) {
|
|
178
|
+
throw new AgentError(`unknown command: ${command}`, "run `firstlight --help` for the usage");
|
|
179
|
+
}
|
|
180
|
+
if (command === "init" && commands.length > 0) {
|
|
181
|
+
throw new AgentError("init picks package.json scripts and takes no commands after --", `add the commands to ${CONFIG_FILE} by hand`);
|
|
182
|
+
}
|
|
183
|
+
if (command !== "run" && command !== "init" && (names.length > 0 || commands.length > 0)) {
|
|
184
|
+
throw new AgentError(`${command} takes no check names`, `run \`firstlight ${command}\` without arguments`);
|
|
185
|
+
}
|
|
186
|
+
if (values.wait && command !== "results") {
|
|
187
|
+
throw new AgentError("--wait belongs to results", "run `firstlight results --wait`");
|
|
188
|
+
}
|
|
189
|
+
if (values.new && command !== "results") {
|
|
190
|
+
throw new AgentError("--new belongs to results", "run `firstlight results --new`");
|
|
191
|
+
}
|
|
192
|
+
if (values["no-cache"] && command !== "run") {
|
|
193
|
+
throw new AgentError("--no-cache belongs to run", "run `firstlight run --no-cache`");
|
|
194
|
+
}
|
|
195
|
+
if (values.write && command !== "init") {
|
|
196
|
+
throw new AgentError("--write belongs to init", "run `firstlight init --write`");
|
|
197
|
+
}
|
|
198
|
+
if (values.force && !values.write) {
|
|
199
|
+
throw new AgentError("--force only lets init --write replace a firstlight.json", "run `firstlight init --write --force`");
|
|
200
|
+
}
|
|
201
|
+
if (values.failed && command !== "run") {
|
|
202
|
+
throw new AgentError("--failed belongs to run", "run `firstlight run --failed`");
|
|
203
|
+
}
|
|
204
|
+
if (values.failed && (names.length > 0 || commands.length > 0)) {
|
|
205
|
+
throw new AgentError("--failed picks the checks itself, so it takes no check names", "run `firstlight run --failed` without names, or name the checks without --failed");
|
|
206
|
+
}
|
|
207
|
+
const root = resolveRoot(path.resolve(values.cwd ?? process.cwd()));
|
|
208
|
+
const ctx = {
|
|
209
|
+
state: new RepoState(root),
|
|
210
|
+
json: values.json,
|
|
211
|
+
cwdFlag: values.cwd === undefined ? "" : ` --cwd ${shellQuote(root)}`,
|
|
212
|
+
};
|
|
213
|
+
switch (command) {
|
|
214
|
+
case "run":
|
|
215
|
+
return runCommand(ctx, values.failed ? "failed" : { names, commands }, !values["no-cache"]);
|
|
216
|
+
case "results":
|
|
217
|
+
return resultsCommand(ctx, values.wait, values.new);
|
|
218
|
+
case "follow":
|
|
219
|
+
return followCommand(ctx);
|
|
220
|
+
case "init":
|
|
221
|
+
return initCommand(ctx, names, values.write, values.force);
|
|
222
|
+
default:
|
|
223
|
+
return stopCommand(ctx);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function runCommand(ctx, selection, useCache) {
|
|
227
|
+
const previous = ctx.state.currentId();
|
|
228
|
+
let checks;
|
|
229
|
+
let notRun = [];
|
|
230
|
+
if (selection === "failed") {
|
|
231
|
+
if (previous === null) {
|
|
232
|
+
throw new AgentError(`no run yet for ${ctx.state.root}, so no check has failed`, cmd(ctx, "run"));
|
|
233
|
+
}
|
|
234
|
+
// Read before the stop, so a broken firstlight.json stops nothing; the
|
|
235
|
+
// selection comes after it, so a check the stop cancelled counts as one
|
|
236
|
+
// that did not pass.
|
|
237
|
+
const configured = configuredChecks(ctx.state.root);
|
|
238
|
+
await stopRun(ctx.state, previous);
|
|
239
|
+
checks = failedChecks(ctx, previous, configured);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const { names, commands } = selection;
|
|
243
|
+
if (names.length === 0 && commands.length === 0) {
|
|
244
|
+
const config = loadConfig(ctx.state.root);
|
|
245
|
+
checks = config.checks;
|
|
246
|
+
notRun = config.notRun;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
checks = resolveRun(ctx.state.root, names, commands);
|
|
250
|
+
}
|
|
251
|
+
if (previous !== null)
|
|
252
|
+
await stopRun(ctx.state, previous);
|
|
253
|
+
}
|
|
254
|
+
checks = withParsers(ctx.state.root, checks);
|
|
255
|
+
// After the stop, so a check of the previous run that was still writing
|
|
256
|
+
// into the tree has finished before the tree is hashed. Keys are recorded
|
|
257
|
+
// even under --no-cache, so a later run can reuse this one.
|
|
258
|
+
const tree = treeKey(ctx.state.root);
|
|
259
|
+
const keys = tree === null ? new Map() : cacheKeys(checks, tree);
|
|
260
|
+
const reused = useCache ? ctx.state.reusable(keys) : new Map();
|
|
261
|
+
const run = ctx.state.createRun(checks, keys, reused);
|
|
262
|
+
const runDir = ctx.state.runDir(run.id);
|
|
263
|
+
// The supervisor has no terminal; its own stderr goes to a file so a
|
|
264
|
+
// crash of firstlight itself is still visible after the fact.
|
|
265
|
+
const errFd = openSync(path.join(runDir, "supervisor.log"), "a");
|
|
266
|
+
let child;
|
|
267
|
+
try {
|
|
268
|
+
child = spawn(process.execPath, [fileURLToPath(import.meta.url), SUPERVISE_COMMAND, runDir], {
|
|
269
|
+
cwd: ctx.state.root,
|
|
270
|
+
detached: true,
|
|
271
|
+
stdio: ["ignore", "ignore", errFd],
|
|
272
|
+
env: process.env,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
closeSync(errFd);
|
|
277
|
+
}
|
|
278
|
+
child.on("error", () => { });
|
|
279
|
+
child.unref();
|
|
280
|
+
if (child.pid === undefined) {
|
|
281
|
+
throw new AgentError(`could not start the supervisor with ${process.execPath}`, "rerun `firstlight run`");
|
|
282
|
+
}
|
|
283
|
+
ctx.state.writeRun({ ...run, supervisor_pid: child.pid });
|
|
284
|
+
ctx.state.setCurrent(run.id);
|
|
285
|
+
ctx.state.prune();
|
|
286
|
+
const typical = ctx.state.typicalDurations(run);
|
|
287
|
+
for (;;) {
|
|
288
|
+
const snapshot = ctx.state.snapshot(run.id);
|
|
289
|
+
const eta = etaOf(snapshot, typical);
|
|
290
|
+
const first = firstFailure(snapshot.checks);
|
|
291
|
+
if (first !== null) {
|
|
292
|
+
const next = snapshot.checks.every(isSettled) ? cmd(ctx, "run --failed") : cmd(ctx, "results --wait");
|
|
293
|
+
out(ctx.json ? jsonLines(snapshot, next, eta, notRun) : text(snapshot, next, eta, first.name, notRun));
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
if (snapshot.checks.every(isSettled)) {
|
|
297
|
+
if (snapshot.checks.every((view) => view.status === "passed")) {
|
|
298
|
+
out(ctx.json ? jsonLines(snapshot, null, eta, notRun) : allPassedText(snapshot, notRun));
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
const superseded = ctx.state.currentId() !== run.id;
|
|
302
|
+
const next = superseded ? cmd(ctx, "results --wait") : cmd(ctx, "run --failed");
|
|
303
|
+
out(ctx.json ? jsonLines(snapshot, next, eta, notRun) : text(snapshot, next, eta, null, notRun));
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
await sleep(POLL_MS);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function failedChecks(ctx, previous, configured) {
|
|
310
|
+
const snapshot = ctx.state.snapshot(previous);
|
|
311
|
+
const specs = new Map(snapshot.run.checks.map((spec) => [spec.name, spec]));
|
|
312
|
+
const { checks, dropped } = selectFailed(configured, snapshot.checks.map((view) => ({ spec: specs.get(view.name), status: view.status })));
|
|
313
|
+
if (checks.length > 0)
|
|
314
|
+
return checks;
|
|
315
|
+
if (dropped.length > 0) {
|
|
316
|
+
throw new AgentError(`the checks that did not pass in run ${previous} are no longer in firstlight.json: ${dropped.join(", ")}`, cmd(ctx, "run"));
|
|
317
|
+
}
|
|
318
|
+
throw new AgentError(`run ${previous} has no check that did not pass`, cmd(ctx, "run"));
|
|
319
|
+
}
|
|
320
|
+
async function resultsCommand(ctx, wait, onlyNew) {
|
|
321
|
+
const id = ctx.state.currentId();
|
|
322
|
+
if (id === null) {
|
|
323
|
+
throw new AgentError(`no run yet for ${ctx.state.root}`, cmd(ctx, "run"));
|
|
324
|
+
}
|
|
325
|
+
let snapshot = ctx.state.snapshot(id);
|
|
326
|
+
while (wait && !snapshot.checks.every(isSettled)) {
|
|
327
|
+
await sleep(POLL_MS);
|
|
328
|
+
snapshot = ctx.state.snapshot(id);
|
|
329
|
+
}
|
|
330
|
+
const eta = etaOf(snapshot, ctx.state.typicalDurations(snapshot.run));
|
|
331
|
+
if (onlyNew) {
|
|
332
|
+
const comparison = compare(ctx, snapshot);
|
|
333
|
+
const failing = [...comparison.checks.values()].some((check) => check.isNew);
|
|
334
|
+
const running = snapshot.checks.some((view) => bucketOf(view.status) === "running");
|
|
335
|
+
const unproven = snapshot.checks.some((view) => isUnproven(view.status));
|
|
336
|
+
const next = running ? cmd(ctx, "results --new --wait") : failing || unproven ? cmd(ctx, "run --failed") : null;
|
|
337
|
+
out(ctx.json ? newJsonLines(snapshot, comparison, next, eta) : newText(snapshot, comparison, next, eta));
|
|
338
|
+
return failing || unproven ? 1 : 0;
|
|
339
|
+
}
|
|
340
|
+
const next = nextFor(ctx, snapshot);
|
|
341
|
+
out(ctx.json ? jsonLines(snapshot, next, eta) : text(snapshot, next, eta));
|
|
342
|
+
return resultsCode(snapshot);
|
|
343
|
+
}
|
|
344
|
+
// Reads the logs of the latest run and of each check's baseline; the
|
|
345
|
+
// comparison itself is pure.
|
|
346
|
+
function compare(ctx, snapshot) {
|
|
347
|
+
const baselines = ctx.state.baselines(snapshot.run);
|
|
348
|
+
const earlier = new Map();
|
|
349
|
+
for (const [name, baseline] of baselines) {
|
|
350
|
+
const failed = isFailure(baseline.status);
|
|
351
|
+
const dir = ctx.state.runDir(baseline.run);
|
|
352
|
+
earlier.set(name, { failed, diagnostics: failed ? checkDiagnostics(baseline.record, dir, name) : [] });
|
|
353
|
+
}
|
|
354
|
+
const latest = snapshot.checks.map((view) => {
|
|
355
|
+
const failed = isFailure(view.status);
|
|
356
|
+
return { name: view.name, failed, diagnostics: failed ? checkDiagnostics(snapshot.run, snapshot.dir, view.name) : [] };
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
baselines: new Map([...baselines].map(([name, baseline]) => [name, baseline.run])),
|
|
360
|
+
checks: new Map(compareFailures(latest, earlier).map((check) => [check.name, check])),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// A cancelled check proved nothing, so it does not count as a pass.
|
|
364
|
+
function resultsCode(snapshot) {
|
|
365
|
+
const unproven = snapshot.checks.some((view) => {
|
|
366
|
+
const bucket = bucketOf(view.status);
|
|
367
|
+
return bucket === "failed" || bucket === "cancelled";
|
|
368
|
+
});
|
|
369
|
+
return unproven ? 1 : 0;
|
|
370
|
+
}
|
|
371
|
+
// Reads events.jsonl and never writes it. Each poll replays the whole file
|
|
372
|
+
// and prints the steps past the ones already printed; the snapshot is taken
|
|
373
|
+
// before the events are read, so once it says every check is settled, the
|
|
374
|
+
// events read after it hold every step it saw.
|
|
375
|
+
async function followCommand(ctx) {
|
|
376
|
+
const id = ctx.state.currentId();
|
|
377
|
+
if (id === null) {
|
|
378
|
+
throw new AgentError(`no run yet for ${ctx.state.root}`, cmd(ctx, "run"));
|
|
379
|
+
}
|
|
380
|
+
const run = ctx.state.readRun(id);
|
|
381
|
+
const typical = ctx.state.typicalDurations(run);
|
|
382
|
+
const names = run.checks.map((check) => check.name);
|
|
383
|
+
let printed = 0;
|
|
384
|
+
for (;;) {
|
|
385
|
+
const snapshot = ctx.state.snapshot(id);
|
|
386
|
+
let steps;
|
|
387
|
+
try {
|
|
388
|
+
steps = replay(names, readEvents(eventsPath(snapshot.dir)));
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
if (!(err instanceof IllegalTransition))
|
|
392
|
+
throw err;
|
|
393
|
+
throw new AgentError(`inconsistent events in ${eventsPath(snapshot.dir)}: ${err.message}`, cmd(ctx, "run"));
|
|
394
|
+
}
|
|
395
|
+
for (const step of steps.slice(printed)) {
|
|
396
|
+
const views = presentChecks({ checks: step.checks, outcome: null }, true);
|
|
397
|
+
const view = views.find((candidate) => candidate.name === step.event.check);
|
|
398
|
+
// The estimate as it stood when the change happened, not as of now.
|
|
399
|
+
const eta = estimate(views, run.checks, typical, Date.parse(step.event.at));
|
|
400
|
+
const at = { ...snapshot, checks: views };
|
|
401
|
+
out(ctx.json ? `${checkJson(at, view, eta)}\n` : changeText(at, view, eta));
|
|
402
|
+
}
|
|
403
|
+
printed = steps.length;
|
|
404
|
+
if (snapshot.checks.every(isSettled)) {
|
|
405
|
+
const eta = etaOf(snapshot, typical);
|
|
406
|
+
// An orphan has no event of its own; its unknown status is derived.
|
|
407
|
+
for (const view of snapshot.checks.filter((candidate) => candidate.orphan)) {
|
|
408
|
+
out(ctx.json ? `${checkJson(snapshot, view, eta)}\n` : changeText(snapshot, view, eta));
|
|
409
|
+
}
|
|
410
|
+
const next = nextFor(ctx, snapshot);
|
|
411
|
+
out(ctx.json ? `${JSON.stringify(summarize(snapshot, next, eta))}\n` : summaryText(snapshot, next, eta));
|
|
412
|
+
return resultsCode(snapshot);
|
|
413
|
+
}
|
|
414
|
+
await sleep(POLL_MS);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function initCommand(ctx, names, write, force) {
|
|
418
|
+
const { checks, leftOut } = proposeChecks(ctx.state.root, names);
|
|
419
|
+
const durations = ctx.state.passDurations(checks);
|
|
420
|
+
const proposed = checks.map((check) => {
|
|
421
|
+
const ms = durations.get(check.name);
|
|
422
|
+
const timeoutSec = ms === undefined ? null : suggestTimeoutSec(ms);
|
|
423
|
+
return timeoutSec === null ? check : { ...check, timeoutSec };
|
|
424
|
+
});
|
|
425
|
+
const file = path.join(ctx.state.root, CONFIG_FILE);
|
|
426
|
+
const exists = existsSync(file);
|
|
427
|
+
const content = configText(proposed);
|
|
428
|
+
if (write) {
|
|
429
|
+
if (exists && !force) {
|
|
430
|
+
throw new AgentError(`${file} already exists, and init does not replace it`, cmd(ctx, `init${argsOf(names)} --write --force`));
|
|
431
|
+
}
|
|
432
|
+
writeFileSync(file, content);
|
|
433
|
+
}
|
|
434
|
+
const next = write ? cmd(ctx, "run") : cmd(ctx, `init${argsOf(names)} --write${exists ? " --force" : ""}`);
|
|
435
|
+
const widen = leftOut.length === 0 ? null : cmd(ctx, `init${argsOf([...checks.map((check) => check.name), ...leftOut])}`);
|
|
436
|
+
if (ctx.json) {
|
|
437
|
+
out(`${JSON.stringify({ type: "init", file, written: write, existed: exists, config: JSON.parse(content), left_out: leftOut, next })}\n`);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
const lines = [write ? `wrote ${file}:` : `would write ${file}${exists ? " (it exists; --force replaces it)" : ""}:`, content.trimEnd()];
|
|
441
|
+
if (widen !== null)
|
|
442
|
+
lines.push(`left out: ${leftOut.join(", ")} (to add them: ${widen})`);
|
|
443
|
+
lines.push(`next: ${next}`);
|
|
444
|
+
out(`${lines.join("\n")}\n`);
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
function argsOf(names) {
|
|
448
|
+
return names.map((name) => ` ${shellQuote(name)}`).join("");
|
|
449
|
+
}
|
|
450
|
+
async function stopCommand(ctx) {
|
|
451
|
+
const id = ctx.state.currentId();
|
|
452
|
+
const stopped = id === null ? false : await stopRun(ctx.state, id);
|
|
453
|
+
if (!stopped || id === null) {
|
|
454
|
+
if (ctx.json) {
|
|
455
|
+
out(`${JSON.stringify({ type: "summary", run: id, failed: [], running: [], passed: [], cancelled: [], next: null, eta_ms: 0 })}\n`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
out("nothing running\n");
|
|
459
|
+
}
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
const snapshot = ctx.state.snapshot(id);
|
|
463
|
+
const next = cmd(ctx, "run");
|
|
464
|
+
const eta = etaOf(snapshot, ctx.state.typicalDurations(snapshot.run));
|
|
465
|
+
out(ctx.json ? jsonLines(snapshot, next, eta) : `stopped run ${id}\n${text(snapshot, next, eta)}`);
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
// The same path serves `stop` and the start of `run`. The supervisor gets
|
|
469
|
+
// SIGTERM and cancels its own checks; only if it fails to finish in time do
|
|
470
|
+
// we SIGKILL the groups it recorded, which leaves those checks unknown.
|
|
471
|
+
async function stopRun(state, id) {
|
|
472
|
+
let snapshot = state.snapshot(id);
|
|
473
|
+
if (snapshot.outcome !== null || !snapshot.supervisorAlive)
|
|
474
|
+
return false;
|
|
475
|
+
const supervisor = snapshot.run.supervisor_pid;
|
|
476
|
+
signalPid(supervisor, "SIGTERM");
|
|
477
|
+
const deadline = Date.now() + STOP_WAIT_MS;
|
|
478
|
+
while (Date.now() < deadline) {
|
|
479
|
+
await sleep(POLL_MS);
|
|
480
|
+
snapshot = state.snapshot(id);
|
|
481
|
+
if (snapshot.outcome !== null || !snapshot.supervisorAlive)
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
for (const view of snapshot.checks) {
|
|
485
|
+
if (view.status === "running" && view.pid !== null)
|
|
486
|
+
signalGroup(view.pid, "SIGKILL");
|
|
487
|
+
}
|
|
488
|
+
signalPid(supervisor, "SIGKILL");
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
function firstFailure(views) {
|
|
492
|
+
const failed = views.filter((view) => view.status === "failed" || view.status === "timed_out");
|
|
493
|
+
const endOf = (view) => Date.parse(view.started_at ?? "") + (view.elapsed_ms ?? 0);
|
|
494
|
+
failed.sort((a, b) => endOf(a) - endOf(b));
|
|
495
|
+
return failed[0] ?? null;
|
|
496
|
+
}
|
|
497
|
+
function etaOf(snapshot, typical) {
|
|
498
|
+
return estimate(snapshot.checks, snapshot.run.checks, typical, Date.now());
|
|
499
|
+
}
|
|
500
|
+
function nextFor(ctx, snapshot) {
|
|
501
|
+
const buckets = snapshot.checks.map((view) => bucketOf(view.status));
|
|
502
|
+
if (buckets.includes("running"))
|
|
503
|
+
return cmd(ctx, "results --wait");
|
|
504
|
+
if (buckets.includes("failed") || buckets.includes("cancelled"))
|
|
505
|
+
return cmd(ctx, "run --failed");
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
function cmd(ctx, rest) {
|
|
509
|
+
return `firstlight ${rest}${ctx.cwdFlag}`;
|
|
510
|
+
}
|
|
511
|
+
function out(textValue) {
|
|
512
|
+
writeSync(1, textValue);
|
|
513
|
+
}
|
|
514
|
+
const tooOld = nodeVersionError(process.version);
|
|
515
|
+
if (tooOld !== null)
|
|
516
|
+
exitWithEnvelope(tooOld);
|
|
517
|
+
main(process.argv.slice(2)).then((code) => {
|
|
518
|
+
process.exitCode = code;
|
|
519
|
+
}, (err) => exitWithEnvelope(err));
|