@stacknyu/pi-check 0.0.0-stage → 1.1.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 +7 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/cli.mjs +348 -0
- package/extension.ts +215 -0
- package/lib/adapters/custom.mjs +35 -0
- package/lib/adapters/go.mjs +358 -0
- package/lib/adapters/python.mjs +320 -0
- package/lib/adapters/ts.mjs +445 -0
- package/lib/broker.mjs +1189 -0
- package/lib/client.mjs +131 -0
- package/lib/commands.mjs +27 -0
- package/lib/repo.mjs +185 -0
- package/lib/util.mjs +395 -0
- package/package.json +46 -3
- package/skills/pi-check/SKILL.md +69 -0
package/lib/broker.mjs
ADDED
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pi-check broker: one per repo, shared by the main checkout and every worktree.
|
|
3
|
+
// Units of work (a Go package's tests, a test file, a project's typecheck, a file's lint) are keyed by
|
|
4
|
+
// the content of everything they depend on. Same content => same key => run once, shared by everyone.
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { loadavg } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import custom from "./adapters/custom.mjs";
|
|
11
|
+
import go, { goGraph } from "./adapters/go.mjs";
|
|
12
|
+
import python from "./adapters/python.mjs";
|
|
13
|
+
import ts from "./adapters/ts.mjs";
|
|
14
|
+
import { RootCtx, kindOptions, loadConfig, rootLockfiles } from "./repo.mjs";
|
|
15
|
+
import { adoptLoginPath, cleanEnv, CPUS, FileHasher, STATE_DIR, VERSION, baseOf, dirOf, ensureDir, fmtMs, git, headLines, isUnder, loadJson, logTo, normRel, parseDuration, pidAlive, repoRoots, runProc, sha, tailLines, writeJson } from "./util.mjs";
|
|
16
|
+
|
|
17
|
+
export const ADAPTERS = { go, ts, python, custom };
|
|
18
|
+
const IGNORE_EVENT = /(^|\/)(\.git|node_modules|\.pi-queue|\.pi-check|\.pi-flow|dist|build|coverage|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|venv|target|\.tox)(\/|$)|\.tsbuildinfo$|~$|\.swp$/;
|
|
19
|
+
const PRIO = { speculative: 0, warm: 0, normal: 10, gate: 20 };
|
|
20
|
+
|
|
21
|
+
let jobSeq = 0;
|
|
22
|
+
|
|
23
|
+
export class Broker {
|
|
24
|
+
constructor(main) {
|
|
25
|
+
this.main = main;
|
|
26
|
+
this.dir = ensureDir(join(main, STATE_DIR));
|
|
27
|
+
this.cacheDir = ensureDir(join(this.dir, "cache"));
|
|
28
|
+
this.tmpDir = ensureDir(join(this.dir, "tmp"));
|
|
29
|
+
this.config = loadConfig(main);
|
|
30
|
+
this.hasher = new FileHasher();
|
|
31
|
+
this.roots = new Map();
|
|
32
|
+
this.inflight = new Map();
|
|
33
|
+
this.pending = [];
|
|
34
|
+
this.running = new Set();
|
|
35
|
+
this.mem = new Map();
|
|
36
|
+
this.known = loadJson(join(this.dir, "known.json"), { ids: {} }) ?? { ids: {} };
|
|
37
|
+
this.known.ids ??= {};
|
|
38
|
+
const st = loadJson(join(this.dir, "state.json"), {}) ?? {};
|
|
39
|
+
this.stats = { requests: 0, unitsAsked: 0, cacheHits: 0, joined: 0, ran: 0, speculativeRan: 0, speculativeUsed: 0, savedMs: 0, ranMs: 0, preempted: 0, execRuns: 0, execHits: 0, ...(st.stats ?? {}) };
|
|
40
|
+
this.recent = st.recent ?? [];
|
|
41
|
+
this.log = logTo(join(this.dir, "broker.log"));
|
|
42
|
+
this.lastUse = Date.now();
|
|
43
|
+
this.unstable = new Map();
|
|
44
|
+
this.dirty = false;
|
|
45
|
+
this.startedAt = Date.now();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------- roots ----------
|
|
49
|
+
|
|
50
|
+
root(abs) {
|
|
51
|
+
let r = this.roots.get(abs);
|
|
52
|
+
if (r) {
|
|
53
|
+
r.lastUse = Date.now();
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
const rr = repoRoots(abs);
|
|
57
|
+
if (!rr || rr.main !== this.main) throw new Error(`${abs} is not a checkout of ${this.main}`);
|
|
58
|
+
const rc = new RootCtx(rr.root, this.main, this.hasher, this.config);
|
|
59
|
+
r = { rc, abs: rr.root, name: rr.root === this.main ? "main" : baseOf(rr.root), watchers: [], changes: new Set(), timer: undefined, lastUse: Date.now(), watching: false };
|
|
60
|
+
this.roots.set(rr.root, r);
|
|
61
|
+
if (rr.root !== abs) this.roots.set(abs, r);
|
|
62
|
+
if (this.config.watch) this.watchRoot(r);
|
|
63
|
+
return r;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
watchRoot(r) {
|
|
67
|
+
this.unwatch(r);
|
|
68
|
+
const onEvent = (base) => (ev, name) => {
|
|
69
|
+
if (!name) return;
|
|
70
|
+
const relPath = normRel(base === "." ? String(name) : `${base}/${name}`);
|
|
71
|
+
if (IGNORE_EVENT.test(relPath)) return;
|
|
72
|
+
r.changes.add(relPath);
|
|
73
|
+
if (ev === "rename") r.structural = true;
|
|
74
|
+
// a speculative run whose inputs just changed is wasted work: stop it
|
|
75
|
+
for (const job of this.running) if (job.root === r && job.speculativeOnly() && job.inputSet.has(relPath)) this.abortJob(job, "inputs changed");
|
|
76
|
+
clearTimeout(r.timer);
|
|
77
|
+
r.timer = setTimeout(() => this.settle(r), this.config.debounceMs);
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
if (process.platform === "linux") {
|
|
81
|
+
// recursive watching on Linux walks node_modules too; watch only directories git sees
|
|
82
|
+
const dirs = new Set(["."]);
|
|
83
|
+
for (const f of r.rc.listing().files) {
|
|
84
|
+
let d = dirOf(f);
|
|
85
|
+
while (d !== "." && !dirs.has(d)) {
|
|
86
|
+
dirs.add(d);
|
|
87
|
+
d = dirOf(d);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const d of dirs) {
|
|
91
|
+
try {
|
|
92
|
+
const w = watch(r.rc.path(d), { persistent: false }, onEvent(d));
|
|
93
|
+
w.on("error", () => {});
|
|
94
|
+
r.watchers.push(w);
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
r.watchedDirs = dirs;
|
|
98
|
+
} else {
|
|
99
|
+
const w = watch(r.abs, { recursive: true, persistent: false }, onEvent("."));
|
|
100
|
+
w.on("error", () => {});
|
|
101
|
+
r.watchers.push(w);
|
|
102
|
+
}
|
|
103
|
+
r.watching = true;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
this.log("watch failed", r.abs, String(e?.message ?? e));
|
|
106
|
+
r.watching = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
unwatch(r) {
|
|
111
|
+
for (const w of r.watchers) {
|
|
112
|
+
try {
|
|
113
|
+
w.close();
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
r.watchers = [];
|
|
117
|
+
r.watching = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
settle(r) {
|
|
121
|
+
if (!existsSync(r.abs)) return this.dropRoot(r);
|
|
122
|
+
const before = r.rc.listing().set;
|
|
123
|
+
r.rc.invalidate(true);
|
|
124
|
+
const after = r.rc.listing().set;
|
|
125
|
+
const changed = new Set([...r.changes].filter((f) => after.has(f) || before.has(f)));
|
|
126
|
+
r.changes.clear();
|
|
127
|
+
if (r.structural && process.platform === "linux") {
|
|
128
|
+
const newDirs = [...after].map(dirOf).filter((d) => !r.watchedDirs?.has(d));
|
|
129
|
+
if (newDirs.length) this.watchRoot(r);
|
|
130
|
+
}
|
|
131
|
+
r.structural = false;
|
|
132
|
+
if (!changed.size) return;
|
|
133
|
+
this.log("changed", r.name, changed.size, [...changed].slice(0, 5));
|
|
134
|
+
if (this.config.speculate) this.speculate(r, changed);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
dropRoot(r) {
|
|
138
|
+
this.unwatch(r);
|
|
139
|
+
for (const [k, v] of this.roots) if (v === r) this.roots.delete(k);
|
|
140
|
+
for (const t of this.pending.filter((x) => x.root === r)) this.resolveTask(t, { status: "error", failures: [{ id: `${t.uid}:[gone]`, title: "checkout was removed", output: "" }] });
|
|
141
|
+
this.pending = this.pending.filter((t) => t.root !== r);
|
|
142
|
+
this.log("root removed", r.abs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Re-read structure unless the watcher says nothing changed recently. Hashes are always re-checked. */
|
|
146
|
+
refreshRoot(r) {
|
|
147
|
+
const l = r.rc._listing;
|
|
148
|
+
if (r.watching && !r.changes.size && l && Date.now() - l.at < 30000) return;
|
|
149
|
+
r.rc.invalidate(true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- planning ----------
|
|
153
|
+
|
|
154
|
+
projects(rc) {
|
|
155
|
+
let p = rc.memo.get("projects");
|
|
156
|
+
if (p) return p;
|
|
157
|
+
p = [];
|
|
158
|
+
for (const a of Object.values(ADAPTERS)) {
|
|
159
|
+
try {
|
|
160
|
+
p.push(...a.detect(rc));
|
|
161
|
+
} catch (e) {
|
|
162
|
+
this.log("detect failed", a.lang, String(e?.stack ?? e));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
rc.memo.set("projects", p);
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
unitsOf(rc, project, kind, env, extraArgs) {
|
|
170
|
+
const k = `units:${project.id}|${kind}|${JSON.stringify([env, extraArgs])}`;
|
|
171
|
+
let u = rc.memo.get(k);
|
|
172
|
+
if (!u) {
|
|
173
|
+
u = ADAPTERS[project.lang].units(rc, project, kind, { env, extraArgs });
|
|
174
|
+
rc.memo.set(k, u);
|
|
175
|
+
}
|
|
176
|
+
return u;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
keyOf(rc, project, kind, unit, extra = "", env) {
|
|
180
|
+
const a = ADAPTERS[project.lang];
|
|
181
|
+
const opts = kindOptions(rc.config, project.dir, kind) || {};
|
|
182
|
+
if (env) opts.env = { ...env, ...opts.env };
|
|
183
|
+
return sha(JSON.stringify([4, process.platform, process.arch, process.version, project.lang, a.version, project.dir, kind, unit.target, a.argsKey(opts, project), a.executionKey?.(rc, project, opts), extra, rc.digest(unit.inputs)]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Units a request covers, with their current keys. */
|
|
187
|
+
plan(rc, req) {
|
|
188
|
+
return rc.batch(() => {
|
|
189
|
+
let changed;
|
|
190
|
+
if (req.scope === "changed") changed = rc.changedFiles(req.base || undefined);
|
|
191
|
+
else if (req.scope === "files") changed = new Set((req.files ?? []).map((f) => normRel(f)));
|
|
192
|
+
else if (req.changedSet) changed = req.changedSet;
|
|
193
|
+
let gone;
|
|
194
|
+
if (changed) {
|
|
195
|
+
gone = new Set();
|
|
196
|
+
for (const f of changed) if (!rc.has(f)) gone.add(dirOf(f));
|
|
197
|
+
}
|
|
198
|
+
let extra = req.extraKey ?? (req.extraArgs?.length ? JSON.stringify(req.extraArgs) : "");
|
|
199
|
+
if (extra === "[]") extra = "";
|
|
200
|
+
if (req.env) extra += sha(JSON.stringify(Object.entries(req.env).map(([k, v]) => [k, k === "GOWORK" && v.startsWith(`${rc.abs}/`) ? v.slice(rc.abs.length + 1) : v]).sort()));
|
|
201
|
+
const goSel = req.goSelect ? this.resolveGo(rc, req.goSelect).map : undefined;
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const project of this.projects(rc)) {
|
|
204
|
+
if (goSel && (project.lang !== "go" || !goSel.has(project.dir))) continue;
|
|
205
|
+
if (req.langs?.length && !req.langs.includes(project.lang)) continue;
|
|
206
|
+
if (req.projects?.length && !req.projects.some((p) => normRel(p) === project.dir || p === project.id || p === project.name)) continue;
|
|
207
|
+
if (req.select && (project.lang !== req.select.lang || project.dir !== req.select.projectDir)) continue;
|
|
208
|
+
for (const kind of project.kinds) {
|
|
209
|
+
if (req.kinds?.length && !req.kinds.includes(kind)) continue;
|
|
210
|
+
if (req.select && kind !== req.select.kind) continue;
|
|
211
|
+
const sel = goSel ? goSel.get(project.dir) : req.select?.targets ? new Set(req.select.targets) : undefined;
|
|
212
|
+
for (const unit of this.unitsOf(rc, project, kind, req.env, req.extraArgs)) {
|
|
213
|
+
if (sel && !sel.has(unit.target)) continue;
|
|
214
|
+
if (changed && !sel) {
|
|
215
|
+
const hit = unit.inputs.some((f) => changed.has(f)) || (gone.size && unit.inputs.some((f) => gone.has(dirOf(f))));
|
|
216
|
+
if (!hit) continue;
|
|
217
|
+
}
|
|
218
|
+
out.push({ project, kind, unit, uid: `${project.id}|${kind}|${unit.target}`, key: this.keyOf(rc, project, kind, unit, extra, req.env), extra });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** go package patterns (./..., ./x, import/path/...) relative to a directory -> module dir -> package dirs */
|
|
227
|
+
resolveGo(rc, { cwdRel = ".", patterns = [] }) {
|
|
228
|
+
const g = goGraph(rc);
|
|
229
|
+
const map = new Map();
|
|
230
|
+
const order = [];
|
|
231
|
+
const noTests = [];
|
|
232
|
+
let unsupported = false;
|
|
233
|
+
for (const pat of patterns.length ? patterns : ["."]) {
|
|
234
|
+
let p = pat;
|
|
235
|
+
let recursive = false;
|
|
236
|
+
if (p === "..." || p.endsWith("/...")) {
|
|
237
|
+
recursive = true;
|
|
238
|
+
p = p === "..." ? "." : p.slice(0, -4);
|
|
239
|
+
}
|
|
240
|
+
let hits = [];
|
|
241
|
+
if (p.startsWith(".")) {
|
|
242
|
+
const dir = normRel(`${cwdRel}/${p}`);
|
|
243
|
+
const owner = g.modules.find((m) => isUnder(dir, m.dir));
|
|
244
|
+
hits = owner ? [...g.pkgs.values()].filter((q) => q.module === owner && (recursive ? isUnder(q.dir, dir) : q.dir === dir)) : [];
|
|
245
|
+
} else if (["all", "std", "cmd"].includes(p)) unsupported = true;
|
|
246
|
+
else hits = [...g.pkgs.values()].filter((q) => (recursive ? q.importPath === p || q.importPath.startsWith(`${p}/`) : q.importPath === p));
|
|
247
|
+
if (!hits.length) unsupported = true;
|
|
248
|
+
for (const q of hits.sort((a, b) => a.importPath.localeCompare(b.importPath))) {
|
|
249
|
+
if (order.includes(q.importPath)) continue;
|
|
250
|
+
order.push(q.importPath);
|
|
251
|
+
if (!q.hasTests) noTests.push(q.importPath);
|
|
252
|
+
if (!map.has(q.module.dir)) map.set(q.module.dir, new Set());
|
|
253
|
+
map.get(q.module.dir).add(q.dir);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { map, order, noTests, unsupported };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------- cache ----------
|
|
260
|
+
|
|
261
|
+
cachePath(key) {
|
|
262
|
+
return join(this.cacheDir, key.slice(0, 2), `${key}.json`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
cacheGet(key) {
|
|
266
|
+
let r = this.mem.get(key);
|
|
267
|
+
if (!r) {
|
|
268
|
+
r = loadJson(this.cachePath(key));
|
|
269
|
+
if (r) this.memSet(key, r);
|
|
270
|
+
}
|
|
271
|
+
if (r?.exec && Date.now() - r.at > 24 * 3600000) return undefined;
|
|
272
|
+
return r;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
memSet(key, r) {
|
|
276
|
+
this.mem.delete(key);
|
|
277
|
+
this.mem.set(key, r);
|
|
278
|
+
if (this.mem.size > 20000) this.mem.delete(this.mem.keys().next().value);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
cachePut(key, rec) {
|
|
282
|
+
this.memSet(key, rec);
|
|
283
|
+
try {
|
|
284
|
+
mkdirSync(join(this.cacheDir, key.slice(0, 2)), { recursive: true });
|
|
285
|
+
writeFileSync(this.cachePath(key), JSON.stringify(rec));
|
|
286
|
+
} catch (e) {
|
|
287
|
+
this.log("cache write failed", String(e?.message ?? e));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
pruneCache() {
|
|
292
|
+
const cutoff = Date.now() - this.config.cacheDays * 86400000;
|
|
293
|
+
let n = 0;
|
|
294
|
+
try {
|
|
295
|
+
for (const d of readdirSync(this.cacheDir)) {
|
|
296
|
+
const dir = join(this.cacheDir, d);
|
|
297
|
+
for (const f of readdirSync(dir)) {
|
|
298
|
+
const p = join(dir, f);
|
|
299
|
+
try {
|
|
300
|
+
if (statSync(p).mtimeMs < cutoff) {
|
|
301
|
+
unlinkSync(p);
|
|
302
|
+
n++;
|
|
303
|
+
}
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
if (n) this.log("pruned", n, "cache entries");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------- tasks & jobs ----------
|
|
312
|
+
|
|
313
|
+
/** Ask for a unit result: cache, join an identical run, or queue a new one. Returns a promise. */
|
|
314
|
+
want(r, item, { priority, speculative, request }) {
|
|
315
|
+
const cached = this.cacheGet(item.key);
|
|
316
|
+
if (cached) {
|
|
317
|
+
if (!speculative) {
|
|
318
|
+
this.stats.cacheHits++;
|
|
319
|
+
this.stats.savedMs += cached.ms ?? 0;
|
|
320
|
+
if (cached.speculative) this.stats.speculativeUsed++;
|
|
321
|
+
}
|
|
322
|
+
return { promise: Promise.resolve({ ...cached, source: cached.root === r.name ? "cache" : "shared" }), cached: true };
|
|
323
|
+
}
|
|
324
|
+
let t = this.inflight.get(item.key);
|
|
325
|
+
if (t) {
|
|
326
|
+
if (!speculative) {
|
|
327
|
+
this.stats.joined++;
|
|
328
|
+
t.speculative = false;
|
|
329
|
+
t.priority = Math.max(t.priority, priority);
|
|
330
|
+
}
|
|
331
|
+
if (speculative) return { promise: Promise.resolve(undefined), joined: true };
|
|
332
|
+
const p = new Promise((res) => t.waiters.push({ res, request }));
|
|
333
|
+
return { promise: p.then((x) => ({ ...x, source: t.root === r ? x.source ?? "ran" : "shared" })), joined: true };
|
|
334
|
+
}
|
|
335
|
+
if (speculative) {
|
|
336
|
+
if ((this.unstable.get(item.uid) ?? 0) >= 2) return undefined;
|
|
337
|
+
}
|
|
338
|
+
t = { key: item.key, uid: item.uid, root: r, project: item.project, kind: item.kind, unit: item.unit, extra: item.extra, extraArgs: item.extraArgs, extraEnv: item.extraEnv, exec: item.exec, priority, speculative, waiters: [], seq: jobSeq++ };
|
|
339
|
+
this.inflight.set(item.key, t);
|
|
340
|
+
this.pending.push(t);
|
|
341
|
+
const p = speculative ? Promise.resolve(undefined) : new Promise((res) => t.waiters.push({ res, request }));
|
|
342
|
+
return { promise: p, queued: true };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
resolveTask(t, result) {
|
|
346
|
+
if (this.inflight.get(t.key) === t) this.inflight.delete(t.key);
|
|
347
|
+
for (const w of t.waiters) w.res(result);
|
|
348
|
+
t.waiters = [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
cancelRequest(request) {
|
|
352
|
+
request.cancelled = true;
|
|
353
|
+
for (const t of this.inflight.values()) {
|
|
354
|
+
for (const w of t.waiters.filter((w) => w.request === request)) w.res({ status: "error", code: 130, failures: [{ id: `${t.uid}:[cancelled]`, title: "request cancelled", output: "" }] });
|
|
355
|
+
t.waiters = t.waiters.filter((w) => w.request !== request);
|
|
356
|
+
if (!t.waiters.length) { t.speculative = true; t.priority = PRIO.speculative; }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
groupOf(t) {
|
|
361
|
+
if (t.exec) return { name: `exec:${t.exec.projectDir}`, limit: this.config.groups[`exec:${t.exec.projectDir}`] ?? 1 };
|
|
362
|
+
const a = ADAPTERS[t.project.lang];
|
|
363
|
+
const opts = kindOptions(this.config, t.project.dir, t.kind) || {};
|
|
364
|
+
const g = a.group(t.project, t.kind, opts);
|
|
365
|
+
return { name: g.name, limit: this.config.groups[g.name] ?? g.limit };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
loadOk() {
|
|
369
|
+
return loadavg()[0] < CPUS * this.config.speculateMaxLoad;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Scheduling hints only: slow suites can explicitly opt out of the fast lane. */
|
|
373
|
+
laneOf(task) {
|
|
374
|
+
const opts = kindOptions(this.config, task.project?.dir ?? task.exec?.projectDir ?? ".", task.kind) || {};
|
|
375
|
+
const explicit = opts.lane ?? task.project?.meta?.lane;
|
|
376
|
+
if (explicit) return explicit;
|
|
377
|
+
if (!task.exec && ["lint", "vet", "typecheck"].includes(task.kind)) return "fast";
|
|
378
|
+
if (!task.exec && task.project?.lang === "ts" && task.kind === "test") return "fast";
|
|
379
|
+
const command = task.exec?.cmd ?? task.project?.meta?.cmd ?? "";
|
|
380
|
+
if (/\bgo\s+(test|build)\b|\b(pnpm|npm|yarn|bun)\s+(run\s+)?build\b|\btsc\s+(-b|--build)\b/.test(command)) return "heavy";
|
|
381
|
+
return /\b(tsc|vue-tsc|eslint|oxlint|ruff|mypy|pyright)\b|\b(pnpm|npm|yarn|bun)\b[^;&|\n]*\b(type|typecheck|lint)\b|\bnode\b[^;&|\n]*\s--test\b/.test(command) ? "fast" : "heavy";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
laneReserved() {
|
|
385
|
+
return this.config.fastLane && this.config.maxJobs > 1 && this.config.cpuBudget > 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Never lend the reserved slot to a long job: late fast work must still fit. */
|
|
389
|
+
allocation(task) {
|
|
390
|
+
const lane = this.laneOf(task), reserved = this.laneReserved();
|
|
391
|
+
const running = [...this.running];
|
|
392
|
+
const peers = running.filter((j) => !reserved || j.lane === lane);
|
|
393
|
+
const limit = reserved ? lane === "fast" ? 1 : this.config.maxJobs - 1 : this.config.maxJobs;
|
|
394
|
+
if (peers.length >= limit) return 0;
|
|
395
|
+
const used = running.reduce((n, j) => n + j.threads, 0);
|
|
396
|
+
const budget = reserved ? lane === "fast" ? 1 : this.config.cpuBudget - 1 : this.config.cpuBudget;
|
|
397
|
+
const available = Math.max(0, Math.min(this.config.cpuBudget - used, budget - peers.reduce((n, j) => n + j.threads, 0)));
|
|
398
|
+
if (!available) return 0;
|
|
399
|
+
const groups = new Map();
|
|
400
|
+
for (const j of this.running) groups.set(j.group.name, (groups.get(j.group.name) ?? 0) + 1);
|
|
401
|
+
let ready = 0;
|
|
402
|
+
for (const t of this.pending) {
|
|
403
|
+
if (reserved && this.laneOf(t) !== lane) continue;
|
|
404
|
+
if (t.speculative && !task.speculative) continue;
|
|
405
|
+
const g = this.groupOf(t), busy = groups.get(g.name) ?? 0;
|
|
406
|
+
if (busy >= g.limit) continue;
|
|
407
|
+
groups.set(g.name, busy + 1);
|
|
408
|
+
ready++;
|
|
409
|
+
}
|
|
410
|
+
const slots = Math.max(1, Math.min(ready, limit - peers.length));
|
|
411
|
+
// Leave capacity for a late requester instead of leasing the whole budget to one job.
|
|
412
|
+
const ceiling = this.config.threads || Math.ceil(budget / Math.min(limit, 2));
|
|
413
|
+
return Math.min(ceiling, Math.max(1, Math.floor(available / slots)));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
pump() {
|
|
417
|
+
if (this.pumping) return;
|
|
418
|
+
if (this.pendingConfig && !this.running.size && !this.pending.some((t) => !t.speculative)) this.applyConfig(this.pendingConfig);
|
|
419
|
+
this.pumping = true;
|
|
420
|
+
try {
|
|
421
|
+
this.pending.sort((a, b) => b.priority - a.priority || a.seq - b.seq);
|
|
422
|
+
const explicitWaiting = () => this.pending.some((t) => !t.speculative);
|
|
423
|
+
let progress = true;
|
|
424
|
+
while (progress) {
|
|
425
|
+
progress = false;
|
|
426
|
+
const groupsBusy = new Map();
|
|
427
|
+
for (const j of this.running) groupsBusy.set(j.group.name, (groupsBusy.get(j.group.name) ?? 0) + 1);
|
|
428
|
+
for (const t of this.pending) {
|
|
429
|
+
const g = this.groupOf(t);
|
|
430
|
+
const busy = groupsBusy.get(g.name) ?? 0;
|
|
431
|
+
if (t.speculative) {
|
|
432
|
+
if (explicitWaiting() || !this.loadOk()) continue;
|
|
433
|
+
if (this.running.size >= Math.max(1, this.config.maxJobs - 1)) continue; // keep a slot for agents
|
|
434
|
+
}
|
|
435
|
+
const threads = this.allocation(t);
|
|
436
|
+
if (this.running.size >= this.config.maxJobs || busy >= g.limit || !threads) {
|
|
437
|
+
if (!t.speculative) {
|
|
438
|
+
// make room: stop a speculative job in the way
|
|
439
|
+
const victim = [...this.running].find((j) => j.speculativeOnly() && (!this.laneReserved() || j.lane === this.laneOf(t)) && (busy >= g.limit ? j.group.name === g.name : true));
|
|
440
|
+
if (victim) this.abortJob(victim, "preempted by a requested check");
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
this.startJob(t, g, threads);
|
|
445
|
+
progress = true;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} finally {
|
|
450
|
+
this.pumping = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
startJob(first, group, threads = this.allocation(first)) {
|
|
455
|
+
const batch = [first];
|
|
456
|
+
if (!first.exec) {
|
|
457
|
+
const max = Math.min(ADAPTERS[first.project.lang].batchSize(first.kind), this.laneReserved() && this.laneOf(first) === "fast" && first.kind === "test" ? 8 : Infinity);
|
|
458
|
+
for (const t of this.pending) {
|
|
459
|
+
if (batch.length >= max) break;
|
|
460
|
+
if (t === first || t.exec) continue;
|
|
461
|
+
if (t.root === first.root && t.project.id === first.project.id && t.kind === first.kind && t.extra === first.extra && t.speculative === first.speculative) batch.push(t);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
this.pending = this.pending.filter((t) => !batch.includes(t));
|
|
465
|
+
const ac = new AbortController();
|
|
466
|
+
const job = {
|
|
467
|
+
id: ++jobSeq,
|
|
468
|
+
root: first.root,
|
|
469
|
+
project: first.project,
|
|
470
|
+
kind: first.exec ? "exec" : first.kind,
|
|
471
|
+
tasks: batch,
|
|
472
|
+
group,
|
|
473
|
+
threads,
|
|
474
|
+
lane: this.laneOf(first),
|
|
475
|
+
ac,
|
|
476
|
+
completed: 0,
|
|
477
|
+
activity: "starting",
|
|
478
|
+
lastOutputAt: Date.now(),
|
|
479
|
+
startedAt: Date.now(),
|
|
480
|
+
inputSet: new Set(batch.flatMap((t) => (t.exec ? t.exec.inputs : t.unit.inputs))),
|
|
481
|
+
speculativeOnly() {
|
|
482
|
+
return this.tasks.every((t) => t.speculative && !t.waiters.length);
|
|
483
|
+
},
|
|
484
|
+
label: first.exec ? `exec: ${first.exec.cmd.slice(0, 60)}` : `${first.project.lang} ${first.project.dir} ${first.kind} (${batch.length} unit${batch.length > 1 ? "s" : ""})`,
|
|
485
|
+
};
|
|
486
|
+
this.running.add(job);
|
|
487
|
+
this.log("start", job.id, job.root.name, job.label, first.speculative ? "speculative" : "");
|
|
488
|
+
this.runJob(job)
|
|
489
|
+
.catch((e) => {
|
|
490
|
+
this.log("job crashed", job.id, String(e?.stack ?? e));
|
|
491
|
+
for (const t of job.tasks) this.resolveTask(t, { status: "error", failures: [{ id: `${t.uid}:[crash]`, title: "pi-check crashed running this check", output: String(e?.stack ?? e).slice(-3000) }] });
|
|
492
|
+
})
|
|
493
|
+
.finally(() => {
|
|
494
|
+
this.running.delete(job);
|
|
495
|
+
this.dirty = true;
|
|
496
|
+
this.pump();
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
abortJob(job, why) {
|
|
501
|
+
if (job.ac.signal.aborted) return;
|
|
502
|
+
job.abortReason = why;
|
|
503
|
+
this.stats.preempted++;
|
|
504
|
+
this.log("abort", job.id, job.label, why);
|
|
505
|
+
job.ac.abort();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async runJob(job) {
|
|
509
|
+
const r = job.root;
|
|
510
|
+
const rc = r.rc;
|
|
511
|
+
const threads = job.threads;
|
|
512
|
+
const nice = this.config.nice;
|
|
513
|
+
if (job.tasks[0].exec) return this.runExecJob(job, threads, nice);
|
|
514
|
+
const { project, kind } = job;
|
|
515
|
+
const a = ADAPTERS[project.lang];
|
|
516
|
+
const opts = kindOptions(this.config, project.dir, kind) || {};
|
|
517
|
+
if (job.tasks[0].extraEnv) opts.env = { ...job.tasks[0].extraEnv, ...opts.env };
|
|
518
|
+
// A joined request owns this snapshot, not whatever the producer edits next.
|
|
519
|
+
const live = [];
|
|
520
|
+
rc.invalidate(true);
|
|
521
|
+
rc.batch(() => {
|
|
522
|
+
for (const t of job.tasks) {
|
|
523
|
+
const unit = this.unitsOf(rc, project, kind, t.extraEnv, t.extraArgs).find((u) => u.target === t.unit.target);
|
|
524
|
+
if (!unit) {
|
|
525
|
+
this.resolveTask(t, { status: "error", failures: [{ id: `${t.uid}:[gone]`, title: `${t.unit.target} no longer exists`, output: "" }] });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const k = this.keyOf(rc, project, kind, unit, t.extra, t.extraEnv);
|
|
529
|
+
if (k !== t.key) {
|
|
530
|
+
this.resolveTask(t, { status: "error", stale: true, failures: [] });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
t.unit = unit;
|
|
534
|
+
live.push(t);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
if (!live.length) return;
|
|
538
|
+
job.tasks = live;
|
|
539
|
+
job.inputSet = new Set(live.flatMap((t) => t.unit.inputs));
|
|
540
|
+
const baseClean = r.abs === this.main ? this.baseIsClean(rc) : undefined;
|
|
541
|
+
const finalized = new Set();
|
|
542
|
+
const byTarget = new Map(live.map((t) => [t.unit.target, t]));
|
|
543
|
+
const finalize = (t, result, ms) => {
|
|
544
|
+
if (finalized.has(t)) return;
|
|
545
|
+
finalized.add(t);
|
|
546
|
+
job.completed++;
|
|
547
|
+
rc.invalidate(true);
|
|
548
|
+
const current = this.unitsOf(rc, project, kind, t.extraEnv, t.extraArgs).find((u) => u.target === t.unit.target);
|
|
549
|
+
const after = current && rc.batch(() => this.keyOf(rc, project, kind, current, t.extra, t.extraEnv));
|
|
550
|
+
const rec = { ...result, key: t.key, uid: t.uid, lang: project.lang, project: project.dir, kind, target: t.unit.target, label: t.unit.label, ms, at: Date.now(), root: r.name, speculative: t.speculative && !t.waiters.length };
|
|
551
|
+
if (t.speculative && !t.waiters.length) this.stats.speculativeRan++;
|
|
552
|
+
else this.stats.ran++;
|
|
553
|
+
if (after === t.key) {
|
|
554
|
+
if (result.status !== "error") this.cachePut(t.key, rec);
|
|
555
|
+
if (result.status !== "error") {
|
|
556
|
+
if (baseClean) this.updateKnown(rec, this.baseIsClean(rc) ?? { dirty: new Set(current.inputs) }, current.inputs);
|
|
557
|
+
else this.noteIfBase(r, project, kind, t, rec);
|
|
558
|
+
}
|
|
559
|
+
this.unstable.delete(t.uid);
|
|
560
|
+
this.resolveTask(t, { ...rec, source: "ran" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.unstable.set(t.uid, (this.unstable.get(t.uid) ?? 0) + 1);
|
|
564
|
+
this.resolveTask(t, { ...rec, source: "ran", stale: true });
|
|
565
|
+
};
|
|
566
|
+
const onUnit = (target, result) => {
|
|
567
|
+
const t = byTarget.get(target);
|
|
568
|
+
if (t && !job.ac.signal.aborted) finalize(t, result, Math.round((result.seconds ?? 0) * 1000));
|
|
569
|
+
};
|
|
570
|
+
const onProgress = (activity) => { job.activity = activity; job.lastOutputAt = Date.now(); };
|
|
571
|
+
const res = await a.run({ rc, project, kind, units: live.map((t) => t.unit), opts, threads, signal: job.ac.signal, nice, tmpDir: this.tmpDir, extraArgs: live[0].extraArgs ?? [], onUnit, onProgress });
|
|
572
|
+
const per = Math.round((res.ms ?? 0) / live.length);
|
|
573
|
+
if (job.ac.signal.aborted) {
|
|
574
|
+
const preempted = String(job.abortReason ?? "").startsWith("preempted");
|
|
575
|
+
for (const t of live) {
|
|
576
|
+
if (finalized.has(t)) continue;
|
|
577
|
+
if (this.inflight.get(t.key) === t) this.inflight.delete(t.key);
|
|
578
|
+
if (t.waiters.length || preempted) {
|
|
579
|
+
// someone needs it, or it only made room for a requested check: run again later
|
|
580
|
+
if (t.waiters.length) t.speculative = false;
|
|
581
|
+
this.inflight.set(t.key, t);
|
|
582
|
+
this.pending.push(t);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.stats.ranMs += res.ms ?? 0;
|
|
588
|
+
for (const t of live) {
|
|
589
|
+
const result = res.results.get(t.unit.target) ?? { status: "error", failures: [{ id: `${t.uid}:[no result]`, title: `${t.unit.label}: no result from ${project.lang} ${kind}`, output: "" }] };
|
|
590
|
+
finalize(t, result, result.seconds ? Math.round(result.seconds * 1000) : per);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A failure in a worktree that is not known yet: does the same unit fail on the base branch too?
|
|
596
|
+
* Look at (or run) the base checkout's version of that unit, so agents never chase old failures.
|
|
597
|
+
*/
|
|
598
|
+
async confirmBase(r, results, emit, req = {}) {
|
|
599
|
+
const unknown = results.filter(({ res }) => res && res.status === "fail" && (res.failures ?? []).some((f) => !this.known.ids[f.id]));
|
|
600
|
+
if (!unknown.length) return;
|
|
601
|
+
const m = this.root(this.main);
|
|
602
|
+
m.rc.invalidate(true);
|
|
603
|
+
const base = this.baseIsClean(m.rc);
|
|
604
|
+
if (!base) return;
|
|
605
|
+
const waits = [];
|
|
606
|
+
m.rc.batch(() => {
|
|
607
|
+
for (const { it } of unknown.slice(0, 60)) {
|
|
608
|
+
const mp = this.projects(m.rc).find((x) => x.id === it.project.id);
|
|
609
|
+
const mu = mp && this.unitsOf(m.rc, mp, it.kind, req.env, req.extraArgs).find((u) => u.target === it.unit.target);
|
|
610
|
+
if (!mu || mu.inputs.some((f) => base.dirty.has(f))) continue;
|
|
611
|
+
const key = this.keyOf(m.rc, mp, it.kind, mu, it.extra, req.env);
|
|
612
|
+
const hit = this.cacheGet(key);
|
|
613
|
+
if (hit) {
|
|
614
|
+
if (hit.status !== "error") this.updateKnown({ ...hit, uid: it.uid, project: mp.dir, kind: it.kind }, base, mu.inputs);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const w = this.want(m, { project: mp, kind: it.kind, unit: mu, uid: it.uid, key, extra: it.extra, extraArgs: req.extraArgs, extraEnv: req.env }, { priority: PRIO.normal, speculative: false, request: req });
|
|
618
|
+
if (w) waits.push(w.promise);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
if (!waits.length) return;
|
|
622
|
+
emit?.({ type: "progress", text: `checking whether ${waits.length} failing unit(s) also fail on the base branch` });
|
|
623
|
+
this.pump();
|
|
624
|
+
await Promise.all(waits);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** A result from a worktree whose inputs are identical to the base branch tip counts as a base result too. */
|
|
628
|
+
noteIfBase(r, project, kind, t, rec) {
|
|
629
|
+
try {
|
|
630
|
+
const m = this.root(this.main);
|
|
631
|
+
m.rc.invalidate(true);
|
|
632
|
+
const base = this.baseIsClean(m.rc);
|
|
633
|
+
if (!base) return;
|
|
634
|
+
const mp = this.projects(m.rc).find((x) => x.id === project.id);
|
|
635
|
+
const mu = mp && this.unitsOf(m.rc, mp, kind, t.extraEnv, t.extraArgs).find((u) => u.target === t.unit.target);
|
|
636
|
+
if (!mu) return;
|
|
637
|
+
if (m.rc.batch(() => this.keyOf(m.rc, mp, kind, mu, t.extra, t.extraEnv)) === t.key) this.updateKnown(rec, base, mu.inputs);
|
|
638
|
+
} catch {}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Main checkout on the base branch tip, and which of its files are uncommitted. */
|
|
642
|
+
baseIsClean(rc) {
|
|
643
|
+
const head = rc.head();
|
|
644
|
+
const base = git(rc.abs, ["rev-parse", rc.baseRef()]);
|
|
645
|
+
if (!base.ok || base.out.trim() !== head) return undefined;
|
|
646
|
+
return { dirty: rc.dirtyFiles(), head };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
updateKnown(rec, base, inputs) {
|
|
650
|
+
if (rec.exec || inputs.some((f) => base.dirty.has(f))) return;
|
|
651
|
+
const ids = this.known.ids;
|
|
652
|
+
let changed = false;
|
|
653
|
+
for (const [id, k] of Object.entries(ids)) {
|
|
654
|
+
if (k.uid === rec.uid && k.source === "base" && !rec.failures.some((f) => f.id === id)) {
|
|
655
|
+
delete ids[id];
|
|
656
|
+
changed = true;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
for (const f of rec.failures ?? []) {
|
|
660
|
+
if (!ids[f.id]) {
|
|
661
|
+
ids[f.id] = { uid: rec.uid, project: rec.project, kind: rec.kind, title: f.title, source: "base", since: Date.now(), head: base.head };
|
|
662
|
+
changed = true;
|
|
663
|
+
} else ids[f.id].seen = Date.now();
|
|
664
|
+
}
|
|
665
|
+
if (changed) this.saveKnown();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
saveKnown() {
|
|
669
|
+
writeJson(join(this.dir, "known.json"), this.known);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ---------- exec (whole commands agents run in bash) ----------
|
|
673
|
+
|
|
674
|
+
execPlan(r, cwdAbs, cmd, env) {
|
|
675
|
+
const rc = r.rc;
|
|
676
|
+
let eff = cwdAbs.startsWith(rc.abs) ? normRel(cwdAbs.slice(rc.abs.length + 1)) : ".";
|
|
677
|
+
const cdm = cmd.match(/^\s*cd\s+(["']?)([^"'&;|]+?)\1\s*(&&|;)/);
|
|
678
|
+
if (cdm && !cdm[2].startsWith("/") && !cdm[2].startsWith("~")) eff = normRel(`${eff}/${cdm[2]}`);
|
|
679
|
+
const dirs = this.projects(rc)
|
|
680
|
+
.map((p) => p.dir)
|
|
681
|
+
.filter((d) => isUnder(eff, d))
|
|
682
|
+
.sort((a, b) => b.length - a.length);
|
|
683
|
+
const projectDir = dirs[0] ?? eff;
|
|
684
|
+
const inputs = [...rc.filesUnder(projectDir), ...rootLockfiles(rc), ...["package.json", "go.work"].filter((f) => rc.has(f))];
|
|
685
|
+
const cwdRel = cwdAbs.startsWith(rc.abs) ? normRel(cwdAbs.slice(rc.abs.length + 1)) : ".";
|
|
686
|
+
const key = rc.batch(() => sha(JSON.stringify(["exec", 3, process.platform, process.arch, process.version, cmd, cwdRel, env ? Object.entries(env).sort() : [], rc.digest(inputs)])));
|
|
687
|
+
return { key, projectDir, inputs, cwdRel };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async runExecJob(job, threads, nice) {
|
|
691
|
+
const t = job.tasks[0];
|
|
692
|
+
const rc = job.root.rc;
|
|
693
|
+
// A broker-owned shell already holds a CPU lease. Nested shims execute inside that
|
|
694
|
+
// lease rather than requesting a second slot and deadlocking maxJobs=1.
|
|
695
|
+
const env = cleanEnv({ CI: "1", FORCE_COLOR: "0", NO_COLOR: "1", PY_COLORS: "0", ...t.exec.env, PI_CHECK_BYPASS: "1", GOMAXPROCS: String(threads), GOFLAGS: `${t.exec.env?.GOFLAGS ?? process.env.GOFLAGS ?? ""} -p=1`.trim() });
|
|
696
|
+
const r = await runProc({ cmd: "/bin/sh", args: ["-c", t.exec.cmd], cwd: rc.path(t.exec.cwdRel), env, signal: job.ac.signal, nice, timeoutMs: parseDuration(this.config.execTimeout, 45 * 60000), maxBytes: 2 * 1024 * 1024 });
|
|
697
|
+
if (job.ac.signal.aborted) {
|
|
698
|
+
if (this.inflight.get(t.key) === t) this.inflight.delete(t.key);
|
|
699
|
+
if (t.waiters.length) {
|
|
700
|
+
t.speculative = false;
|
|
701
|
+
this.inflight.set(t.key, t);
|
|
702
|
+
this.pending.push(t);
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
this.stats.execRuns++;
|
|
707
|
+
this.stats.ranMs += r.ms;
|
|
708
|
+
rc.invalidate(true);
|
|
709
|
+
const again = this.execPlan(job.root, rc.path(t.exec.cwdRel), t.exec.cmd, t.exec.env);
|
|
710
|
+
const rec = { exec: true, key: t.key, code: r.code, output: `${r.stdout}${r.stderr ? (r.stdout ? "\n" : "") + r.stderr : ""}`, ms: r.ms, at: Date.now(), root: job.root.name, cmd: t.exec.cmd, killed: r.killed };
|
|
711
|
+
if (t.exec.cache && again.key === t.exec.inputKey && !r.killed && !/panic: test timed out after/.test(rec.output)) this.cachePut(t.key, rec);
|
|
712
|
+
this.resolveTask(t, { ...rec, source: "ran", stale: again.key !== t.exec.inputKey });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async exec(req, emit) {
|
|
716
|
+
this.lastUse = Date.now();
|
|
717
|
+
const r = this.root(req.root);
|
|
718
|
+
this.refreshRoot(r);
|
|
719
|
+
const p = this.execPlan(r, req.cwd ?? r.abs, req.cmd, req.env);
|
|
720
|
+
// Measurements and artifact-producing commands must actually execute in each requesting checkout.
|
|
721
|
+
const cache = req.cache !== false && !/(?:^|[\s'"])-(?:test\.)?(?:c|o|bench\w*|fuzz\w*|\w*profile|trace|outputdir|count)(?:=|[\s'"]|$)|--(?:coverage|output)|(?:^|\s)(?:>>?|tee)\s/.test(req.cmd);
|
|
722
|
+
const hit = cache && this.cacheGet(p.key);
|
|
723
|
+
if (hit) {
|
|
724
|
+
this.stats.execHits++;
|
|
725
|
+
this.stats.savedMs += hit.ms ?? 0;
|
|
726
|
+
return { ...hit, source: hit.root === r.name ? "cache" : "shared" };
|
|
727
|
+
}
|
|
728
|
+
const w = this.want(r, { key: cache ? p.key : sha(`${p.key}:${randomBytes(16).toString("hex")}`), uid: `exec|${p.cwdRel}|${req.cmd}`, exec: { cmd: req.cmd, cwdRel: p.cwdRel, projectDir: p.projectDir, inputs: p.inputs, inputKey: p.key, cache, env: req.env } }, { priority: req.priority ?? PRIO.normal, speculative: false, request: req });
|
|
729
|
+
const tick = setInterval(() => emit?.({ type: "progress", text: this.waitingText(r) }), 5000);
|
|
730
|
+
this.pump();
|
|
731
|
+
try {
|
|
732
|
+
const result = await w.promise;
|
|
733
|
+
r.rc.invalidate(true);
|
|
734
|
+
return { ...result, stale: result.stale || this.execPlan(r, req.cwd ?? r.abs, req.cmd, req.env).key !== p.key };
|
|
735
|
+
} finally {
|
|
736
|
+
clearInterval(tick);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
waitingText(r) {
|
|
741
|
+
const running = [...this.running].map((j) => `${j.root.name}: ${j.label} [${j.lane}; ${fmtMs(Date.now() - j.startedAt)}, ${j.threads} CPU budget, ${j.completed}/${j.tasks.length} complete; ${j.activity}]`);
|
|
742
|
+
return `running: ${running.join("; ") || "nothing"} · queued units: ${this.pending.filter((t) => !t.speculative).length}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ---------- requests ----------
|
|
746
|
+
|
|
747
|
+
async run(req, emit) {
|
|
748
|
+
this.lastUse = Date.now();
|
|
749
|
+
this.stats.requests++;
|
|
750
|
+
const started = Date.now();
|
|
751
|
+
const r = this.root(req.root);
|
|
752
|
+
this.refreshRoot(r);
|
|
753
|
+
if (req.reloadConfig) this.reload();
|
|
754
|
+
if (req.goSelect) {
|
|
755
|
+
const selected = this.resolveGo(r.rc, req.goSelect);
|
|
756
|
+
if (selected.unsupported) return { ...this.report(r, req, [], Date.now() - started), ok: false, go: selected };
|
|
757
|
+
}
|
|
758
|
+
const items = this.plan(r.rc, req);
|
|
759
|
+
this.stats.unitsAsked += items.length;
|
|
760
|
+
const priority = req.priority ?? PRIO.normal;
|
|
761
|
+
const waits = [];
|
|
762
|
+
let cached = 0;
|
|
763
|
+
let joined = 0;
|
|
764
|
+
let queued = 0;
|
|
765
|
+
for (const it of items) {
|
|
766
|
+
if (req.refresh) {
|
|
767
|
+
this.mem.delete(it.key);
|
|
768
|
+
try {
|
|
769
|
+
rmSync(this.cachePath(it.key), { force: true });
|
|
770
|
+
} catch {}
|
|
771
|
+
}
|
|
772
|
+
const w = this.want(r, { ...it, extraArgs: req.extraArgs, extraEnv: req.env }, { priority, speculative: false, request: req });
|
|
773
|
+
if (w.cached) cached++;
|
|
774
|
+
else if (w.joined) joined++;
|
|
775
|
+
else queued++;
|
|
776
|
+
waits.push(w.promise.then((res) => ({ it, res })));
|
|
777
|
+
}
|
|
778
|
+
emit?.({ type: "start", units: items.length, cached, joined, queued });
|
|
779
|
+
this.pump();
|
|
780
|
+
let done = 0;
|
|
781
|
+
const tick = setInterval(() => emit?.({ type: "progress", done, total: items.length, text: this.waitingText(r) }), 4000);
|
|
782
|
+
let results;
|
|
783
|
+
try {
|
|
784
|
+
results = await Promise.all(waits.map((p) => p.then((x) => (done++, x))));
|
|
785
|
+
} finally { clearInterval(tick); }
|
|
786
|
+
if (!req.cancelled && r.abs !== this.main && !(req.strict ?? this.config.strict)) await this.confirmBase(r, results, emit, req);
|
|
787
|
+
// Revalidate after every wait, including baseline classification.
|
|
788
|
+
if (!req.cancelled) {
|
|
789
|
+
r.rc.invalidate(true);
|
|
790
|
+
const current = new Map(this.plan(r.rc, req).map((it) => [it.uid, it.key]));
|
|
791
|
+
const added = [...current.keys()].some((uid) => !items.some((it) => it.uid === uid));
|
|
792
|
+
for (const row of results) if (added || current.get(row.it.uid) !== row.it.key) row.res = { ...row.res, stale: true };
|
|
793
|
+
}
|
|
794
|
+
if (r.abs === this.main) {
|
|
795
|
+
// results on the base branch (cached or fresh) define what is "pre-existing"
|
|
796
|
+
const base = this.baseIsClean(r.rc);
|
|
797
|
+
if (base) for (const { it, res } of results) if (res && res.status !== "error" && !res.stale && !res.exec) this.updateKnown({ ...res, uid: it.uid, project: it.project.dir, kind: it.kind }, base, it.unit.inputs);
|
|
798
|
+
}
|
|
799
|
+
const report = this.report(r, req, results, Date.now() - started);
|
|
800
|
+
if (req.goSelect) {
|
|
801
|
+
const gs = this.resolveGo(r.rc, req.goSelect);
|
|
802
|
+
report.go = { order: gs.order, noTests: gs.noTests, unsupported: gs.unsupported };
|
|
803
|
+
}
|
|
804
|
+
this.recent.unshift({ at: Date.now(), root: r.name, scope: req.scope ?? "changed", units: items.length, ok: report.ok, newFailures: report.newFailures, ms: report.ms, label: req.label ?? "" });
|
|
805
|
+
this.recent = this.recent.slice(0, 50);
|
|
806
|
+
this.dirty = true;
|
|
807
|
+
return report;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
report(r, req, results, ms) {
|
|
811
|
+
const strict = req.strict ?? this.config.strict;
|
|
812
|
+
const groups = new Map();
|
|
813
|
+
let newFailures = 0;
|
|
814
|
+
let knownFailures = 0;
|
|
815
|
+
let errors = 0;
|
|
816
|
+
const src = { cache: 0, shared: 0, ran: 0 };
|
|
817
|
+
for (const { it, res } of results) {
|
|
818
|
+
const k = `${it.project.lang} ${it.project.dir} · ${it.kind}`;
|
|
819
|
+
let g = groups.get(k);
|
|
820
|
+
if (!g) groups.set(k, (g = { lang: it.project.lang, project: it.project.dir, kind: it.kind, total: 0, pass: 0, fail: 0, new: [], known: [], fixed: 0, notes: [] }));
|
|
821
|
+
g.total++;
|
|
822
|
+
src[res?.source === "shared" ? "shared" : res?.source === "cache" ? "cache" : "ran"]++;
|
|
823
|
+
if (!res) {
|
|
824
|
+
g.fail++;
|
|
825
|
+
errors++;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (res.stale) {
|
|
829
|
+
g.fail++;
|
|
830
|
+
errors++;
|
|
831
|
+
newFailures++;
|
|
832
|
+
g.new.push({ title: `${it.unit.label}: inputs changed during execution; result is not current`, stale: true });
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (res.status === "pass") {
|
|
836
|
+
g.pass++;
|
|
837
|
+
for (const [, kn] of Object.entries(this.known.ids)) if (kn.uid === it.uid) g.fixed++;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
g.fail++;
|
|
841
|
+
if (res.status === "error") errors++;
|
|
842
|
+
for (const f of res.failures ?? []) {
|
|
843
|
+
if (!strict && this.known.ids[f.id] && res.status !== "error") {
|
|
844
|
+
g.known.push(f);
|
|
845
|
+
knownFailures++;
|
|
846
|
+
} else {
|
|
847
|
+
g.new.push({ ...f, unit: it.unit.label, stale: res.stale });
|
|
848
|
+
newFailures++;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (res.note) g.notes.push(res.note);
|
|
852
|
+
}
|
|
853
|
+
const ok = newFailures === 0 && errors === 0;
|
|
854
|
+
const lines = [];
|
|
855
|
+
const scopeText = req.select ? "selected" : req.scope === "all" ? "all" : req.scope === "files" ? `files (${req.files?.length ?? 0})` : `changed vs ${req.base || r.rc.baseRef()}`;
|
|
856
|
+
lines.push(`pi-check · ${r.name} · scope: ${scopeText} · ${results.length} unit${results.length === 1 ? "" : "s"}: ${src.ran} ran, ${src.cache} cached, ${src.shared} shared · ${fmtMs(ms)}`);
|
|
857
|
+
if (!results.length) lines.push("Nothing to check: no test, type or lint unit depends on the changed files.");
|
|
858
|
+
let shown = 0;
|
|
859
|
+
for (const g of [...groups.values()].sort((a, b) => b.new.length - a.new.length)) {
|
|
860
|
+
const head = `${g.new.length ? "✗" : "✓"} ${g.lang} ${g.project} · ${g.kind}: ${g.pass}/${g.total} pass`;
|
|
861
|
+
const extra = [g.known.length ? `${g.known.length} pre-existing failure${g.known.length > 1 ? "s" : ""} (also fail on base)` : "", g.fixed ? `${g.fixed} known failure${g.fixed > 1 ? "s" : ""} now pass` : ""].filter(Boolean).join(", ");
|
|
862
|
+
lines.push(`${head}${extra ? ` — ${extra}` : ""}`);
|
|
863
|
+
for (const f of g.new) {
|
|
864
|
+
if (shown >= 15) continue;
|
|
865
|
+
shown++;
|
|
866
|
+
lines.push(` ✗ ${f.title}${f.stale ? " (files changed during the run — rerun to confirm)" : ""}`);
|
|
867
|
+
if (f.output) lines.push(...tailLines(headLines(f.output, 60), 30).split("\n").map((l) => ` ${l}`));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (newFailures > shown) lines.push(`… ${newFailures - shown} more new failures (pi-check run --json for all)`);
|
|
871
|
+
if (knownFailures && !strict) lines.push(`Pre-existing failures are not caused by these changes and do not fail the check (strict: true to count them).`);
|
|
872
|
+
lines.push(ok ? `RESULT: PASS${knownFailures ? ` (${knownFailures} pre-existing failures ignored)` : ""}` : `RESULT: FAIL — ${newFailures} new failure${newFailures === 1 ? "" : "s"}${errors ? ` (${errors} could not run)` : ""}`);
|
|
873
|
+
const out = { ok, ms, units: results.length, ...src, newFailures, knownFailures, errors, groups: [...groups.values()], text: lines.join("\n") };
|
|
874
|
+
if (req.detail) out.results = results.map(({ it, res }) => ({ project: it.project.dir, lang: it.project.lang, kind: it.kind, target: it.unit.target, label: it.unit.label, ...res, known: (res?.failures ?? []).filter((f) => this.known.ids[f.id]).map((f) => f.id) }));
|
|
875
|
+
return out;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
speculate(r, changedSet, priority = PRIO.speculative) {
|
|
879
|
+
if (!this.loadOk() || this.pending.some((t) => !t.speculative)) return 0;
|
|
880
|
+
let items;
|
|
881
|
+
try {
|
|
882
|
+
items = this.plan(r.rc, { changedSet });
|
|
883
|
+
} catch (e) {
|
|
884
|
+
this.log("speculate plan failed", String(e?.stack ?? e));
|
|
885
|
+
return 0;
|
|
886
|
+
}
|
|
887
|
+
let n = 0;
|
|
888
|
+
for (const it of items) {
|
|
889
|
+
if (n >= 400) break;
|
|
890
|
+
if (this.cacheGet(it.key) || this.inflight.has(it.key)) continue;
|
|
891
|
+
if (this.want(r, it, { priority, speculative: true })) n++;
|
|
892
|
+
}
|
|
893
|
+
if (n) this.log("speculating", r.name, n, "units");
|
|
894
|
+
this.pump();
|
|
895
|
+
return n;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Watch every worktree of the repo (queue worktrees appear and disappear). */
|
|
899
|
+
scanWorktrees() {
|
|
900
|
+
const l = git(this.main, ["worktree", "list", "--porcelain"]);
|
|
901
|
+
if (!l.ok) return;
|
|
902
|
+
for (const line of l.out.split("\n")) {
|
|
903
|
+
if (!line.startsWith("worktree ")) continue;
|
|
904
|
+
const p = line.slice(9).trim();
|
|
905
|
+
if (!existsSync(p) || this.roots.has(p)) continue;
|
|
906
|
+
try {
|
|
907
|
+
this.root(p);
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
warm() {
|
|
913
|
+
const r = this.root(this.main);
|
|
914
|
+
r.rc.invalidate(true);
|
|
915
|
+
const items = this.plan(r.rc, { scope: "all" });
|
|
916
|
+
const base = this.baseIsClean(r.rc);
|
|
917
|
+
let n = 0;
|
|
918
|
+
for (const it of items) {
|
|
919
|
+
const hit = this.cacheGet(it.key);
|
|
920
|
+
if (hit) {
|
|
921
|
+
if (base && hit.status !== "error") this.updateKnown({ ...hit, uid: it.uid, project: it.project.dir, kind: it.kind }, base, it.unit.inputs);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (!this.inflight.has(it.key) && this.want(r, it, { priority: PRIO.warm, speculative: true })) n++;
|
|
925
|
+
}
|
|
926
|
+
this.log("warm", n, "of", items.length, "units uncached on main");
|
|
927
|
+
this.pump();
|
|
928
|
+
return n;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
reload() {
|
|
932
|
+
const config = loadConfig(this.main);
|
|
933
|
+
if (this.running.size || this.pending.some((t) => !t.speculative)) {
|
|
934
|
+
this.pendingConfig = config;
|
|
935
|
+
return { deferred: true };
|
|
936
|
+
}
|
|
937
|
+
this.applyConfig(config);
|
|
938
|
+
return { deferred: false };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
applyConfig(config) {
|
|
942
|
+
this.cancel(); // only optional queued work; requested work has drained
|
|
943
|
+
this.config = config;
|
|
944
|
+
this.pendingConfig = undefined;
|
|
945
|
+
for (const r of new Set(this.roots.values())) {
|
|
946
|
+
r.rc.config = this.config;
|
|
947
|
+
r.rc.invalidate(true);
|
|
948
|
+
if (config.watch) this.watchRoot(r);
|
|
949
|
+
else this.unwatch(r);
|
|
950
|
+
}
|
|
951
|
+
this.log("configuration applied at idle boundary");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
status() {
|
|
955
|
+
const roots = [...new Set(this.roots.values())].map((r) => ({ name: r.name, abs: r.abs, watching: r.watching, idle: fmtMs(Date.now() - r.lastUse) }));
|
|
956
|
+
return {
|
|
957
|
+
pid: process.pid,
|
|
958
|
+
version: VERSION,
|
|
959
|
+
scheduler: "lanes-v2",
|
|
960
|
+
fastLane: !!this.laneReserved(),
|
|
961
|
+
lanes: Object.fromEntries(["fast", "heavy"].map((lane) => [lane, {
|
|
962
|
+
running: [...this.running].filter((j) => j.lane === lane).length,
|
|
963
|
+
queued: this.pending.filter((t) => !t.speculative && this.laneOf(t) === lane).length,
|
|
964
|
+
}])),
|
|
965
|
+
main: this.main,
|
|
966
|
+
uptime: fmtMs(Date.now() - this.startedAt),
|
|
967
|
+
maxJobs: this.config.maxJobs,
|
|
968
|
+
threads: this.config.threads,
|
|
969
|
+
cpuBudget: this.config.cpuBudget,
|
|
970
|
+
allocatedCpus: [...this.running].reduce((n, j) => n + j.threads, 0),
|
|
971
|
+
configPending: !!this.pendingConfig,
|
|
972
|
+
cpus: CPUS,
|
|
973
|
+
load: Number(loadavg()[0].toFixed(2)),
|
|
974
|
+
speculate: this.config.speculate,
|
|
975
|
+
running: [...this.running].map((j) => ({ id: j.id, root: j.root.name, label: j.label, lane: j.lane, threads: j.threads, completed: j.completed, units: j.tasks.length, activity: j.activity, outputAgo: fmtMs(Date.now() - j.lastOutputAt), speculative: j.speculativeOnly(), for: fmtMs(Date.now() - j.startedAt) })),
|
|
976
|
+
pending: { requested: this.pending.filter((t) => !t.speculative).length, speculative: this.pending.filter((t) => t.speculative).length },
|
|
977
|
+
roots,
|
|
978
|
+
stats: { ...this.stats, saved: fmtMs(this.stats.savedMs), spent: fmtMs(this.stats.ranMs) },
|
|
979
|
+
known: Object.keys(this.known.ids).length,
|
|
980
|
+
recent: this.recent.slice(0, 10),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
persist() {
|
|
985
|
+
if (!this.dirty) return;
|
|
986
|
+
this.dirty = false;
|
|
987
|
+
try {
|
|
988
|
+
writeJson(join(this.dir, "state.json"), { stats: this.stats, recent: this.recent, status: { running: [...this.running].map((j) => ({ root: j.root.name, label: j.label })), at: Date.now() } });
|
|
989
|
+
} catch {}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
cancel(rootAbs) {
|
|
993
|
+
let n = 0;
|
|
994
|
+
for (const t of [...this.pending]) {
|
|
995
|
+
if (rootAbs && t.root.abs !== rootAbs) continue;
|
|
996
|
+
if (t.speculative) {
|
|
997
|
+
this.pending.splice(this.pending.indexOf(t), 1);
|
|
998
|
+
if (this.inflight.get(t.key) === t) this.inflight.delete(t.key);
|
|
999
|
+
n++;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
for (const j of this.running) if ((!rootAbs || j.root.abs === rootAbs) && j.speculativeOnly()) this.abortJob(j, "cancelled"), n++;
|
|
1003
|
+
return n;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ---------- server ----------
|
|
1008
|
+
|
|
1009
|
+
function readBody(req) {
|
|
1010
|
+
return new Promise((res) => {
|
|
1011
|
+
let b = "";
|
|
1012
|
+
req.on("data", (d) => (b += d));
|
|
1013
|
+
req.on("end", () => {
|
|
1014
|
+
try {
|
|
1015
|
+
res(b ? JSON.parse(b) : {});
|
|
1016
|
+
} catch {
|
|
1017
|
+
res({});
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export async function serve(main) {
|
|
1024
|
+
const dir = ensureDir(join(main, STATE_DIR));
|
|
1025
|
+
const lockPath = join(dir, "broker.lock");
|
|
1026
|
+
// single instance per repo
|
|
1027
|
+
try {
|
|
1028
|
+
const fd = openSync(lockPath, "wx");
|
|
1029
|
+
writeFileSync(fd, String(process.pid));
|
|
1030
|
+
closeSync(fd);
|
|
1031
|
+
} catch {
|
|
1032
|
+
const other = Number(loadJson(join(dir, "broker.json"), {})?.pid) || Number(String(require_text(lockPath)).trim());
|
|
1033
|
+
if (pidAlive(other) && other !== process.pid) {
|
|
1034
|
+
process.exit(0);
|
|
1035
|
+
}
|
|
1036
|
+
writeFileSync(lockPath, String(process.pid));
|
|
1037
|
+
}
|
|
1038
|
+
ensureExcluded(main);
|
|
1039
|
+
adoptLoginPath();
|
|
1040
|
+
const b = new Broker(main);
|
|
1041
|
+
const token = randomBytes(16).toString("hex");
|
|
1042
|
+
const server = http.createServer(async (req, res) => {
|
|
1043
|
+
if (req.headers["x-pi-check-token"] !== token) {
|
|
1044
|
+
res.writeHead(403).end();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const url = new URL(req.url, "http://x");
|
|
1048
|
+
const body = req.method === "POST" ? await readBody(req) : {};
|
|
1049
|
+
const json = (code, obj) => {
|
|
1050
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
1051
|
+
res.end(JSON.stringify(obj));
|
|
1052
|
+
};
|
|
1053
|
+
const stream = () => {
|
|
1054
|
+
res.writeHead(200, { "content-type": "application/x-ndjson" });
|
|
1055
|
+
let open = true;
|
|
1056
|
+
res.on("close", () => {
|
|
1057
|
+
open = false;
|
|
1058
|
+
b.cancelRequest(body);
|
|
1059
|
+
});
|
|
1060
|
+
return (o) => open && res.write(`${JSON.stringify(o)}\n`);
|
|
1061
|
+
};
|
|
1062
|
+
try {
|
|
1063
|
+
switch (url.pathname) {
|
|
1064
|
+
case "/ping":
|
|
1065
|
+
return json(200, { ok: true, pid: process.pid, version: VERSION });
|
|
1066
|
+
case "/status":
|
|
1067
|
+
return json(200, b.status());
|
|
1068
|
+
case "/touch":
|
|
1069
|
+
b.root(body.root ?? main);
|
|
1070
|
+
return json(200, { ok: true });
|
|
1071
|
+
case "/projects": {
|
|
1072
|
+
const r = b.root(body.root ?? main);
|
|
1073
|
+
r.rc.invalidate(true);
|
|
1074
|
+
return json(200, { projects: b.projects(r.rc).map((p) => ({ ...p, units: Object.fromEntries(p.kinds.map((k) => [k, b.unitsOf(r.rc, p, k).length])) })) });
|
|
1075
|
+
}
|
|
1076
|
+
case "/run": {
|
|
1077
|
+
const emit = stream();
|
|
1078
|
+
const rep = await b.run(body, emit);
|
|
1079
|
+
emit({ type: "result", report: rep });
|
|
1080
|
+
return res.end();
|
|
1081
|
+
}
|
|
1082
|
+
case "/exec": {
|
|
1083
|
+
const emit = stream();
|
|
1084
|
+
const rep = await b.exec(body, emit);
|
|
1085
|
+
emit({ type: "result", ...rep });
|
|
1086
|
+
return res.end();
|
|
1087
|
+
}
|
|
1088
|
+
case "/known": {
|
|
1089
|
+
const ids = b.known.ids;
|
|
1090
|
+
if (body.action === "add") for (const id of body.ids ?? []) ids[id] = { source: "manual", since: Date.now(), title: id, uid: "" };
|
|
1091
|
+
else if (body.action === "remove") {
|
|
1092
|
+
for (const id of Object.keys(ids)) if ((body.ids ?? []).includes(id) || (body.pattern && id.includes(body.pattern))) delete ids[id];
|
|
1093
|
+
} else if (body.action === "clear") b.known.ids = {};
|
|
1094
|
+
if (body.action) b.saveKnown();
|
|
1095
|
+
return json(200, { known: b.known.ids });
|
|
1096
|
+
}
|
|
1097
|
+
case "/speculate": {
|
|
1098
|
+
const r = b.root(body.root ?? main);
|
|
1099
|
+
r.rc.invalidate(true);
|
|
1100
|
+
const n = body.all ? (r.abs === main ? b.warm() : b.speculate(r, new Set(r.rc.listing().files))) : b.speculate(r, r.rc.changedFiles());
|
|
1101
|
+
return json(200, { queued: n });
|
|
1102
|
+
}
|
|
1103
|
+
case "/cancel":
|
|
1104
|
+
return json(200, { cancelled: b.cancel(body.root) });
|
|
1105
|
+
case "/reload":
|
|
1106
|
+
return json(200, { ok: true, ...b.reload(), config: b.config });
|
|
1107
|
+
case "/forget": {
|
|
1108
|
+
b.mem.clear();
|
|
1109
|
+
rmSync(b.cacheDir, { recursive: true, force: true });
|
|
1110
|
+
ensureDir(b.cacheDir);
|
|
1111
|
+
return json(200, { ok: true });
|
|
1112
|
+
}
|
|
1113
|
+
case "/stop":
|
|
1114
|
+
json(200, { ok: true });
|
|
1115
|
+
return shutdown("stop requested");
|
|
1116
|
+
default:
|
|
1117
|
+
return json(404, { error: "unknown endpoint" });
|
|
1118
|
+
}
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
b.log("request failed", url.pathname, String(e?.stack ?? e));
|
|
1121
|
+
if (!res.headersSent) json(500, { error: String(e?.message ?? e) });
|
|
1122
|
+
else res.end(`${JSON.stringify({ type: "error", error: String(e?.message ?? e) })}\n`);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
await new Promise((res) => server.listen(0, "127.0.0.1", res));
|
|
1126
|
+
const port = server.address().port;
|
|
1127
|
+
writeJson(join(dir, "broker.json"), { pid: process.pid, port, token, version: VERSION, main, startedAt: Date.now() });
|
|
1128
|
+
b.log(`broker ${VERSION} up on ${port} (cpus ${CPUS}, maxJobs ${b.config.maxJobs}, threads ${b.config.threads})`);
|
|
1129
|
+
b.root(main);
|
|
1130
|
+
b.scanWorktrees();
|
|
1131
|
+
setTimeout(() => b.pruneCache(), 30000).unref();
|
|
1132
|
+
if (b.config.warmBase && b.config.speculate) setTimeout(() => b.warm(), 1500);
|
|
1133
|
+
const timers = [
|
|
1134
|
+
setInterval(() => b.persist(), 5000),
|
|
1135
|
+
setInterval(() => {
|
|
1136
|
+
for (const r of new Set(b.roots.values())) if (r.abs !== main && (!existsSync(r.abs) || Date.now() - r.lastUse > 3 * 3600000)) b.dropRoot(r);
|
|
1137
|
+
if (b.config.watch) b.scanWorktrees();
|
|
1138
|
+
if (Date.now() - b.lastUse > b.config.idleMinutes * 60000 && !b.running.size && !b.pending.some((t) => !t.speculative)) shutdown("idle");
|
|
1139
|
+
b.pump();
|
|
1140
|
+
}, 30000),
|
|
1141
|
+
];
|
|
1142
|
+
function shutdown(why) {
|
|
1143
|
+
b.log("shutdown:", why);
|
|
1144
|
+
for (const t of timers) clearInterval(t);
|
|
1145
|
+
for (const j of b.running) j.ac.abort();
|
|
1146
|
+
b.dirty = true;
|
|
1147
|
+
b.persist();
|
|
1148
|
+
try {
|
|
1149
|
+
unlinkSync(join(dir, "broker.json"));
|
|
1150
|
+
} catch {}
|
|
1151
|
+
try {
|
|
1152
|
+
unlinkSync(lockPath);
|
|
1153
|
+
} catch {}
|
|
1154
|
+
server.close();
|
|
1155
|
+
setTimeout(() => process.exit(0), 300).unref();
|
|
1156
|
+
}
|
|
1157
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1158
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1159
|
+
process.on("uncaughtException", (e) => b.log("uncaught", String(e?.stack ?? e)));
|
|
1160
|
+
process.on("unhandledRejection", (e) => b.log("unhandled", String(e?.stack ?? e)));
|
|
1161
|
+
return b;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function require_text(p) {
|
|
1165
|
+
try {
|
|
1166
|
+
return readFileSync(p, "utf8");
|
|
1167
|
+
} catch {
|
|
1168
|
+
return "";
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Keep .pi-check/ out of git status in every checkout. */
|
|
1173
|
+
export function ensureExcluded(main) {
|
|
1174
|
+
const c = git(main, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
1175
|
+
if (!c.ok) return;
|
|
1176
|
+
const f = join(c.out.trim(), "info", "exclude");
|
|
1177
|
+
try {
|
|
1178
|
+
mkdirSync(join(c.out.trim(), "info"), { recursive: true });
|
|
1179
|
+
const t = existsSync(f) ? readFileSync(f, "utf8") : "";
|
|
1180
|
+
if (!t.split("\n").includes("/.pi-check/")) writeFileSync(f, `${t}${t && !t.endsWith("\n") ? "\n" : ""}/.pi-check/\n`);
|
|
1181
|
+
} catch {}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (process.argv[1] && process.argv[1].endsWith("broker.mjs") && process.argv[2]) {
|
|
1185
|
+
serve(process.argv[2]).catch((e) => {
|
|
1186
|
+
console.error(e);
|
|
1187
|
+
process.exit(1);
|
|
1188
|
+
});
|
|
1189
|
+
}
|