forgedock 1.42.0 → 1.43.1
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/bin/engine/outcome-codes.mjs +1 -0
- package/bin/engine-cli.mjs +91 -19
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +11 -3
- package/bin/lean/batch.test.mjs +66 -2
- package/bin/lean/conformance.mjs +140 -9
- package/bin/lean/conformance.test.mjs +164 -2
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lean.mjs +80 -24
- package/bin/lean/lean.test.mjs +119 -7
- package/bin/lean/orchestrate-blocked.test.mjs +16 -0
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +116 -19
- package/bin/lean/schedd.test.mjs +201 -2
- package/bin/lean/selftest-live.mjs +1 -1
- package/bin/lean/selftest-nightly.mjs +1 -1
- package/bin/lean/signoff.mjs +11 -1
- package/bin/lean/signoff.test.mjs +24 -1
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/pool/agent.mjs +1 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/OUTCOME-CODES.md +1 -0
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
package/bin/lean/schedd.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* {type:"ok", cancelled:false, finished:true}, nothing written
|
|
21
21
|
* {op:"hold"|"release", id, issues} → {type:"ok", changed, hosted} (#4592): merges into / removes from the batch's `hold` file; a hosted
|
|
22
22
|
* runner admits no held issue from its next pass (running lanes are not stopped)
|
|
23
|
-
* {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running}]} (#
|
|
23
|
+
* {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running, lastProgressAt, lastProgressAgeSec, pendingAdds}]} (#4775: also an unhosted batch with pending adds)
|
|
24
24
|
* Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
|
|
25
25
|
* never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
|
|
26
26
|
* report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
36
36
|
import { createConnection, createServer } from "node:net";
|
|
37
|
-
import { spawn } from "node:child_process";
|
|
38
|
-
import { tmpdir } from "node:os";
|
|
37
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
38
|
+
import { homedir, tmpdir } from "node:os";
|
|
39
39
|
import { createHash } from "node:crypto";
|
|
40
40
|
import { join, resolve } from "node:path";
|
|
41
41
|
import { fileURLToPath } from "node:url";
|
|
@@ -46,6 +46,10 @@ import { batchRoot, listBatches, openBatch, SECRET_ENV } from "./batch.mjs";
|
|
|
46
46
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
47
47
|
export const STOP_MS = 10 * 1000;
|
|
48
48
|
export const LOCK_CHECK_MS = 5 * 1000;
|
|
49
|
+
export const ADMIT_MS = 120 * 1000; // #4775: how long one add/requeue/attach/resume waits for its batch to start before answering an error
|
|
50
|
+
export const LAG_MS = 5 * 1000; // #4775: an event-loop stall longer than this is logged
|
|
51
|
+
export const REQUEST_MS = 15 * 1000; // #4775: default bound of a control/status request
|
|
52
|
+
export const UNIT = "forgedock-schedd";
|
|
49
53
|
export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
|
|
50
54
|
|
|
51
55
|
/** #4762: win32 `net` servers only accept named pipes, so the socket is a per-dir pipe there (it vanishes with its server). */
|
|
@@ -130,7 +134,7 @@ function takeLock(lock, pid, alive) {
|
|
|
130
134
|
*/
|
|
131
135
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
132
136
|
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
133
|
-
lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
|
|
137
|
+
lockCheckMs = LOCK_CHECK_MS, admitMs = Number(process.env.FORGEDOCK_SCHEDD_ADMIT_MS) || ADMIT_MS, lagMs = LAG_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
|
|
134
138
|
const p = scheddPaths(dir);
|
|
135
139
|
mkdirSync(dir, { recursive: true });
|
|
136
140
|
const lock = takeLock(p.lock, pid, alive);
|
|
@@ -168,6 +172,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
168
172
|
const run = Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker, signal: ac.signal })).then((result) => {
|
|
169
173
|
const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
|
|
170
174
|
emit({ type: "done", result, report });
|
|
175
|
+
if (ent.id) rehostLater(ent.id); // #4917
|
|
171
176
|
}, (e) => {
|
|
172
177
|
const message = e?.message ?? String(e);
|
|
173
178
|
if (e?.code === "batch.stopped") { log(`schedd: batch ${ent.id ?? argv.join(" ")} stopped (lanes keep running; resumed by the next daemon)`); if (!started) reject(new Error(message)); return; }
|
|
@@ -176,8 +181,61 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
176
181
|
runs.set(run, ac);
|
|
177
182
|
});
|
|
178
183
|
|
|
184
|
+
// #4917: a batch that ended with a deferred issue still waiting (no report) is re-hosted once its retry-at is near, without a restart
|
|
185
|
+
const rehostTimers = new Set();
|
|
186
|
+
const rehostLater = (id) => {
|
|
187
|
+
if (closing) return;
|
|
188
|
+
let b, st;
|
|
189
|
+
try { b = openBatch(root, id); if (b.readReport() || b.readCancel() || !b.pendingIssues().length) return; st = b.store.load(); } catch { return; }
|
|
190
|
+
if (st?.breaker && !st.breaker.transport) return; // a tripped breaker holds the batch for an operator --resume (a re-host would stop again at once)
|
|
191
|
+
const at = Math.min(...b.pendingIssues().map((n) => Number(st?.notBefore?.[n]) || Infinity));
|
|
192
|
+
if (!Number.isFinite(at)) return; // nothing waits on the clock: a deliberate hold stays for --resume
|
|
193
|
+
if (at <= Date.now()) return; // the run that just ended already saw this retry-at pass and did not admit: re-hosting would loop every second
|
|
194
|
+
const t = setTimeout(() => {
|
|
195
|
+
rehostTimers.delete(t);
|
|
196
|
+
if (closing || hosted.has(id) || starting.has(id)) return;
|
|
197
|
+
log(`schedd: re-hosting batch ${id} (pending issues waiting on a retry)`);
|
|
198
|
+
const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
|
|
199
|
+
starting.set(id, started);
|
|
200
|
+
started.catch((e) => log(`schedd: re-host ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
|
|
201
|
+
}, Math.min(60_000, Math.max(1000, at - Date.now())));
|
|
202
|
+
t.unref?.();
|
|
203
|
+
rehostTimers.add(t);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** #4775: wait for a batch start at most `admitMs`; the start itself goes on (a second host() would duplicate the batch). */
|
|
207
|
+
const bound = (p, what) => {
|
|
208
|
+
let t;
|
|
209
|
+
return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${what} did not start within ${admitMs} ms (schedd is busy; retry)`)), admitMs); t.unref?.(); })]).finally(() => clearTimeout(t));
|
|
210
|
+
};
|
|
211
|
+
const hostBounded = (args, first = null, what = "batch") => bound(host(args, first), what);
|
|
212
|
+
|
|
213
|
+
/** #4775: per batch id, last progress and pending adds: every hosted batch, plus any daemon batch holding adds it never took. */
|
|
214
|
+
const statusBatches = () => {
|
|
215
|
+
const now = Date.now(), out = [];
|
|
216
|
+
const ids = new Set(hosted.keys());
|
|
217
|
+
for (const id of starting.keys()) ids.add(id);
|
|
218
|
+
try { for (const id of listBatches(root)) if (!ids.has(id) && openBatch(root, id).readPlan()?.daemon) ids.add(id); } catch { /* unreadable root */ }
|
|
219
|
+
for (const id of [...ids].sort()) {
|
|
220
|
+
const isHosted = hosted.has(id) || starting.has(id);
|
|
221
|
+
let last = null, pending = [];
|
|
222
|
+
try { const b = openBatch(root, id); last = b.lastProgressMs(); pending = b.unrunAdds(); } catch { /* a half-written batch */ }
|
|
223
|
+
if (!isHosted && !pending.length) continue;
|
|
224
|
+
out.push({ id, running: isHosted, lastProgressAt: last === null ? null : new Date(last).toISOString(), lastProgressAgeSec: last === null ? null : Math.max(0, Math.round((now - last) / 1000)), pendingAdds: pending });
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
};
|
|
228
|
+
|
|
179
229
|
const handle = async (req, send) => {
|
|
180
|
-
if (req.op === "status")
|
|
230
|
+
if (req.op === "status") {
|
|
231
|
+
// #4917: a batch dir that still holds pending issues but is not hosted (finalised early, or never resumed)
|
|
232
|
+
const unhosted = [];
|
|
233
|
+
for (const id of listBatches(root)) {
|
|
234
|
+
if (hosted.has(id) || starting.has(id)) continue;
|
|
235
|
+
try { const pending = openBatch(root, id).pendingIssues(); if (pending.length) unhosted.push({ id, pending }); } catch { /* advisory */ }
|
|
236
|
+
}
|
|
237
|
+
return send({ type: "status", pid, lastTick, batches: statusBatches(), unhosted }, true);
|
|
238
|
+
}
|
|
181
239
|
if (req.op === "set-parallel") {
|
|
182
240
|
const b = openBatch(root, req.id);
|
|
183
241
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
@@ -188,9 +246,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
188
246
|
const b = openBatch(root, req.id);
|
|
189
247
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
190
248
|
const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
|
|
191
|
-
// #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
|
|
192
|
-
const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
|
|
193
|
-
if (reopen) await
|
|
249
|
+
// #4687/#5000/#4917: a re-queued skipped issue or a newly added one reopens a finished, unhosted batch like --requeue does
|
|
250
|
+
const reopen = (added.length > 0 || requeued.length > 0 || b.unrunAdds().length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
|
|
251
|
+
if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
|
|
194
252
|
return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
195
253
|
}
|
|
196
254
|
if (req.op === "requeue") {
|
|
@@ -198,8 +256,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
198
256
|
const b = openBatch(root, req.id);
|
|
199
257
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
200
258
|
const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
|
|
201
|
-
|
|
202
|
-
|
|
259
|
+
// #4917: a refused repeat requeue still reopens when an earlier entry was never applied
|
|
260
|
+
const reopen = (requeued.length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
|
|
261
|
+
if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
|
|
203
262
|
return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
204
263
|
}
|
|
205
264
|
if (req.op === "hold" || req.op === "release") {
|
|
@@ -222,7 +281,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
222
281
|
}
|
|
223
282
|
if (req.op === "attach") {
|
|
224
283
|
if (req.resume) { const rb = openBatch(root, req.id); if (rb.exists()) rb.clearCancel(); } // #4592: the operator's resume continues a cancelled batch the daemon still hosts
|
|
225
|
-
if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
|
|
284
|
+
if (!hosted.has(req.id) && starting.has(req.id)) await bound(starting.get(req.id), `batch ${req.id}`).catch((e) => { if (/did not start within/.test(e?.message)) throw e; });
|
|
226
285
|
const live = hosted.get(req.id);
|
|
227
286
|
if (live) {
|
|
228
287
|
// Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
|
|
@@ -236,7 +295,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
236
295
|
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
|
|
237
296
|
return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
|
|
238
297
|
}
|
|
239
|
-
const ent = await
|
|
298
|
+
const ent = await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub, `batch ${req.id}`);
|
|
240
299
|
return send({ type: "batch", id: ent.id, dir: ent.dir });
|
|
241
300
|
}
|
|
242
301
|
throw new Error(`unknown op ${JSON.stringify(req.op)}`);
|
|
@@ -277,7 +336,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
277
336
|
if (closing) return closing;
|
|
278
337
|
clearInterval(idleTimer);
|
|
279
338
|
clearInterval(lockTimer);
|
|
339
|
+
clearInterval(lagTimer);
|
|
280
340
|
clearInterval(beatTimer);
|
|
341
|
+
for (const t of rehostTimers) clearTimeout(t);
|
|
281
342
|
if (dueTimer) clearInterval(dueTimer);
|
|
282
343
|
for (const c of conns) c.destroy();
|
|
283
344
|
const stopping = [...runs.keys()];
|
|
@@ -309,6 +370,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
309
370
|
else if (process.platform !== "win32" && !existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
|
|
310
371
|
}, lockCheckMs);
|
|
311
372
|
lockTimer.unref?.();
|
|
373
|
+
// #4775: an event loop starved by host load leaves sockets unanswered and children unreaped; leave a trace of it.
|
|
374
|
+
let lagAt = Date.now(), lagLogged = 0;
|
|
375
|
+
const lagTimer = setInterval(() => {
|
|
376
|
+
const now = Date.now(), lag = now - lagAt - 1000;
|
|
377
|
+
lagAt = now;
|
|
378
|
+
if (lag >= lagMs && now - lagLogged >= 60_000) { lagLogged = now; log(`schedd: event loop stalled ${Math.round(lag / 1000)}s (host overloaded?)`); }
|
|
379
|
+
}, 1000);
|
|
380
|
+
lagTimer.unref?.();
|
|
312
381
|
// #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
|
|
313
382
|
let dueRunning = false;
|
|
314
383
|
const dueTimer = due ? setInterval(async () => {
|
|
@@ -333,6 +402,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
333
402
|
log(`schedd: resuming batch ${id}`);
|
|
334
403
|
const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
|
|
335
404
|
starting.set(id, started);
|
|
405
|
+
const stall = setTimeout(() => log(`schedd: batch ${id} has not started after ${admitMs} ms (still resuming; other batches are served)`), admitMs);
|
|
406
|
+
stall.unref?.();
|
|
407
|
+
started.finally(() => clearTimeout(stall)).catch(() => {});
|
|
336
408
|
started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
|
|
337
409
|
}
|
|
338
410
|
}
|
|
@@ -362,8 +434,29 @@ export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s ===
|
|
|
362
434
|
on("exit", (code) => log(`schedd: exited (code ${code})`));
|
|
363
435
|
}
|
|
364
436
|
|
|
365
|
-
/**
|
|
366
|
-
|
|
437
|
+
/**
|
|
438
|
+
* #4775: state of the systemd-user unit (`active`, `activating`, `inactive`, ...), or null when no unit is installed or
|
|
439
|
+
* systemd is not usable here (then a client may spawn the daemon itself).
|
|
440
|
+
*/
|
|
441
|
+
export function unitState({ unitFile = join(homedir(), ".config", "systemd", "user", `${UNIT}.service`), exists = existsSync, run = execFileSync } = {}) {
|
|
442
|
+
if (!exists(unitFile)) return null;
|
|
443
|
+
try { return String(run("systemctl", ["--user", "is-active", UNIT], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })).trim() || null; }
|
|
444
|
+
catch (e) { return e?.code === "ENOENT" || e?.killed ? null : String(e?.stdout ?? "").trim() || null; } // is-active exits 3 and prints the state when not active
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Start the installed unit (best effort: the connect retry reports a failure). */
|
|
448
|
+
export function startUnit({ run = execFileSync } = {}) {
|
|
449
|
+
try { run("systemctl", ["--user", "start", "--no-block", UNIT], { timeout: 5000, stdio: "ignore" }); } catch { /* reported by the connect retry */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Connect to the daemon. When nobody listens it is started: a systemd-managed instance (`unitState()` active, activating
|
|
454
|
+
* or reloading: restarting) is waited for and never raced by a second daemon, an installed but stopped unit is started
|
|
455
|
+
* (`startUnit()`), and only without a unit does `spawnDaemon()` run (#4775). `spawnDaemon: null` never starts anything.
|
|
456
|
+
* @returns {Promise<import("node:net").Socket>}
|
|
457
|
+
*/
|
|
458
|
+
export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
459
|
+
unit = unitState, start = startUnit } = {}) {
|
|
367
460
|
const { sock } = scheddPaths(dir);
|
|
368
461
|
const tryConnect = () => new Promise((res, rej) => {
|
|
369
462
|
const c = createConnection(sock);
|
|
@@ -371,7 +464,9 @@ export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpa
|
|
|
371
464
|
c.once("error", rej);
|
|
372
465
|
});
|
|
373
466
|
try { return await tryConnect(); } catch (e) { if (!["ENOENT", "ECONNREFUSED"].includes(e?.code) || !spawnDaemon) throw e; }
|
|
374
|
-
|
|
467
|
+
const state = unit();
|
|
468
|
+
if (state === null) spawnDaemon({ dir });
|
|
469
|
+
else if (!["active", "activating", "reloading"].includes(state)) start();
|
|
375
470
|
const end = Date.now() + waitMs;
|
|
376
471
|
for (;;) {
|
|
377
472
|
await sleep(100);
|
|
@@ -386,7 +481,7 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
|
|
|
386
481
|
mkdirSync(dir, { recursive: true });
|
|
387
482
|
const out = openSync(scheddPaths(dir).log, "a");
|
|
388
483
|
try {
|
|
389
|
-
const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, stdio: ["ignore", out, out] });
|
|
484
|
+
const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
|
|
390
485
|
child.on?.("error", () => { /* the connect retry reports it */ });
|
|
391
486
|
child.unref?.();
|
|
392
487
|
} finally { closeSync(out); }
|
|
@@ -396,10 +491,12 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
|
|
|
396
491
|
* Send one request and feed every reply to `onMsg`. Resolves with the last message (`done`, `ok`, `status`), rejects on
|
|
397
492
|
* `error` or when the daemon closes the stream first.
|
|
398
493
|
*/
|
|
399
|
-
export function request(sock, msg, onMsg = () => {}) {
|
|
494
|
+
export function request(sock, msg, onMsg = () => {}, { timeoutMs = 0 } = {}) {
|
|
400
495
|
return new Promise((resolve, reject) => {
|
|
401
|
-
let buf = "", last = null, settled = false;
|
|
402
|
-
const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };
|
|
496
|
+
let buf = "", last = null, settled = false, timer = null;
|
|
497
|
+
const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v); } };
|
|
498
|
+
// #4775: a bound for one-shot requests; a stalled daemon answers an error instead of hanging the caller (streams pass none)
|
|
499
|
+
if (timeoutMs > 0) timer = setTimeout(() => { sock.destroy(); finish(reject, Object.assign(new Error(`schedd did not answer within ${Math.round(timeoutMs / 1000)}s (not responding)`), { code: "schedd.timeout" })); }, timeoutMs);
|
|
403
500
|
sock.on("data", (d) => {
|
|
404
501
|
buf += d;
|
|
405
502
|
let i;
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -8,11 +8,11 @@ import { homedir, tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
10
|
import { openBatch, isLaneAlive, listBatches, SECRET_ENV } from "./batch.mjs";
|
|
11
|
-
import { startDaemon, scheddPaths, connectDaemon, request, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, unitState, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
12
|
import { schedDir } from "./sched.mjs";
|
|
13
13
|
import { readHeartbeat } from "./schedd-health.mjs";
|
|
14
14
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
15
|
-
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
15
|
+
import { orchestrateFromCli, scheddFromCli } from "../engine-cli.mjs";
|
|
16
16
|
|
|
17
17
|
// #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
|
|
18
18
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
|
|
@@ -249,6 +249,55 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
249
249
|
} finally { await h.cleanup(); }
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
it("#5000: add to a finished unhosted batch reopens it through the daemon and runs the new issue", async () => {
|
|
253
|
+
const h = host();
|
|
254
|
+
try {
|
|
255
|
+
await h.daemon();
|
|
256
|
+
const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
|
|
257
|
+
await withRoot(h, () => capture(async () => {
|
|
258
|
+
writeFileSync(h.release, "");
|
|
259
|
+
await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
260
|
+
const [id] = listBatches(h.root);
|
|
261
|
+
const b = openBatch(h.root, id);
|
|
262
|
+
const res = await orchestrateFromCli(["--add", id, "6"], client);
|
|
263
|
+
assert.deepEqual(res.added, [6]);
|
|
264
|
+
assert.equal(res.reopened, true, "the finished batch is hosted again");
|
|
265
|
+
await until(() => h.launched().includes(6));
|
|
266
|
+
await until(() => b.store.load()?.results?.[6]?.outcome === "merged");
|
|
267
|
+
assert.deepEqual(listBatches(h.root), [id], "no second batch");
|
|
268
|
+
}));
|
|
269
|
+
} finally { await h.cleanup(); }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("#4917: a held (pending) issue is flagged unhosted by status once its report is gone; --requeue re-hosts it and says so", async () => {
|
|
273
|
+
const h = host();
|
|
274
|
+
try {
|
|
275
|
+
await h.daemon();
|
|
276
|
+
const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
|
|
277
|
+
await withRoot(h, () => capture(async () => {
|
|
278
|
+
const run = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
279
|
+
await until(() => h.launched().length === 1 && listBatches(h.root).length === 1);
|
|
280
|
+
const [id] = listBatches(h.root);
|
|
281
|
+
const b = openBatch(h.root, id);
|
|
282
|
+
b.holdIssues([2]);
|
|
283
|
+
writeFileSync(h.release, "");
|
|
284
|
+
const res = await run;
|
|
285
|
+
assert.equal(res.rows.find((r) => r.issue === 2).outcome, "held");
|
|
286
|
+
assert.deepEqual(b.pendingIssues(), [2]);
|
|
287
|
+
b.clearReport(); // a batch finalised before the fix, or one never resumed
|
|
288
|
+
const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
|
|
289
|
+
assert.deepEqual(st.unhosted, [{ id, pending: [2] }]);
|
|
290
|
+
// finalised by an older version: a report plus unapplied requeue entries still reopens, and the CLI says so
|
|
291
|
+
b.releaseIssues([2]);
|
|
292
|
+
b.store.save({ ...b.store.load(), results: { ...b.store.load().results, 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
|
|
293
|
+
b.writeReport({ rows: [{ issue: 1, outcome: "needs-human" }] });
|
|
294
|
+
const rq = await capture(() => orchestrateFromCli(["--requeue", id, "1"], client));
|
|
295
|
+
assert.deepEqual(rq.res.requeued, [1]);
|
|
296
|
+
assert.ok(rq.lines.some((l) => l.includes("reopened and hosted by the schedd")), rq.lines.join("\n"));
|
|
297
|
+
}));
|
|
298
|
+
} finally { await h.cleanup(); }
|
|
299
|
+
});
|
|
300
|
+
|
|
252
301
|
it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
|
|
253
302
|
const h = host();
|
|
254
303
|
try {
|
|
@@ -350,6 +399,66 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
350
399
|
} finally { await h.cleanup(); }
|
|
351
400
|
});
|
|
352
401
|
|
|
402
|
+
it("#4917: a batch that ends unreported with a deferred issue waiting is re-hosted by the running daemon once its retry-at is near", async () => {
|
|
403
|
+
const dir = tmpDir("schedd-rehost-"), root = join(dir, "batches"), id = "b20261031-000000-4917";
|
|
404
|
+
const calls = [];
|
|
405
|
+
const runBatch = async ({ argv, sink }) => {
|
|
406
|
+
calls.push(argv);
|
|
407
|
+
const b = openBatch(root, id);
|
|
408
|
+
if (argv[0] !== "--resume") {
|
|
409
|
+
b.writePlan({ daemon: true, repo: "o/r" });
|
|
410
|
+
b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 1200 } });
|
|
411
|
+
} else b.store.save({ ...b.store.load(), pending: [], results: { 1: { issue: 1, outcome: "merged" } } });
|
|
412
|
+
sink.batch({ id, dir: b.dir });
|
|
413
|
+
return {};
|
|
414
|
+
};
|
|
415
|
+
const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
|
|
416
|
+
try {
|
|
417
|
+
await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
|
|
418
|
+
await until(() => calls.length === 2, 8000);
|
|
419
|
+
assert.deepEqual(calls[1], ["--resume", id]);
|
|
420
|
+
} finally { await d.close(); }
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("#4917: a resume that leaves the waiting issue unadmitted (breaker tripped, retry-at passed) is not re-hosted again", async () => {
|
|
424
|
+
const dir = tmpDir("schedd-rehost-loop-"), root = join(dir, "batches"), id = "b20261031-000000-4918";
|
|
425
|
+
const calls = [];
|
|
426
|
+
const runBatch = async ({ argv, sink }) => {
|
|
427
|
+
calls.push(argv);
|
|
428
|
+
const b = openBatch(root, id);
|
|
429
|
+
// first run: deferred issue 1 waits 300 ms; every resume ends at once (a tripped breaker), leaving it pending with a past retry-at
|
|
430
|
+
if (argv[0] !== "--resume") { b.writePlan({ daemon: true, repo: "o/r" }); b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 } }); }
|
|
431
|
+
sink.batch({ id, dir: b.dir });
|
|
432
|
+
return {};
|
|
433
|
+
};
|
|
434
|
+
const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
|
|
435
|
+
try {
|
|
436
|
+
await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
|
|
437
|
+
await until(() => calls.length >= 2, 8000);
|
|
438
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
439
|
+
assert.equal(calls.length, 2, `re-hosted ${calls.length - 1} times; a past retry-at the resume did not act on must not loop`);
|
|
440
|
+
} finally { await d.close(); }
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("#4917: a batch whose breaker tripped (not transport) is not re-hosted by the timer", async () => {
|
|
444
|
+
const dir = tmpDir("schedd-rehost-brk-"), root = join(dir, "batches"), id = "b20261031-000000-4919";
|
|
445
|
+
const calls = [];
|
|
446
|
+
const runBatch = async ({ argv, sink }) => {
|
|
447
|
+
calls.push(argv);
|
|
448
|
+
const b = openBatch(root, id);
|
|
449
|
+
b.writePlan({ daemon: true, repo: "o/r" });
|
|
450
|
+
b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 }, breaker: { at: Date.now(), errors: 3 } });
|
|
451
|
+
sink.batch({ id, dir: b.dir });
|
|
452
|
+
return {};
|
|
453
|
+
};
|
|
454
|
+
const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
|
|
455
|
+
try {
|
|
456
|
+
await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
|
|
457
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
458
|
+
assert.equal(calls.length, 1, "a tripped breaker holds the batch for an operator --resume");
|
|
459
|
+
} finally { await d.close(); }
|
|
460
|
+
});
|
|
461
|
+
|
|
353
462
|
it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
|
|
354
463
|
const dir = tmpDir("schedd-stop-"), root = join(dir, "batches"), lock = scheddPaths(dir).lock;
|
|
355
464
|
const started = { a: [], b: [] }, stoppedWithLock = {};
|
|
@@ -637,6 +746,96 @@ describe("schedd due timer (#4627)", () => {
|
|
|
637
746
|
});
|
|
638
747
|
});
|
|
639
748
|
|
|
749
|
+
// #4775: a client never races a systemd-managed daemon; a stalled daemon is visible and bounded.
|
|
750
|
+
describe("schedd under load and restart (#4775)", () => {
|
|
751
|
+
const noSleep = () => Promise.resolve();
|
|
752
|
+
it("connectDaemon never spawns while the unit is active or activating, and starts an installed stopped unit instead of spawning", async () => {
|
|
753
|
+
for (const state of ["active", "activating", "reloading"]) {
|
|
754
|
+
let spawned = 0;
|
|
755
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => { spawned++; }, unit: () => state, start: () => { spawned++; } }), /did not start/);
|
|
756
|
+
assert.equal(spawned, 0, `${state}: nothing is started`);
|
|
757
|
+
}
|
|
758
|
+
const calls = [];
|
|
759
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => "inactive", start: () => calls.push("unit") }), /did not start/);
|
|
760
|
+
assert.deepEqual(calls, ["unit"]);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("connectDaemon spawns the daemon itself only when no unit is installed; spawnDaemon:null starts nothing", async () => {
|
|
764
|
+
const calls = [];
|
|
765
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => null, start: () => calls.push("unit") }), /did not start/);
|
|
766
|
+
assert.deepEqual(calls, ["spawn"]);
|
|
767
|
+
await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), spawnDaemon: null, unit: () => { throw new Error("not consulted"); } }), { code: "ENOENT" });
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("unitState reads the systemctl state (also from a non-zero exit) and is null without a unit file", () => {
|
|
771
|
+
assert.equal(unitState({ exists: () => false }), null);
|
|
772
|
+
assert.equal(unitState({ exists: () => true, run: () => "activating\n" }), "activating");
|
|
773
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("exit 3"), { stdout: "inactive\n" }); } }), "inactive");
|
|
774
|
+
assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("none"), { code: "ENOENT" }); } }), null);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("request(timeoutMs) rejects a daemon that never answers instead of hanging", async () => {
|
|
778
|
+
const dir = tmpDir("schedd-mute-"), sock = scheddPaths(dir).sock;
|
|
779
|
+
const server = (await import("node:net")).createServer(() => { /* accepts, never answers */ });
|
|
780
|
+
await new Promise((r) => server.listen(sock, r));
|
|
781
|
+
try {
|
|
782
|
+
const c = await connectDaemon({ dir, spawnDaemon: null });
|
|
783
|
+
await assert.rejects(request(c, { op: "status" }, undefined, { timeoutMs: 80 }), { code: "schedd.timeout" });
|
|
784
|
+
} finally { server.close(); }
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("status reports per-batch last-progress age and pending adds, including an unhosted batch with un-admitted adds", async () => {
|
|
788
|
+
const root = tmpDir("schedd-st-b-"), dir = tmpDir("schedd-st-");
|
|
789
|
+
const mk = (id, files) => { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); for (const [n, t] of Object.entries(files)) writeFileSync(join(root, id, n), t); };
|
|
790
|
+
mk("b-stuck", { "state.json": JSON.stringify({ order: [1] }), add: "[7,8]" });
|
|
791
|
+
mk("b-quiet", { "state.json": JSON.stringify({ order: [1] }) });
|
|
792
|
+
const d = await startDaemon({ dir, root, runBatch: async () => ({}), resumeOnStart: false, log: () => {} });
|
|
793
|
+
try {
|
|
794
|
+
const st = await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "status" });
|
|
795
|
+
assert.equal(st.batches.length, 1, "a quiet unhosted batch is not listed");
|
|
796
|
+
const [b] = st.batches;
|
|
797
|
+
assert.equal(b.id, "b-stuck");
|
|
798
|
+
assert.equal(b.running, false);
|
|
799
|
+
assert.deepEqual(b.pendingAdds, [7, 8]);
|
|
800
|
+
assert.ok(b.lastProgressAgeSec >= 0 && b.lastProgressAgeSec < 60);
|
|
801
|
+
} finally { await d.close(); }
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("a batch whose start stalls does not block another batch's request, and the stalled request answers an error", async () => {
|
|
805
|
+
const root = tmpDir("schedd-stall-b-"), dir = tmpDir("schedd-stall-");
|
|
806
|
+
for (const id of ["b-hung", "b-ok"]) { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); writeFileSync(join(root, id, "state.json"), JSON.stringify({ order: [1] })); }
|
|
807
|
+
const d = await startDaemon({ dir, root, resumeOnStart: false, admitMs: 150, log: () => {},
|
|
808
|
+
runBatch: ({ argv, sink }) => (argv[1] === "b-hung" ? new Promise(() => {}) : Promise.resolve().then(() => { sink.batch({ id: argv[1], dir: join(root, argv[1]) }); return {}; })) });
|
|
809
|
+
try {
|
|
810
|
+
const ask = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "attach", id }).catch((e) => e);
|
|
811
|
+
const hung = ask("b-hung");
|
|
812
|
+
const ok = await ask("b-ok");
|
|
813
|
+
assert.notEqual(ok instanceof Error && /did not start/.test(ok.message), true, "the healthy batch is served");
|
|
814
|
+
const e = await hung;
|
|
815
|
+
assert.match(e.message, /did not start within 150 ms/);
|
|
816
|
+
} finally { await d.close(); }
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("the unit stands by while another daemon holds the lock instead of exiting 0 (no Restart=always loop)", async () => {
|
|
820
|
+
const dir = tmpDir("schedd-sb-"), root = tmpDir("schedd-sb-b-");
|
|
821
|
+
const owner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
|
|
822
|
+
const gone = new Promise((r) => owner.on("exit", r));
|
|
823
|
+
mkdirSync(dir, { recursive: true });
|
|
824
|
+
writeFileSync(scheddPaths(dir).lock, String(owner.pid)); // a live daemon of another process
|
|
825
|
+
const slept = [];
|
|
826
|
+
process.env.FORGEDOCK_SCHEDD_IDLE_MS = "100"; // the successor daemon exits when idle, so the call returns
|
|
827
|
+
process.env.FORGEDOCK_BATCH_DIR = root;
|
|
828
|
+
try {
|
|
829
|
+
const r = await Promise.race([
|
|
830
|
+
scheddFromCli([], { scheddDir: dir, standby: true, standbyMs: 1, sleep: async () => { slept.push(1); if (slept.length === 3) { owner.kill("SIGKILL"); await gone; } }, processOn: () => {}, exit: () => {}, batchDeps: {}, due: null }),
|
|
831
|
+
new Promise((res) => setTimeout(() => res("timeout"), 5000)),
|
|
832
|
+
]);
|
|
833
|
+
assert.ok(slept.length >= 3, "waited for the owner before retrying");
|
|
834
|
+
assert.notEqual(r, "timeout");
|
|
835
|
+
} finally { delete process.env.FORGEDOCK_SCHEDD_IDLE_MS; delete process.env.FORGEDOCK_BATCH_DIR; owner.kill("SIGKILL"); }
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
|
|
640
839
|
describe("#4762 win32 socket path", () => {
|
|
641
840
|
it("win32 derives a stable per-dir named pipe; other platforms keep the unix socket file", () => {
|
|
642
841
|
const a = scheddPaths("/tmp/a", "win32"), b = scheddPaths("/tmp/b", "win32");
|
|
@@ -158,7 +158,7 @@ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs")
|
|
|
158
158
|
export function realIo(env = process.env, { pool = false } = {}) {
|
|
159
159
|
let self;
|
|
160
160
|
const root = batchRoot(env);
|
|
161
|
-
const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
|
|
161
|
+
const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, windowsHide: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
|
|
162
162
|
const waitBatch = async (before) => { for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("orchestrate did not create a batch within 60s"); };
|
|
163
163
|
return (self = {
|
|
164
164
|
now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
@@ -37,7 +37,7 @@ function writeLast(dir, repo, ts) {
|
|
|
37
37
|
*/
|
|
38
38
|
export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
|
|
39
39
|
try {
|
|
40
|
-
const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
|
|
40
|
+
const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, windowsHide: true, stdio: "ignore" });
|
|
41
41
|
c.on("error", () => {});
|
|
42
42
|
c.unref();
|
|
43
43
|
return c;
|
package/bin/lean/signoff.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* never in the range, so they are never rewritten. Deterministic, 0 LLM calls.
|
|
9
9
|
*/
|
|
10
10
|
const SEP = "\x1f", END = "\x1e";
|
|
11
|
+
const STASH_REF = "refs/forgedock/dco-stash";
|
|
11
12
|
const PLACEHOLDER_RE = /@localhost$/i;
|
|
12
13
|
|
|
13
14
|
/** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
|
|
@@ -59,8 +60,17 @@ export async function ensureSignedOff({ git, lane, branch, env = process.env })
|
|
|
59
60
|
throw Object.assign(new Error(`${bad.length} unpushed commit(s) fail DCO (placeholder author or no sign-off: ${bad.slice(0, 5).map((c) => c.sha.slice(0, 8)).join(", ")}) and the worktree has no usable git identity to rewrite them`), { noIdentity: true });
|
|
60
61
|
}
|
|
61
62
|
const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
|
|
63
|
+
// filter-branch refuses a dirty tree (lockfile churn, build output): park tracked changes on a private ref, never the shared stash stack
|
|
64
|
+
const saved = await ok(["status", "--porcelain", "--untracked-files=no"]) ? await ok(["stash", "create"]) : "";
|
|
65
|
+
if (saved) { await git(["update-ref", STASH_REF, saved]); await git(["reset", "-q", "--hard", "HEAD"]); }
|
|
66
|
+
let failure = null;
|
|
62
67
|
try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
|
|
63
|
-
catch (e) {
|
|
68
|
+
catch (e) { failure = `could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
69
|
+
if (saved) {
|
|
70
|
+
try { await git(["stash", "apply", saved]); await ok(["update-ref", "-d", STASH_REF]); }
|
|
71
|
+
catch { failure = `${failure ? `${failure}; ` : ""}could not restore the lane's uncommitted changes after the DCO rewrite; they are kept as ${saved} (${STASH_REF})`; }
|
|
72
|
+
}
|
|
73
|
+
if (failure) throw Object.assign(new Error(failure), { noIdentity: false, engineCode: "engine.dco_rewrite" });
|
|
64
74
|
await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
|
|
65
75
|
return { rewritten: bad.length };
|
|
66
76
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { writeFileSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
10
|
import { ensureSignedOff, needsSignoff, offendersOf } from "./signoff.mjs";
|
|
@@ -99,4 +99,27 @@ describe("ensureSignedOff", () => {
|
|
|
99
99
|
await assert.rejects(ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.noIdentity === true);
|
|
100
100
|
assert.equal(f.sh("rev-parse", "HEAD"), bad);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it("rewrites on a dirty tree (#4997): unstaged and staged tracked changes survive, no stash entry is left", async () => {
|
|
104
|
+
const f = fixture();
|
|
105
|
+
f.commit("a.txt", "unpushed placeholder", "placeholder");
|
|
106
|
+
writeFileSync(join(f.wt, "base.txt"), "churn\n");
|
|
107
|
+
writeFileSync(join(f.wt, "a.txt"), "staged\n"); f.sh("add", "a.txt");
|
|
108
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
109
|
+
assert.equal(r.rewritten, 1);
|
|
110
|
+
assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
|
|
111
|
+
assert.equal(readFileSync(join(f.wt, "a.txt"), "utf8"), "staged\n");
|
|
112
|
+
assert.equal(f.sh("stash", "list"), "");
|
|
113
|
+
assert.equal(f.sh("for-each-ref", "refs/forgedock/"), "");
|
|
114
|
+
assert.match(f.sh("log", "-1", "--format=%B"), /Signed-off-by: Lane Bot/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a failed rewrite carries engineCode engine.dco_rewrite, restores the tree, and is not noIdentity (#4997)", async () => {
|
|
118
|
+
const f = fixture();
|
|
119
|
+
f.commit("a.txt", "unpushed placeholder", "placeholder");
|
|
120
|
+
writeFileSync(join(f.wt, "base.txt"), "churn\n");
|
|
121
|
+
const git = async (a, o) => { if (a[0] === "filter-branch") throw new Error("boom"); return f.git(a, o); };
|
|
122
|
+
await assert.rejects(ensureSignedOff({ git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.engineCode === "engine.dco_rewrite" && e.noIdentity === false && /boom/.test(e.message));
|
|
123
|
+
assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
|
|
124
|
+
});
|
|
102
125
|
});
|
package/bin/lean/toolstate.mjs
CHANGED
|
@@ -51,14 +51,30 @@ export async function unstageToolState(git) {
|
|
|
51
51
|
return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const REQUIREMENTS_EXPORT = /^requirements([-_.][\w.-]+)?\.txt$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* #4959: `requirements*.txt` files the branch added to a Poetry/uv project (pyproject [tool.poetry], or poetry.lock / uv.lock at
|
|
58
|
+
* the base) that the base does not track and the contract does not list (`keep`): an export generated to run local tests.
|
|
59
|
+
*/
|
|
60
|
+
export async function strayGenerated(git, ref, added, keep = []) {
|
|
61
|
+
const cands = added.filter((p) => REQUIREMENTS_EXPORT.test(base(p)) && !keep.includes(p));
|
|
62
|
+
if (!cands.length) return [];
|
|
63
|
+
const tracked = zlist(await git(["ls-tree", "-r", "--name-only", "-z", ref]));
|
|
64
|
+
const managed = tracked.some((f) => /(^|\/)(poetry|uv)\.lock$/.test(f))
|
|
65
|
+
|| (await Promise.all(tracked.filter((f) => /(^|\/)pyproject\.toml$/.test(f)).map((f) => git(["show", `${ref}:${f}`]).catch(() => "")))).some((t) => /^\[tool\.poetry[\].]/m.test(String(t)));
|
|
66
|
+
return managed ? cands : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
/**
|
|
55
70
|
* Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
|
|
56
|
-
* when the branch is clean).
|
|
71
|
+
* when the branch is clean). `keep`: contract deliverable paths, never removed as generated files. Never throws: a failure
|
|
72
|
+
* leaves the branch as it was and the push proceeds.
|
|
57
73
|
*/
|
|
58
|
-
export async function untrackToolState({ git, base: ref, ev = () => {} }) {
|
|
74
|
+
export async function untrackToolState({ git, base: ref, ev = () => {}, keep = [] }) {
|
|
59
75
|
try {
|
|
60
76
|
const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
|
|
61
|
-
const stray = await strayPaths(git, added);
|
|
77
|
+
const stray = [...await strayPaths(git, added), ...await strayGenerated(git, ref, added, keep)];
|
|
62
78
|
if (!stray.length) return [];
|
|
63
79
|
await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
|
|
64
80
|
await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
|