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