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