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/store.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// The on-disk state of runs under XDG_STATE_HOME, never inside the checked
|
|
2
|
+
// repository: repository identity, run ids, run.json, events.jsonl, the
|
|
3
|
+
// `current` pointer, check logs, and pruning, and the earlier runs read back
|
|
4
|
+
// for the time-left estimate, for reused passes, and for the baselines of
|
|
5
|
+
// `results --new`. The CLI writes run.json and `current`; only the
|
|
6
|
+
// supervisor appends to events.jsonl.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { CHECK_STATUSES, foldEvents, IllegalTransition, presentChecks, } from "./checks.js";
|
|
13
|
+
import { findBaselines } from "./baseline.js";
|
|
14
|
+
import { findReuse } from "./cache.js";
|
|
15
|
+
import { AgentError, messageOf } from "./errors.js";
|
|
16
|
+
import { typicalDurations } from "./eta.js";
|
|
17
|
+
import { logFileNames } from "./names.js";
|
|
18
|
+
import { isPidAlive } from "./processes.js";
|
|
19
|
+
export const KEEP_RUNS = 20;
|
|
20
|
+
const RUN_ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
21
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
22
|
+
export function resolveRoot(cwd) {
|
|
23
|
+
let dir;
|
|
24
|
+
try {
|
|
25
|
+
dir = realpathSync(cwd);
|
|
26
|
+
if (!statSync(dir).isDirectory())
|
|
27
|
+
throw new Error("not a directory");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new AgentError(`not a directory: ${cwd}`, "pass an existing directory to --cwd");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const env = { ...process.env };
|
|
34
|
+
delete env.GIT_DIR;
|
|
35
|
+
delete env.GIT_WORK_TREE;
|
|
36
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
37
|
+
cwd: dir,
|
|
38
|
+
env,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
timeout: 5_000,
|
|
42
|
+
}).trim();
|
|
43
|
+
if (top.length > 0)
|
|
44
|
+
return top;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a git repository, or no git: the directory itself is the root.
|
|
48
|
+
}
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
export function stateBase() {
|
|
52
|
+
const fromEnv = process.env.XDG_STATE_HOME?.trim();
|
|
53
|
+
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(homedir(), ".local", "state");
|
|
54
|
+
return path.resolve(base, "firstlight");
|
|
55
|
+
}
|
|
56
|
+
export function repoKey(root) {
|
|
57
|
+
return createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
58
|
+
}
|
|
59
|
+
export class RepoState {
|
|
60
|
+
root;
|
|
61
|
+
dir;
|
|
62
|
+
constructor(root) {
|
|
63
|
+
this.root = root;
|
|
64
|
+
this.dir = path.join(stateBase(), repoKey(root));
|
|
65
|
+
}
|
|
66
|
+
runDir(id) {
|
|
67
|
+
return path.join(this.dir, "runs", id);
|
|
68
|
+
}
|
|
69
|
+
currentId() {
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
text = readFileSync(path.join(this.dir, "current"), "utf8").trim();
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (isEnoent(err))
|
|
76
|
+
return null;
|
|
77
|
+
throw new AgentError(`could not read the current run: ${messageOf(err)}`, `check permissions on ${this.dir}`);
|
|
78
|
+
}
|
|
79
|
+
return RUN_ID_RE.test(text) ? text : null;
|
|
80
|
+
}
|
|
81
|
+
setCurrent(id) {
|
|
82
|
+
atomicWrite(path.join(this.dir, "current"), `${id}\n`);
|
|
83
|
+
}
|
|
84
|
+
createRun(checks, cacheKeys, reused) {
|
|
85
|
+
const id = newRunId();
|
|
86
|
+
mkdirSync(this.runDir(id), { recursive: true });
|
|
87
|
+
const run = {
|
|
88
|
+
id,
|
|
89
|
+
root: this.root,
|
|
90
|
+
supervisor_pid: null,
|
|
91
|
+
started_at: new Date().toISOString(),
|
|
92
|
+
checks,
|
|
93
|
+
cache_keys: Object.fromEntries(cacheKeys),
|
|
94
|
+
reused: Object.fromEntries(reused),
|
|
95
|
+
};
|
|
96
|
+
this.writeRun(run);
|
|
97
|
+
return run;
|
|
98
|
+
}
|
|
99
|
+
writeRun(run) {
|
|
100
|
+
atomicWrite(path.join(this.runDir(run.id), "run.json"), `${JSON.stringify(run, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
readRun(id) {
|
|
103
|
+
const file = path.join(this.runDir(id), "run.json");
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
throw new AgentError(`could not read ${file}: ${messageOf(err)}`, "start a new run with `firstlight run`");
|
|
110
|
+
}
|
|
111
|
+
if (!isRunRecord(raw)) {
|
|
112
|
+
throw new AgentError(`unreadable run record: ${file}`, "start a new run with `firstlight run`");
|
|
113
|
+
}
|
|
114
|
+
return raw;
|
|
115
|
+
}
|
|
116
|
+
snapshot(id) {
|
|
117
|
+
const run = this.readRun(id);
|
|
118
|
+
const dir = this.runDir(id);
|
|
119
|
+
const names = run.checks.map((check) => check.name);
|
|
120
|
+
let folded = this.fold(dir, names);
|
|
121
|
+
let supervisorAlive = true;
|
|
122
|
+
if (folded.outcome === null) {
|
|
123
|
+
supervisorAlive = isPidAlive(run.supervisor_pid);
|
|
124
|
+
// The supervisor writes its final event and then exits; read again so
|
|
125
|
+
// that ordering is never mistaken for an orphan.
|
|
126
|
+
if (!supervisorAlive)
|
|
127
|
+
folded = this.fold(dir, names);
|
|
128
|
+
}
|
|
129
|
+
return { run, dir, outcome: folded.outcome, supervisorAlive, checks: presentChecks(folded, supervisorAlive) };
|
|
130
|
+
}
|
|
131
|
+
fold(dir, names) {
|
|
132
|
+
try {
|
|
133
|
+
return foldEvents(names, readEvents(path.join(dir, "events.jsonl")));
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof IllegalTransition) {
|
|
137
|
+
throw new AgentError(`inconsistent events in ${path.join(dir, "events.jsonl")}: ${err.message}`, "start a new run with `firstlight run`");
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
typicalDurations(run) {
|
|
143
|
+
const past = this.pastRuns()
|
|
144
|
+
.filter((files) => files.record.id < run.id)
|
|
145
|
+
.map((files) => ({ checks: files.record.checks, states: files.states }));
|
|
146
|
+
return typicalDurations(run.checks, past);
|
|
147
|
+
}
|
|
148
|
+
// The newest pass of each check in any kept run, for checks that no run
|
|
149
|
+
// holds yet, as `firstlight init` proposes them.
|
|
150
|
+
passDurations(specs) {
|
|
151
|
+
const past = this.pastRuns().map((files) => ({ checks: files.record.checks, states: files.states }));
|
|
152
|
+
return typicalDurations(specs, past);
|
|
153
|
+
}
|
|
154
|
+
// The run each check of `run` is compared with, keyed by check name, with
|
|
155
|
+
// that run's record, whose parser and log the comparison reads.
|
|
156
|
+
baselines(run) {
|
|
157
|
+
const past = this.pastRuns().filter((files) => files.record.id < run.id);
|
|
158
|
+
const records = new Map(past.map((files) => [files.record.id, files.record]));
|
|
159
|
+
const found = findBaselines(run.checks, past.map((files) => ({ id: files.record.id, checks: files.record.checks, states: files.states })));
|
|
160
|
+
return new Map([...found].map(([name, baseline]) => [name, { ...baseline, record: records.get(baseline.run) }]));
|
|
161
|
+
}
|
|
162
|
+
// Called before the new run exists, so every kept run is earlier.
|
|
163
|
+
reusable(cacheKeys) {
|
|
164
|
+
const past = this.pastRuns().map((files) => ({
|
|
165
|
+
id: files.record.id,
|
|
166
|
+
keys: files.record.cache_keys ?? {},
|
|
167
|
+
states: files.states,
|
|
168
|
+
}));
|
|
169
|
+
return findReuse(cacheKeys, past);
|
|
170
|
+
}
|
|
171
|
+
// Newest first. Only the kept runs are read, so the history costs no file
|
|
172
|
+
// of its own. An earlier run that no longer reads cleanly is history with
|
|
173
|
+
// nothing to say, not a reason to fail the current command.
|
|
174
|
+
pastRuns() {
|
|
175
|
+
const past = [];
|
|
176
|
+
for (const id of this.runIds()) {
|
|
177
|
+
try {
|
|
178
|
+
const record = this.readRun(id);
|
|
179
|
+
const names = record.checks.map((check) => check.name);
|
|
180
|
+
const folded = foldEvents(names, readEvents(eventsPath(this.runDir(id))));
|
|
181
|
+
past.push({ record, states: folded.checks });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return past;
|
|
188
|
+
}
|
|
189
|
+
// Newest first. Run ids sort by creation time.
|
|
190
|
+
runIds() {
|
|
191
|
+
let names;
|
|
192
|
+
try {
|
|
193
|
+
names = readdirSync(path.join(this.dir, "runs"));
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (isEnoent(err))
|
|
197
|
+
return [];
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
return names.filter((name) => RUN_ID_RE.test(name)).sort().reverse();
|
|
201
|
+
}
|
|
202
|
+
prune(keep = KEEP_RUNS) {
|
|
203
|
+
for (const id of this.runIds().slice(keep)) {
|
|
204
|
+
rmSync(this.runDir(id), { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Derived from the run's own check list on every read, so run.json needs no
|
|
209
|
+
// field for it and a run written before script and command checks existed
|
|
210
|
+
// keeps `<name>.log`.
|
|
211
|
+
export function logPaths(runDir, checks) {
|
|
212
|
+
const files = logFileNames(checks.map((check) => check.name));
|
|
213
|
+
return new Map([...files].map(([name, file]) => [name, path.join(runDir, file)]));
|
|
214
|
+
}
|
|
215
|
+
export function eventsPath(runDir) {
|
|
216
|
+
return path.join(runDir, "events.jsonl");
|
|
217
|
+
}
|
|
218
|
+
// One appendFileSync per event is one write(2) with O_APPEND, so a reader
|
|
219
|
+
// never sees two events interleaved.
|
|
220
|
+
export function appendEvent(runDir, event) {
|
|
221
|
+
appendFileSync(eventsPath(runDir), `${JSON.stringify(event)}\n`);
|
|
222
|
+
}
|
|
223
|
+
export function readEvents(file) {
|
|
224
|
+
let text;
|
|
225
|
+
try {
|
|
226
|
+
text = readFileSync(file, "utf8");
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
if (isEnoent(err))
|
|
230
|
+
return [];
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
const lines = text.split("\n");
|
|
234
|
+
// A line without its newline may still be in the middle of being written.
|
|
235
|
+
lines.pop();
|
|
236
|
+
const events = [];
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
if (line.trim().length === 0)
|
|
239
|
+
continue;
|
|
240
|
+
let raw;
|
|
241
|
+
try {
|
|
242
|
+
raw = JSON.parse(line);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
throw new AgentError(`unreadable line in ${file}: ${line}`, "start a new run with `firstlight run`");
|
|
246
|
+
}
|
|
247
|
+
if (!isEvent(raw)) {
|
|
248
|
+
throw new AgentError(`unexpected event in ${file}: ${line}`, "start a new run with `firstlight run`");
|
|
249
|
+
}
|
|
250
|
+
events.push(raw);
|
|
251
|
+
}
|
|
252
|
+
return events;
|
|
253
|
+
}
|
|
254
|
+
const TAIL_BYTES = 256 * 1024;
|
|
255
|
+
export function tailLines(file, count) {
|
|
256
|
+
let fd;
|
|
257
|
+
try {
|
|
258
|
+
fd = openSync(file, "r");
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (isEnoent(err))
|
|
262
|
+
return [];
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const size = statSync(file).size;
|
|
267
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
268
|
+
const buffer = Buffer.alloc(length);
|
|
269
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
270
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
271
|
+
if (lines.at(-1) === "")
|
|
272
|
+
lines.pop();
|
|
273
|
+
// The first line is partial when the read started mid-file.
|
|
274
|
+
if (length < size)
|
|
275
|
+
lines.shift();
|
|
276
|
+
return lines.slice(-count);
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
closeSync(fd);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Enough for any log a parser reads in practice. A longer log keeps its
|
|
283
|
+
// end, where test runners print their failures.
|
|
284
|
+
const PARSE_BYTES = 8 * 1024 * 1024;
|
|
285
|
+
export function readLogEnd(file) {
|
|
286
|
+
let fd;
|
|
287
|
+
try {
|
|
288
|
+
fd = openSync(file, "r");
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (isEnoent(err))
|
|
292
|
+
return "";
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const size = statSync(file).size;
|
|
297
|
+
const length = Math.min(size, PARSE_BYTES);
|
|
298
|
+
const buffer = Buffer.alloc(length);
|
|
299
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
300
|
+
return buffer.toString("utf8");
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
closeSync(fd);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export function newRunId(now = Date.now()) {
|
|
307
|
+
let time = now;
|
|
308
|
+
let timeChars = "";
|
|
309
|
+
for (let i = 0; i < 10; i += 1) {
|
|
310
|
+
timeChars = CROCKFORD[time % 32] + timeChars;
|
|
311
|
+
time = Math.floor(time / 32);
|
|
312
|
+
}
|
|
313
|
+
let randomChars = "";
|
|
314
|
+
for (const byte of randomBytes(16))
|
|
315
|
+
randomChars += CROCKFORD[byte % 32];
|
|
316
|
+
return timeChars + randomChars;
|
|
317
|
+
}
|
|
318
|
+
function atomicWrite(file, text) {
|
|
319
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
320
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
321
|
+
writeFileSync(tmp, text, "utf8");
|
|
322
|
+
renameSync(tmp, file);
|
|
323
|
+
}
|
|
324
|
+
function isRunRecord(value) {
|
|
325
|
+
if (!isRecord(value))
|
|
326
|
+
return false;
|
|
327
|
+
if (typeof value.id !== "string" || typeof value.root !== "string" || typeof value.started_at !== "string")
|
|
328
|
+
return false;
|
|
329
|
+
if (!(value.supervisor_pid === null || Number.isInteger(value.supervisor_pid)))
|
|
330
|
+
return false;
|
|
331
|
+
if (!Array.isArray(value.checks))
|
|
332
|
+
return false;
|
|
333
|
+
if (value.cache_keys !== undefined && !isStringRecord(value.cache_keys))
|
|
334
|
+
return false;
|
|
335
|
+
if (value.reused !== undefined && !isReuseRecord(value.reused))
|
|
336
|
+
return false;
|
|
337
|
+
return value.checks.every((check) => isRecord(check) &&
|
|
338
|
+
typeof check.name === "string" &&
|
|
339
|
+
typeof check.run === "string" &&
|
|
340
|
+
Array.isArray(check.after) &&
|
|
341
|
+
typeof check.timeoutSec === "number" &&
|
|
342
|
+
(check.parser === undefined ||
|
|
343
|
+
check.parser === null ||
|
|
344
|
+
typeof check.parser === "string" ||
|
|
345
|
+
(Array.isArray(check.parser) && check.parser.every((name) => typeof name === "string"))));
|
|
346
|
+
}
|
|
347
|
+
function isEvent(value) {
|
|
348
|
+
if (!isRecord(value) || typeof value.at !== "string")
|
|
349
|
+
return false;
|
|
350
|
+
if (value.type === "run")
|
|
351
|
+
return value.status === "done" || value.status === "cancelled";
|
|
352
|
+
return (value.type === "check" &&
|
|
353
|
+
typeof value.check === "string" &&
|
|
354
|
+
CHECK_STATUSES.includes(value.status) &&
|
|
355
|
+
(value.cached_from === undefined || typeof value.cached_from === "string"));
|
|
356
|
+
}
|
|
357
|
+
function isStringRecord(value) {
|
|
358
|
+
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
359
|
+
}
|
|
360
|
+
function isReuseRecord(value) {
|
|
361
|
+
return (isRecord(value) &&
|
|
362
|
+
Object.values(value).every((entry) => isRecord(entry) && typeof entry.run === "string" && typeof entry.elapsed_ms === "number"));
|
|
363
|
+
}
|
|
364
|
+
function isRecord(value) {
|
|
365
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
366
|
+
}
|
|
367
|
+
function isEnoent(err) {
|
|
368
|
+
return err.code === "ENOENT";
|
|
369
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The per-run supervisor: a detached process that starts each check when
|
|
2
|
+
// its `after` checks have passed, waits on it, enforces its timeout, and
|
|
3
|
+
// appends every status change to events.jsonl. It is the only writer of
|
|
4
|
+
// that file, so it also records the passes the CLI chose to reuse, before
|
|
5
|
+
// it starts anything, and their dependents start at once. It exits when every check is terminal, or after SIGTERM once
|
|
6
|
+
// it has cancelled what was left. It prints nothing; the CLI reads the file.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { appendFileSync, closeSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { constants } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { advance, initialState, isTerminal, } from "./checks.js";
|
|
12
|
+
import { signalGroup } from "./processes.js";
|
|
13
|
+
import { appendEvent, logPaths } from "./store.js";
|
|
14
|
+
export const SUPERVISE_COMMAND = "__supervise";
|
|
15
|
+
export const GRACE_MS = 3_000;
|
|
16
|
+
const KILL_WAIT_MS = 2_000;
|
|
17
|
+
export function supervise(runDir) {
|
|
18
|
+
const run = JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8"));
|
|
19
|
+
new Supervisor(run, runDir).start();
|
|
20
|
+
}
|
|
21
|
+
class Supervisor {
|
|
22
|
+
run;
|
|
23
|
+
runDir;
|
|
24
|
+
logs;
|
|
25
|
+
states = new Map();
|
|
26
|
+
running = new Map();
|
|
27
|
+
stopping = false;
|
|
28
|
+
finished = false;
|
|
29
|
+
constructor(run, runDir) {
|
|
30
|
+
this.run = run;
|
|
31
|
+
this.runDir = runDir;
|
|
32
|
+
this.logs = logPaths(runDir, run.checks);
|
|
33
|
+
for (const check of run.checks)
|
|
34
|
+
this.states.set(check.name, initialState(check.name));
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
38
|
+
process.on(signal, () => {
|
|
39
|
+
void this.stop();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
for (const check of this.run.checks) {
|
|
43
|
+
const reuse = this.run.reused?.[check.name];
|
|
44
|
+
if (reuse === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
// The log is written so its path in the output points at something
|
|
47
|
+
// that says why this run has no output of its own.
|
|
48
|
+
writeFileSync(this.logs.get(check.name), `firstlight: not run; passed in run ${reuse.run} on the same working tree with the same command\n`);
|
|
49
|
+
this.record(check.name, "passed", { exit_code: 0, elapsed_ms: reuse.elapsed_ms, cached_from: reuse.run });
|
|
50
|
+
}
|
|
51
|
+
this.schedule();
|
|
52
|
+
}
|
|
53
|
+
record(name, status, extra = {}) {
|
|
54
|
+
const event = { type: "check", check: name, status, ...extra, at: new Date().toISOString() };
|
|
55
|
+
this.states.set(name, advance(this.states.get(name), event));
|
|
56
|
+
appendEvent(this.runDir, event);
|
|
57
|
+
}
|
|
58
|
+
status(name) {
|
|
59
|
+
return this.states.get(name).status;
|
|
60
|
+
}
|
|
61
|
+
schedule() {
|
|
62
|
+
if (this.stopping || this.finished)
|
|
63
|
+
return;
|
|
64
|
+
let changed = true;
|
|
65
|
+
while (changed) {
|
|
66
|
+
changed = false;
|
|
67
|
+
for (const check of this.run.checks) {
|
|
68
|
+
if (this.status(check.name) !== "pending")
|
|
69
|
+
continue;
|
|
70
|
+
const deps = check.after.map((dep) => this.status(dep));
|
|
71
|
+
if (deps.some((status) => isTerminal(status) && status !== "passed")) {
|
|
72
|
+
this.record(check.name, "skipped");
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
else if (deps.every((status) => status === "passed")) {
|
|
76
|
+
this.launch(check);
|
|
77
|
+
changed = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (this.run.checks.every((check) => isTerminal(this.status(check.name)))) {
|
|
82
|
+
this.finish("done");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
launch(check) {
|
|
86
|
+
const log = this.logs.get(check.name);
|
|
87
|
+
const fd = openSync(log, "a");
|
|
88
|
+
let child;
|
|
89
|
+
try {
|
|
90
|
+
// detached gives the check its own process group, so one signal to
|
|
91
|
+
// -pid reaches everything the shell started.
|
|
92
|
+
child = spawn("/bin/sh", ["-c", check.run], {
|
|
93
|
+
cwd: this.run.root,
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: ["ignore", fd, fd],
|
|
96
|
+
env: process.env,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
const startedMs = Date.now();
|
|
103
|
+
const pid = child.pid;
|
|
104
|
+
if (pid === undefined) {
|
|
105
|
+
child.once("error", (err) => {
|
|
106
|
+
appendFileSync(log, `firstlight: could not start /bin/sh: ${err.message}\n`);
|
|
107
|
+
});
|
|
108
|
+
this.record(check.name, "running");
|
|
109
|
+
this.record(check.name, "failed", { exit_code: null, elapsed_ms: 0 });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let markExited = () => { };
|
|
113
|
+
const exited = new Promise((resolve) => {
|
|
114
|
+
markExited = resolve;
|
|
115
|
+
});
|
|
116
|
+
const entry = {
|
|
117
|
+
pid,
|
|
118
|
+
startedMs,
|
|
119
|
+
timedOut: false,
|
|
120
|
+
exited,
|
|
121
|
+
timer: setTimeout(() => {
|
|
122
|
+
entry.timedOut = true;
|
|
123
|
+
void this.terminate(entry);
|
|
124
|
+
}, check.timeoutSec * 1000),
|
|
125
|
+
};
|
|
126
|
+
this.running.set(check.name, entry);
|
|
127
|
+
this.record(check.name, "running", { pid });
|
|
128
|
+
child.on("exit", (code, signal) => {
|
|
129
|
+
clearTimeout(entry.timer);
|
|
130
|
+
this.running.delete(check.name);
|
|
131
|
+
const exitCode = exitCodeOf(code, signal);
|
|
132
|
+
const status = entry.timedOut
|
|
133
|
+
? "timed_out"
|
|
134
|
+
: this.stopping
|
|
135
|
+
? "cancelled"
|
|
136
|
+
: exitCode === 0
|
|
137
|
+
? "passed"
|
|
138
|
+
: "failed";
|
|
139
|
+
this.record(check.name, status, { pid, exit_code: exitCode, elapsed_ms: Date.now() - startedMs });
|
|
140
|
+
markExited();
|
|
141
|
+
this.schedule();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// SIGKILL only while the shell is still unreaped: once it has exited its
|
|
145
|
+
// pid, and with it the group id, may belong to a process we did not start.
|
|
146
|
+
async terminate(entry) {
|
|
147
|
+
signalGroup(entry.pid, "SIGTERM");
|
|
148
|
+
const gone = await Promise.race([entry.exited.then(() => true), delay(GRACE_MS).then(() => false)]);
|
|
149
|
+
if (!gone)
|
|
150
|
+
signalGroup(entry.pid, "SIGKILL");
|
|
151
|
+
}
|
|
152
|
+
async stop() {
|
|
153
|
+
if (this.stopping || this.finished)
|
|
154
|
+
return;
|
|
155
|
+
this.stopping = true;
|
|
156
|
+
const entries = [...this.running.values()];
|
|
157
|
+
await Promise.all(entries.map((entry) => this.terminate(entry)));
|
|
158
|
+
await Promise.race([Promise.all(entries.map((entry) => entry.exited)), delay(KILL_WAIT_MS)]);
|
|
159
|
+
for (const check of this.run.checks) {
|
|
160
|
+
if (!isTerminal(this.status(check.name))) {
|
|
161
|
+
this.record(check.name, "cancelled", this.status(check.name) === "running" ? { exit_code: null } : {});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.finish("cancelled");
|
|
165
|
+
}
|
|
166
|
+
finish(outcome) {
|
|
167
|
+
if (this.finished)
|
|
168
|
+
return;
|
|
169
|
+
this.finished = true;
|
|
170
|
+
appendEvent(this.runDir, { type: "run", status: outcome, at: new Date().toISOString() });
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// A signal death is recorded with the shell convention, 128 plus the
|
|
175
|
+
// signal number, because the signal was observed rather than guessed.
|
|
176
|
+
function exitCodeOf(code, signal) {
|
|
177
|
+
if (code !== null)
|
|
178
|
+
return code;
|
|
179
|
+
if (signal !== null) {
|
|
180
|
+
const number = constants.signals[signal];
|
|
181
|
+
if (number !== undefined)
|
|
182
|
+
return 128 + number;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
function delay(ms) {
|
|
187
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
}
|
package/dist/tree.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The tree key: the git tree hash of the working tree as it is on disk,
|
|
2
|
+
// untracked files included and ignored files left out. It is the part of a
|
|
3
|
+
// cache key that stands for the files a check reads. It runs git against a
|
|
4
|
+
// temporary index of its own; the user's index and refs stay as they were.
|
|
5
|
+
// Blobs for changed files land in the object store as loose objects, which
|
|
6
|
+
// git's own gc prunes like any other unreferenced object.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { copyFileSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
12
|
+
// Not a git repository, no git, or any git failure gives null: without a
|
|
13
|
+
// key nothing is reused, which is never wrong, only slower.
|
|
14
|
+
export function treeKey(root) {
|
|
15
|
+
const base = { ...process.env };
|
|
16
|
+
delete base.GIT_DIR;
|
|
17
|
+
delete base.GIT_WORK_TREE;
|
|
18
|
+
delete base.GIT_INDEX_FILE;
|
|
19
|
+
const git = (args, env = base) => execFileSync("git", args, {
|
|
20
|
+
cwd: root,
|
|
21
|
+
env,
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
timeout: GIT_TIMEOUT_MS,
|
|
25
|
+
}).trim();
|
|
26
|
+
let realIndex;
|
|
27
|
+
try {
|
|
28
|
+
// A directory inside some other repository is not that repository's root.
|
|
29
|
+
if (realpathSync(git(["rev-parse", "--show-toplevel"])) !== realpathSync(root))
|
|
30
|
+
return null;
|
|
31
|
+
realIndex = path.resolve(root, git(["rev-parse", "--git-path", "index"]));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const dir = mkdtempSync(path.join(tmpdir(), "firstlight-index-"));
|
|
37
|
+
const index = path.join(dir, "index");
|
|
38
|
+
const env = { ...base, GIT_INDEX_FILE: index };
|
|
39
|
+
try {
|
|
40
|
+
// A copy of the real index carries its stat data, so `git add` hashes
|
|
41
|
+
// only the files that changed. A copy git cannot use (a split index,
|
|
42
|
+
// say) falls back to an empty index, which only costs time.
|
|
43
|
+
for (const seed of [true, false]) {
|
|
44
|
+
rmSync(index, { force: true });
|
|
45
|
+
try {
|
|
46
|
+
if (seed)
|
|
47
|
+
copyFileSync(realIndex, index);
|
|
48
|
+
git(["add", "-A"], env);
|
|
49
|
+
return git(["write-tree"], env);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstlight",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Run lint, typecheck, and tests in parallel for a coding agent, and return at the first failure.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"parallel",
|
|
7
|
+
"checks",
|
|
8
|
+
"lint",
|
|
9
|
+
"typecheck",
|
|
10
|
+
"test-runner",
|
|
11
|
+
"npm-run-all",
|
|
12
|
+
"coding-agents"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "meganemura",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/meganemura/firstlight.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/meganemura/firstlight#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/meganemura/firstlight/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"firstlight": "./dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=24"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:scenarios": "nuka check && nuka run features",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"archstrict": "archstrict check",
|
|
43
|
+
"mutation": "stryker run",
|
|
44
|
+
"prepack": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@hegeldev/hegel": "0.4.5",
|
|
48
|
+
"@meganemura/depug": "0.1.3",
|
|
49
|
+
"@stryker-mutator/core": "10.0.0",
|
|
50
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
51
|
+
"@types/node": "26.5.1",
|
|
52
|
+
"archstrict": "0.1.0",
|
|
53
|
+
"nukadoko": "0.14.0",
|
|
54
|
+
"stryker-agent-reporter": "0.1.0",
|
|
55
|
+
"typescript": "6.0.3",
|
|
56
|
+
"vitest": "4.1.11"
|
|
57
|
+
},
|
|
58
|
+
"overrides": {
|
|
59
|
+
"zod": "4.6.5",
|
|
60
|
+
"ip-address": "10.7.3"
|
|
61
|
+
}
|
|
62
|
+
}
|