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/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));