forgedock 1.49.0 → 1.51.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/bin/engine/outcome-codes.mjs +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/orchestrate.md +2 -1
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- package/scripts/review-staging-post.test.sh +40 -8
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Early batch health (#5167). A batch that fails in its first minutes (roots engine-error, the rest skipped) or is refused
|
|
5
|
+
* at launch used to look "launched" until someone read its log. The batch runner (`runBatch`, the schedd per-batch runner)
|
|
6
|
+
* evaluates the batch's `state.json` at T+15 and T+60 min after `plan.created` and once when the batch ends; the first
|
|
7
|
+
* unhealthy evaluation emits one `BATCH UNHEALTHY` alert (a runner line, a GitHub comment on `orchestrate.health.issue`
|
|
8
|
+
* or the batch's `--epic` issues, and `notify.command`) with its causes and recovery commands. `health.json` in the batch
|
|
9
|
+
* dir records the checkpoints done and the alert, so a restarted daemon re-arms only the remaining checks and never alerts
|
|
10
|
+
* twice. A refused launch has no batch dir: it is recorded in `<batch root>/refusals.json`. `forgedock status` reads both.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { spawn as spawnProc } from "node:child_process";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { listBatches, openBatch, REQUEUABLE, writeAtomic } from "./batch.mjs";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_HEALTH = Object.freeze({ rootFailed: true, terminalShare: 0.5, stalled: true, checksMin: Object.freeze([15, 60]), issue: null, notifyCommand: null });
|
|
18
|
+
/** Outcomes that end a row well: never counted against the batch. */
|
|
19
|
+
export const GOOD_END = new Set(["merged", "invalid", "decomposed"]);
|
|
20
|
+
export const REFUSALS_FILE = "refusals.json";
|
|
21
|
+
export const REFUSAL_KEEP = 20;
|
|
22
|
+
export const REFUSAL_SHOW_MS = 24 * 3600 * 1000;
|
|
23
|
+
export const NOTIFY_TIMEOUT_MS = 30 * 1000;
|
|
24
|
+
|
|
25
|
+
const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
26
|
+
const list = (ns) => ns.map((n) => `#${n}`).join(", ");
|
|
27
|
+
|
|
28
|
+
/** forge.yaml (parsed) → health config. The parser flattens `orchestrate.health.*` to `orchestrate.*`. */
|
|
29
|
+
export function parseHealthConfig(yaml) {
|
|
30
|
+
const o = yaml?.orchestrate && typeof yaml.orchestrate === "object" ? yaml.orchestrate : {};
|
|
31
|
+
const bool = (v, d) => (v === undefined || String(v).trim() === "" ? d : String(v).trim().toLowerCase() !== "false");
|
|
32
|
+
const share = Number(o.terminal_share);
|
|
33
|
+
const issue = Number(String(o.issue ?? "").replace(/^#/, ""));
|
|
34
|
+
const checks = String(o.checks_min ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => Number(x.trim())).filter((n) => Number.isFinite(n) && n > 0);
|
|
35
|
+
const cmd = String(yaml?.notify?.command ?? "").trim();
|
|
36
|
+
return { rootFailed: bool(o.root_failed, DEFAULT_HEALTH.rootFailed), terminalShare: o.terminal_share !== undefined && Number.isFinite(share) && share >= 0 && share <= 1 ? share : DEFAULT_HEALTH.terminalShare,
|
|
37
|
+
stalled: bool(o.stalled, DEFAULT_HEALTH.stalled), checksMin: checks.length ? checks : [...DEFAULT_HEALTH.checksMin], issue: Number.isSafeInteger(issue) && issue > 0 ? issue : null, notifyCommand: cmd || null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Issues of the batch with no dependency edge to another issue of the batch. */
|
|
41
|
+
export function rootsOf(state) {
|
|
42
|
+
const order = (state?.order ?? []).map(Number), inBatch = new Set(order);
|
|
43
|
+
return order.filter((n) => !(state?.deps?.[n] ?? []).some((d) => inBatch.has(Number(d)) && Number(d) !== n));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `{healthy, causes:[{code, issues, detail}], recovery:[cmd]}` for one batch state. `held`: operator-held issues (not a
|
|
48
|
+
* stall); `refused`: a refusal record (the launch never started). Pure.
|
|
49
|
+
*/
|
|
50
|
+
export function evaluateHealth(state, { held = [], refused = null, config = DEFAULT_HEALTH, batchId = "<id>", repo = null } = {}) {
|
|
51
|
+
const causes = [], recovery = [];
|
|
52
|
+
if (refused) {
|
|
53
|
+
causes.push({ code: "refused", issues: refused.issues ?? [], detail: refused.message ?? "launch refused" });
|
|
54
|
+
const relaunch = `forgedock orchestrate ${(refused.issues ?? []).join(" ")}${repo ? ` --repo ${repo}` : ""}`;
|
|
55
|
+
recovery.push(`merge the unmet dependencies, then: ${relaunch}`, `${relaunch} --force "<why the unmet dependencies are acceptable>"`);
|
|
56
|
+
return { healthy: false, causes, recovery };
|
|
57
|
+
}
|
|
58
|
+
const order = (state?.order ?? []).map(Number);
|
|
59
|
+
const running = new Set(Object.keys(state?.running ?? {}).map(Number));
|
|
60
|
+
const pending = new Set((state?.pending ?? []).map(Number));
|
|
61
|
+
const results = state?.results ?? {};
|
|
62
|
+
const outcomeOf = (n) => results[n]?.outcome;
|
|
63
|
+
// Terminal and not good: a final row not running and not waiting again (a held row stays pending but counts).
|
|
64
|
+
const bad = (n) => { const o = outcomeOf(n); return o !== undefined && !GOOD_END.has(o) && !running.has(n) && (!pending.has(n) || o === "held"); };
|
|
65
|
+
const merged = order.filter((n) => outcomeOf(n) === "merged").length;
|
|
66
|
+
const failedRoots = rootsOf(state).filter(bad);
|
|
67
|
+
if (config.rootFailed && failedRoots.length) causes.push({ code: "root-failed", issues: failedRoots, detail: `root ${failedRoots.map((n) => `#${n} ${outcomeOf(n)}`).join(", ")}` });
|
|
68
|
+
const badRows = order.filter(bad);
|
|
69
|
+
if (order.length && merged === 0 && badRows.length && badRows.length / order.length >= config.terminalShare) {
|
|
70
|
+
const by = {};
|
|
71
|
+
for (const n of badRows) by[outcomeOf(n)] = (by[outcomeOf(n)] ?? 0) + 1;
|
|
72
|
+
causes.push({ code: "terminal-share", issues: badRows, detail: `${badRows.length}/${order.length} rows ended non-merged (${Object.entries(by).map(([o, k]) => `${k} ${o}`).join(", ")}), 0 merged` });
|
|
73
|
+
}
|
|
74
|
+
const heldSet = new Set(held.map(Number));
|
|
75
|
+
const waiting = [...pending].filter((n) => !heldSet.has(n));
|
|
76
|
+
if (config.stalled && !running.size && waiting.length && state?.phase !== "setup" && !state?.cancelled) {
|
|
77
|
+
causes.push({ code: "stalled", issues: waiting, detail: `0 lanes running while ${waiting.length} issue${waiting.length === 1 ? "" : "s"} pending${state?.breaker ? " (breaker tripped)" : ""}` });
|
|
78
|
+
}
|
|
79
|
+
if (!causes.length) return { healthy: true, causes, recovery };
|
|
80
|
+
const failed = [...new Set(causes.filter((c) => c.code !== "stalled").flatMap((c) => c.issues))];
|
|
81
|
+
const requeue = failed.filter((n) => REQUEUABLE(outcomeOf(n))), relaunch = failed.filter((n) => !REQUEUABLE(outcomeOf(n)));
|
|
82
|
+
if (requeue.length) recovery.push(`forgedock orchestrate --requeue ${batchId} ${requeue.join(" ")} --reason "batch unhealthy"`);
|
|
83
|
+
if (relaunch.length) recovery.push(`forgedock orchestrate ${relaunch.join(" ")}${repo ? ` --repo ${repo}` : ""} # after fixing the cause (see lanes/<n>.log)`);
|
|
84
|
+
if (pending.size) recovery.push(`forgedock orchestrate --resume ${batchId}`);
|
|
85
|
+
recovery.push(`forgedock status --batch ${batchId}`, `forgedock orchestrate --cancel ${batchId}`);
|
|
86
|
+
return { healthy: false, causes, recovery };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `BATCH UNHEALTHY` text: top causes, then the exact recovery commands. */
|
|
90
|
+
export function formatAlert({ batchId, checkpoint, health }) {
|
|
91
|
+
return [`BATCH UNHEALTHY ${batchId} (${checkpoint})`, ...health.causes.slice(0, 5).map((c) => ` cause: ${c.code}: ${c.detail}${c.issues?.length && c.code !== "refused" ? ` [${list(c.issues.slice(0, 12))}${c.issues.length > 12 ? ", …" : ""}]` : ""}`),
|
|
92
|
+
" recovery:", ...health.recovery.map((r) => ` ${r}`)].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const healthPath = (dir) => join(dir, "health.json");
|
|
96
|
+
export const readHealth = (dir) => { const h = readJson(healthPath(dir)); return h && typeof h === "object" && !Array.isArray(h) ? h : null; };
|
|
97
|
+
export const writeHealth = (dir, h) => writeAtomic(healthPath(dir), JSON.stringify(h) + "\n", { private: true });
|
|
98
|
+
|
|
99
|
+
/** The checkpoints `[{label, at}]` (`T+<min>`) for a batch created at `created` (ms). */
|
|
100
|
+
export const checkpointsOf = (created, checksMin = DEFAULT_HEALTH.checksMin) => checksMin.map((m) => ({ label: `T+${m}`, at: created + m * 60000 }));
|
|
101
|
+
|
|
102
|
+
/** `health checks: T+15 14:05Z, T+60 14:50Z; forgedock status --batch <id>` */
|
|
103
|
+
export function scheduleLine(batchId, created, checksMin = DEFAULT_HEALTH.checksMin) {
|
|
104
|
+
const hhmm = (ms) => `${new Date(ms).toISOString().slice(11, 16)}Z`;
|
|
105
|
+
return `health checks: ${checkpointsOf(created, checksMin).map((c) => `${c.label} ${hhmm(c.at)}`).join(", ")}, and at batch end; forgedock status --batch ${batchId}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One checkpoint: evaluate the batch, persist it, and call `alert(text, health)` on the first unhealthy result only.
|
|
110
|
+
* @returns the evaluation, plus `alerted` when this call raised the alert
|
|
111
|
+
*/
|
|
112
|
+
export async function runCheck({ batch, checkpoint, config = DEFAULT_HEALTH, repo = null, now = () => Date.now(), alert = async () => {} }) {
|
|
113
|
+
const state = batch.store.load() ?? {};
|
|
114
|
+
let held = [];
|
|
115
|
+
try { held = batch.readHeld?.() ?? []; } catch { held = []; }
|
|
116
|
+
const health = evaluateHealth(state, { held, config, batchId: batch.id, repo });
|
|
117
|
+
const prev = readHealth(batch.dir) ?? {};
|
|
118
|
+
const at = new Date(now()).toISOString();
|
|
119
|
+
const next = { ...prev, checks: { ...(prev.checks ?? {}), [checkpoint]: at }, last: { at, checkpoint, healthy: health.healthy, causes: health.causes, recovery: health.recovery } };
|
|
120
|
+
const fire = !health.healthy && !prev.alertedAt;
|
|
121
|
+
if (fire) Object.assign(next, { alertedAt: at, alert: formatAlert({ batchId: batch.id, checkpoint, health }) });
|
|
122
|
+
try { writeHealth(batch.dir, next); } catch { /* health is advisory */ }
|
|
123
|
+
if (fire) { try { await alert(next.alert, health); } catch { /* every channel is best effort */ } }
|
|
124
|
+
return { ...health, alerted: fire };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Arm the timed checkpoints not yet done (per `health.json`); past-due ones run on the next tick. Timers are unref'd.
|
|
129
|
+
* @returns `{cancel()}`
|
|
130
|
+
*/
|
|
131
|
+
export function scheduleChecks({ batch, created, config = DEFAULT_HEALTH, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout, check }) {
|
|
132
|
+
const done = readHealth(batch.dir)?.checks ?? {};
|
|
133
|
+
const timers = checkpointsOf(created, config.checksMin).filter((c) => !done[c.label]).map((c) => {
|
|
134
|
+
const t = setTimer(() => { Promise.resolve().then(() => check(c.label)).catch(() => {}); }, Math.max(0, c.at - now()));
|
|
135
|
+
t?.unref?.();
|
|
136
|
+
return t;
|
|
137
|
+
});
|
|
138
|
+
return { cancel: () => { for (const t of timers) clearTimer(t); } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Run `notify.command` through the shell: alert JSON on stdin, FORGEDOCK_ALERT_* in env. Never throws. */
|
|
142
|
+
export function runNotify(command, payload, { spawn = spawnProc, timeoutMs = NOTIFY_TIMEOUT_MS, env = process.env } = {}) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let child;
|
|
145
|
+
try {
|
|
146
|
+
child = spawn(command, { shell: true, stdio: ["pipe", "ignore", "ignore"], env: { ...env, FORGEDOCK_ALERT: "BATCH_UNHEALTHY", FORGEDOCK_ALERT_BATCH: String(payload.batch ?? ""), FORGEDOCK_ALERT_TEXT: String(payload.text ?? "") } });
|
|
147
|
+
} catch (e) { return resolve({ ok: false, error: String(e?.message ?? e) }); }
|
|
148
|
+
const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } resolve({ ok: false, error: `timed out after ${Math.round(timeoutMs / 1000)}s` }); }, timeoutMs);
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, error: String(e?.message ?? e) }); });
|
|
151
|
+
child.on("close", (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }); });
|
|
152
|
+
try { child.stdin?.on?.("error", () => {}); child.stdin?.end?.(JSON.stringify(payload) + "\n"); } catch { /* the command ignores stdin */ }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The alert channels: a runner line, a comment on the configured issue (else the batch's epics), and notify.command.
|
|
158
|
+
* Each is best effort.
|
|
159
|
+
*/
|
|
160
|
+
export async function deliverAlert({ text, batchId, health, sink = { line: () => {} }, gh = null, repo = null, config = DEFAULT_HEALTH, epics = [], notify = runNotify }) {
|
|
161
|
+
sink.line(text);
|
|
162
|
+
const targets = config.issue ? [config.issue] : [...new Set(epics)];
|
|
163
|
+
if (gh && repo) for (const n of targets) {
|
|
164
|
+
try { await gh(["issue", "comment", String(n), "-R", repo, "--body", `<!-- FORGE:BATCH-HEALTH batch=${batchId} -->\n## BATCH UNHEALTHY\n\`\`\`text\n${text}\n\`\`\``]); } catch (e) { sink.line(`health: comment on #${n} failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
165
|
+
}
|
|
166
|
+
if (config.notifyCommand) {
|
|
167
|
+
const r = await notify(config.notifyCommand, { alert: "BATCH_UNHEALTHY", batch: batchId, repo, text, causes: health?.causes ?? [], recovery: health?.recovery ?? [] });
|
|
168
|
+
if (!r?.ok) sink.line(`health: notify.command failed (${r?.error ?? "unknown"})`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Record a refused launch under the batch root (no batch dir exists); keeps the newest `REFUSAL_KEEP`. */
|
|
173
|
+
export function recordRefusal(root, rec) {
|
|
174
|
+
const prev = readRefusals(root, { maxAgeMs: Infinity });
|
|
175
|
+
writeAtomic(join(root, REFUSALS_FILE), JSON.stringify([...prev, rec].slice(-REFUSAL_KEEP)) + "\n", { private: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Refusals newer than `maxAgeMs`, oldest first. */
|
|
179
|
+
export function readRefusals(root, { now = Date.now(), maxAgeMs = REFUSAL_SHOW_MS } = {}) {
|
|
180
|
+
const v = readJson(join(root, REFUSALS_FILE));
|
|
181
|
+
return (Array.isArray(v) ? v : []).filter((r) => r && typeof r.at === "string" && now - Date.parse(r.at) <= maxAgeMs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Health of one batch for `forgedock status --batch <id>`. */
|
|
185
|
+
export function batchStatusLines(root, id, { now = Date.now() } = {}) {
|
|
186
|
+
const b = openBatch(root, id);
|
|
187
|
+
if (!b.exists()) return [`batch ${id}: not found in ${root}`];
|
|
188
|
+
const h = readHealth(b.dir), plan = b.readPlan() ?? {};
|
|
189
|
+
const created = Date.parse(plan.created ?? "");
|
|
190
|
+
const out = [];
|
|
191
|
+
if (h?.alertedAt) out.push(`batch ${id}: UNHEALTHY since ${h.alertedAt}`, ...String(h.alert ?? "").split("\n").slice(1));
|
|
192
|
+
else if (h?.last) out.push(`batch ${id}: healthy at ${h.last.checkpoint} (${h.last.at})`);
|
|
193
|
+
else out.push(`batch ${id}: no health check yet`);
|
|
194
|
+
if (h?.alertedAt && h.last && h.last.at !== h.alertedAt) out.push(` last check ${h.last.checkpoint} (${h.last.at}): ${h.last.healthy ? "healthy" : h.last.causes.map((c) => c.code).join(", ")}`);
|
|
195
|
+
if (Number.isFinite(created)) {
|
|
196
|
+
const due = checkpointsOf(created, DEFAULT_HEALTH.checksMin).filter((c) => !h?.checks?.[c.label]);
|
|
197
|
+
if (due.length && !b.readReport()) out.push(` next checks: ${due.map((c) => `${c.label} ${new Date(c.at).toISOString().slice(11, 16)}Z${c.at < now ? " (due)" : ""}`).join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `forgedock status` lines: unhealthy batches still unresolved (no report, or alerted within a day) and recent refusals;
|
|
204
|
+
* with `batch`, that one batch's health.
|
|
205
|
+
*/
|
|
206
|
+
export function statusLines(root, { now = Date.now(), batch = null } = {}) {
|
|
207
|
+
if (batch) return batchStatusLines(root, batch, { now });
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const id of listBatches(root)) {
|
|
210
|
+
try {
|
|
211
|
+
const b = openBatch(root, id), h = readHealth(b.dir);
|
|
212
|
+
if (!h?.alertedAt || (b.readReport() && now - Date.parse(h.alertedAt) > REFUSAL_SHOW_MS)) continue;
|
|
213
|
+
const causes = (h.last?.healthy === false ? h.last.causes : null) ?? [];
|
|
214
|
+
out.push(` BATCH UNHEALTHY ${id} (since ${h.alertedAt})${causes.length ? `: ${causes.map((c) => c.detail).join("; ")}` : ""} — forgedock status --batch ${id}`);
|
|
215
|
+
} catch { /* an unreadable batch has no line */ }
|
|
216
|
+
}
|
|
217
|
+
for (const r of readRefusals(root, { now })) out.push(` LAUNCH REFUSED ${r.at}: ${String(r.message ?? "").slice(0, 300)}`);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { openBatch } from "./batch.mjs";
|
|
9
|
+
import { DEFAULT_HEALTH, parseHealthConfig, rootsOf, evaluateHealth, formatAlert, runCheck, scheduleChecks, scheduleLine, runNotify, deliverAlert,
|
|
10
|
+
recordRefusal, readRefusals, statusLines, REFUSAL_KEEP } from "./batch-health.mjs";
|
|
11
|
+
|
|
12
|
+
// #1 and #3 are roots; #2 depends on #1, #4 on #3.
|
|
13
|
+
const base = (results, extra = {}) => ({ order: [1, 2, 3, 4], deps: { 2: [1], 4: [3] }, results, running: {}, pending: [], ...extra });
|
|
14
|
+
|
|
15
|
+
describe("#5167 evaluateHealth", () => {
|
|
16
|
+
it("roots are the issues with no in-batch dependency", () => {
|
|
17
|
+
assert.deepEqual(rootsOf(base({})), [1, 3]);
|
|
18
|
+
assert.deepEqual(rootsOf({ order: [5, 6], deps: { 6: [99] } }), [5, 6], "an out-of-batch dependency keeps a root");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("all roots engine-error, the rest skipped: root-failed and terminal-share, with recovery commands", () => {
|
|
22
|
+
const st = base({ 1: { outcome: "engine-error" }, 2: { outcome: "skipped" }, 3: { outcome: "engine-error" }, 4: { outcome: "skipped" } });
|
|
23
|
+
const h = evaluateHealth(st, { batchId: "b1", repo: "o/r" });
|
|
24
|
+
assert.equal(h.healthy, false);
|
|
25
|
+
assert.deepEqual(h.causes.map((c) => c.code), ["root-failed", "terminal-share"]);
|
|
26
|
+
assert.deepEqual(h.causes[0].issues, [1, 3]);
|
|
27
|
+
assert.match(h.causes[1].detail, /4\/4 rows ended non-merged \(2 engine-error, 2 skipped\), 0 merged/);
|
|
28
|
+
assert.ok(h.recovery.includes(`forgedock orchestrate --requeue b1 1 3 2 4 --reason "batch unhealthy"`), h.recovery.join("\n"));
|
|
29
|
+
assert.ok(h.recovery.includes("forgedock orchestrate --cancel b1") && h.recovery.includes("forgedock status --batch b1"));
|
|
30
|
+
const text = formatAlert({ batchId: "b1", checkpoint: "T+15", health: h });
|
|
31
|
+
assert.match(text, /^BATCH UNHEALTHY b1 \(T\+15\)\n {2}cause: root-failed: root #1 engine-error, #3 engine-error \[#1, #3\]/);
|
|
32
|
+
assert.match(text, /\n {2}recovery:\n {4}forgedock orchestrate --requeue b1 1 3 2 4/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("a healthy batch (running, merged, or a requeued row waiting again) has no cause", () => {
|
|
36
|
+
assert.deepEqual(evaluateHealth(base({ 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [2, 4] })), { healthy: true, causes: [], recovery: [] });
|
|
37
|
+
assert.equal(evaluateHealth(base({ 1: { outcome: "merged" }, 2: { outcome: "merged" }, 3: { outcome: "invalid" }, 4: { outcome: "decomposed" } })).healthy, true);
|
|
38
|
+
assert.equal(evaluateHealth(base({ 1: { outcome: "engine-error" } }, { running: { 1: {} } })).healthy, true, "a relaunched root is running");
|
|
39
|
+
assert.equal(evaluateHealth(base({}, { phase: "setup", pending: [1, 2, 3, 4] })).healthy, true, "setup is not a stall");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("terminal-share needs 0 merged and the configured share; root_failed: false turns that cause off", () => {
|
|
43
|
+
const st = base({ 2: { outcome: "needs-human" }, 4: { outcome: "held" }, 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [4] });
|
|
44
|
+
assert.equal(evaluateHealth(st).healthy, true, "one merge keeps the share cause off");
|
|
45
|
+
const st2 = base({ 2: { outcome: "needs-human" }, 4: { outcome: "held" } }, { running: { 1: {}, 3: {} }, pending: [4] });
|
|
46
|
+
assert.deepEqual(evaluateHealth(st2).causes.map((c) => c.code), ["terminal-share"], "held counts even though it stays pending");
|
|
47
|
+
assert.equal(evaluateHealth(st2, { config: { ...DEFAULT_HEALTH, terminalShare: 0.75 } }).healthy, true);
|
|
48
|
+
const st3 = base({ 1: { outcome: "engine-error" } }, { running: { 3: {} }, pending: [2, 4] });
|
|
49
|
+
assert.equal(evaluateHealth(st3, { config: { ...DEFAULT_HEALTH, rootFailed: false } }).healthy, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stalled: 0 running with pending, operator-held issues excepted", () => {
|
|
53
|
+
const st = base({ 1: { outcome: "merged" } }, { pending: [2, 3, 4] });
|
|
54
|
+
const h = evaluateHealth(st, { batchId: "b2" });
|
|
55
|
+
assert.deepEqual(h.causes.map((c) => [c.code, c.issues]), [["stalled", [2, 3, 4]]]);
|
|
56
|
+
assert.ok(h.recovery.includes("forgedock orchestrate --resume b2"));
|
|
57
|
+
assert.equal(evaluateHealth(st, { held: [2, 3, 4] }).healthy, true);
|
|
58
|
+
assert.equal(evaluateHealth({ ...st, cancelled: true }).healthy, true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("a refused launch is unhealthy with the merge-or-force relaunch", () => {
|
|
62
|
+
const h = evaluateHealth(null, { refused: { issues: [7], message: "parallel milestone start refused: #7 depends on #9" }, repo: "o/r" });
|
|
63
|
+
assert.deepEqual(h.causes.map((c) => c.code), ["refused"]);
|
|
64
|
+
assert.deepEqual(h.recovery, ["merge the unmet dependencies, then: forgedock orchestrate 7 --repo o/r", `forgedock orchestrate 7 --repo o/r --force "<why the unmet dependencies are acceptable>"`]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("#5167 config", () => {
|
|
69
|
+
it("defaults, and orchestrate.health.* (flattened) plus notify.command", () => {
|
|
70
|
+
assert.deepEqual(parseHealthConfig(null), { ...DEFAULT_HEALTH, checksMin: [15, 60] });
|
|
71
|
+
const c = parseHealthConfig({ orchestrate: { root_failed: "false", terminal_share: "0.4", stalled: "false", issue: "#12", checks_min: "[10, 30]" }, notify: { command: "curl -d @- x" } });
|
|
72
|
+
assert.deepEqual(c, { rootFailed: false, terminalShare: 0.4, stalled: false, checksMin: [10, 30], issue: 12, notifyCommand: "curl -d @- x" });
|
|
73
|
+
assert.equal(parseHealthConfig({ orchestrate: { terminal_share: "2" } }).terminalShare, 0.5, "out of range keeps the default");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("#5167 checkpoints, alert-once and persistence", () => {
|
|
78
|
+
const mkBatch = (state) => {
|
|
79
|
+
const root = tmpDir("bh-"), b = openBatch(root, "b20260101-000000-1");
|
|
80
|
+
mkdirSync(b.dir, { recursive: true });
|
|
81
|
+
b.writePlan({ v: 1, id: b.id, argv: ["1"], created: "2026-01-01T00:00:00.000Z" });
|
|
82
|
+
b.store.save(state);
|
|
83
|
+
return { root, b };
|
|
84
|
+
};
|
|
85
|
+
const bad = base({ 1: { outcome: "engine-error" }, 2: { outcome: "skipped" }, 3: { outcome: "engine-error" }, 4: { outcome: "skipped" } });
|
|
86
|
+
|
|
87
|
+
it("the first unhealthy check alerts; later checks and a restart do not alert again", async () => {
|
|
88
|
+
const { root, b } = mkBatch(bad), alerts = [];
|
|
89
|
+
const alert = async (t) => { alerts.push(t); };
|
|
90
|
+
const r1 = await runCheck({ batch: b, checkpoint: "T+15", alert, now: () => Date.parse("2026-01-01T00:15:00Z") });
|
|
91
|
+
const r2 = await runCheck({ batch: openBatch(root, b.id), checkpoint: "T+60", alert });
|
|
92
|
+
await runCheck({ batch: b, checkpoint: "end", alert });
|
|
93
|
+
assert.deepEqual([r1.alerted, r2.alerted, alerts.length], [true, false, 1]);
|
|
94
|
+
const h = JSON.parse(readFileSync(join(b.dir, "health.json"), "utf8"));
|
|
95
|
+
assert.equal(h.alertedAt, "2026-01-01T00:15:00.000Z");
|
|
96
|
+
assert.deepEqual(Object.keys(h.checks), ["T+15", "T+60", "end"]);
|
|
97
|
+
assert.match(statusLines(root, { now: Date.parse("2026-01-01T01:00:00Z") }).join("\n"), /BATCH UNHEALTHY b20260101-000000-1 \(since 2026-01-01T00:15:00.000Z\): root #1 engine-error/);
|
|
98
|
+
const one = statusLines(root, { batch: b.id }).join("\n");
|
|
99
|
+
assert.match(one, /UNHEALTHY since/);
|
|
100
|
+
assert.match(one, /forgedock orchestrate --cancel b20260101-000000-1/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a healthy check records no alert, and a throwing channel never escapes", async () => {
|
|
104
|
+
const { b, root } = mkBatch(base({ 1: { outcome: "merged" } }, { running: { 3: {} }, pending: [2, 4] }));
|
|
105
|
+
const r = await runCheck({ batch: b, checkpoint: "T+15", alert: async () => { throw new Error("x"); } });
|
|
106
|
+
assert.equal(r.healthy, true);
|
|
107
|
+
assert.equal(statusLines(root).length, 0);
|
|
108
|
+
assert.match(statusLines(root, { batch: b.id })[0], /healthy at T\+15/);
|
|
109
|
+
b.store.save(bad);
|
|
110
|
+
const r2 = await runCheck({ batch: b, checkpoint: "T+60", alert: async () => { throw new Error("gh down"); } });
|
|
111
|
+
assert.equal(r2.alerted, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("scheduleChecks arms only the checkpoints health.json has not recorded, relative to created", () => {
|
|
115
|
+
const { b } = mkBatch(bad);
|
|
116
|
+
writeFileSync(join(b.dir, "health.json"), JSON.stringify({ checks: { "T+15": "x" } }));
|
|
117
|
+
const armed = [];
|
|
118
|
+
const created = Date.parse("2026-01-01T00:00:00Z");
|
|
119
|
+
const s = scheduleChecks({ batch: b, created, now: () => created + 20 * 60000, setTimer: (fn, ms) => { armed.push(ms); return 1; }, clearTimer: () => armed.push("cleared"), check: () => {} });
|
|
120
|
+
s.cancel();
|
|
121
|
+
assert.deepEqual(armed, [40 * 60000, "cleared"]);
|
|
122
|
+
assert.equal(scheduleLine("b9", created), "health checks: T+15 00:15Z, T+60 01:00Z, and at batch end; forgedock status --batch b9");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("#5167 channels and refusals", () => {
|
|
127
|
+
it("runNotify pipes the alert JSON and env to the shell command; failures are returned", async () => {
|
|
128
|
+
const out = join(tmpDir("bh-n-"), "got");
|
|
129
|
+
const ok = await runNotify(`cat > ${out}; echo "$FORGEDOCK_ALERT $FORGEDOCK_ALERT_BATCH" >> ${out}`, { batch: "b1", text: "t" });
|
|
130
|
+
assert.deepEqual(ok, { ok: true });
|
|
131
|
+
const got = readFileSync(out, "utf8").split("\n");
|
|
132
|
+
assert.equal(JSON.parse(got[0]).batch, "b1");
|
|
133
|
+
assert.equal(got[1], "BATCH_UNHEALTHY b1");
|
|
134
|
+
assert.deepEqual(await runNotify("exit 3", { batch: "b1" }), { ok: false, error: "exit 3" });
|
|
135
|
+
assert.equal((await runNotify("sleep 5", {}, { timeoutMs: 50 })).ok, false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("deliverAlert comments on the configured issue, else the epics, and reports failed channels", async () => {
|
|
139
|
+
const calls = [], lines = [];
|
|
140
|
+
const sink = { line: (l) => lines.push(l) };
|
|
141
|
+
await deliverAlert({ text: "BATCH UNHEALTHY b1", batchId: "b1", sink, gh: async (a) => { calls.push(a[2]); }, repo: "o/r", epics: [5, 6, 5] });
|
|
142
|
+
await deliverAlert({ text: "BATCH UNHEALTHY b1", batchId: "b1", sink, gh: async (a) => { calls.push(a[2]); throw new Error("403"); }, repo: "o/r", config: { ...DEFAULT_HEALTH, issue: 9, notifyCommand: "x" }, epics: [5], notify: async () => ({ ok: false, error: "exit 1" }) });
|
|
143
|
+
assert.deepEqual(calls, ["5", "6", "9"]);
|
|
144
|
+
assert.deepEqual(lines.filter((l) => /^health:/.test(l)), ["health: comment on #9 failed (403)", "health: notify.command failed (exit 1)"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("refusals are kept under the batch root, pruned, and shown by status for a day", () => {
|
|
148
|
+
const root = tmpDir("bh-r-");
|
|
149
|
+
for (let i = 0; i < REFUSAL_KEEP + 3; i++) recordRefusal(root, { at: new Date(Date.parse("2026-01-01T00:00:00Z") + i * 1000).toISOString(), issues: [7], message: `refused ${i}` });
|
|
150
|
+
assert.equal(readRefusals(root, { maxAgeMs: Infinity }).length, REFUSAL_KEEP);
|
|
151
|
+
const now = Date.parse("2026-01-01T01:00:00Z");
|
|
152
|
+
assert.match(statusLines(root, { now }).at(-1), /^ {2}LAUNCH REFUSED 2026-01-01T00:00:22.000Z: refused 22$/);
|
|
153
|
+
assert.equal(statusLines(root, { now: now + 2 * 86400000 }).length, 0);
|
|
154
|
+
assert.ok(existsSync(join(root, "refusals.json")));
|
|
155
|
+
});
|
|
156
|
+
});
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { dirname, join, resolve, sep } from "node:path";
|
|
22
|
+
import { sanitizeOverride, validateExecutionPatch } from "./live-execution.mjs";
|
|
22
23
|
|
|
23
24
|
export const RAM_FS = new Set(["tmpfs", "ramfs"]);
|
|
24
25
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -85,8 +86,8 @@ const readAdded = (file) => {
|
|
|
85
86
|
return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
|
|
86
87
|
};
|
|
87
88
|
|
|
88
|
-
/** `outcome`s `--requeue` may move back to pending (#4550). */
|
|
89
|
-
export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
89
|
+
/** `outcome`s `--requeue` may move back to pending (#4550); #5168: a final `engine-error` too (the runner clears its engine-error count); #5162: `held` rows too. */
|
|
90
|
+
export const REQUEUABLE = (outcome) => ["needs-human", "engine-error", "held", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
90
91
|
/** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
|
|
91
92
|
export const requeueKey = (e) => `${e.issue}@${e.at}`;
|
|
92
93
|
|
|
@@ -145,6 +146,14 @@ export function openBatch(root, id) {
|
|
|
145
146
|
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
146
147
|
put(f("parallel"), `${k}\n`);
|
|
147
148
|
},
|
|
149
|
+
/** Live execution override (#5138): the `execution` file `{maxRemote?, mode?, provider?}`, read on every admission; null when missing or invalid. */
|
|
150
|
+
readExecution: () => sanitizeOverride(readJson(f("execution"))),
|
|
151
|
+
/** Merge a validated patch into the `execution` file. @returns the stored override. */
|
|
152
|
+
setExecution: (patch) => {
|
|
153
|
+
const next = { ...(sanitizeOverride(readJson(f("execution"))) ?? {}), ...validateExecutionPatch(patch) };
|
|
154
|
+
put(f("execution"), JSON.stringify(next) + "\n");
|
|
155
|
+
return next;
|
|
156
|
+
},
|
|
148
157
|
/** Issues added by `--add` (#4408): the `add` file, sorted unique positive integers; [] when missing or invalid. */
|
|
149
158
|
readAdded: () => readAdded(f("add")),
|
|
150
159
|
/**
|
|
@@ -168,7 +177,7 @@ export function openBatch(root, id) {
|
|
|
168
177
|
},
|
|
169
178
|
/**
|
|
170
179
|
* `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
|
|
171
|
-
* `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
|
|
180
|
+
* `order`, no terminal result yet (`not terminal`; a final `engine-error` is terminal, #5168), or already requeued and not yet applied. The needs-human label and
|
|
172
181
|
* closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
|
|
173
182
|
*/
|
|
174
183
|
requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
|