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
|
@@ -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,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstlight",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"archstrict": "archstrict check",
|
|
42
|
+
"mutation": "stryker run",
|
|
43
|
+
"prepack": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@hegeldev/hegel": "0.4.5",
|
|
47
|
+
"@meganemura/depug": "0.1.3",
|
|
48
|
+
"@stryker-mutator/core": "10.0.0",
|
|
49
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
50
|
+
"@types/node": "26.5.1",
|
|
51
|
+
"archstrict": "0.1.0",
|
|
52
|
+
"stryker-agent-reporter": "0.1.0",
|
|
53
|
+
"typescript": "6.0.3",
|
|
54
|
+
"vitest": "4.1.11"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"zod": "4.6.5",
|
|
58
|
+
"ip-address": "10.7.3"
|
|
59
|
+
}
|
|
60
|
+
}
|