forgedock 1.34.0 → 1.36.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/README.md +78 -324
- package/bin/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +181 -26
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +44 -5
- package/bin/lean/batch.mjs +66 -3
- package/bin/lean/batch.test.mjs +176 -1
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +30 -9
- package/bin/lean/conformance.test.mjs +51 -0
- package/bin/lean/effects.mjs +14 -3
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +16 -0
- package/bin/lean/lean.mjs +147 -36
- package/bin/lean/lean.test.mjs +319 -8
- package/bin/lean/orchestrate.mjs +130 -13
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +58 -8
- package/bin/lean/precheck.test.mjs +77 -3
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +36 -6
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +34 -5
- package/bin/lean/schedd.test.mjs +84 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +61 -2
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
|
|
8
8
|
* (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
|
|
9
9
|
* the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
|
|
10
|
+
* A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
|
|
10
11
|
* No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
|
|
11
12
|
* Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
|
|
12
13
|
* When it parked the issue (#4389, `parked: true`), no lane starts either and the row ends with the parked kind.
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
* `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
|
|
29
30
|
* join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
|
|
30
31
|
* batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
|
|
32
|
+
* `getHeld()` (#4592, `--hold`/`--release`) is read on every pass: the issues it returns are never admitted (a running lane is not stopped);
|
|
33
|
+
* when only held issues (and their dependents) remain and nothing runs, the batch ends with them `held` ("held by operator"), and a `--resume <id> <issue...>` naming them (or `--release`) re-admits them.
|
|
31
34
|
*
|
|
32
35
|
* Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
|
|
33
36
|
* `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
|
|
@@ -101,9 +104,11 @@ import { recoveryEvidence } from "./render.mjs";
|
|
|
101
104
|
import { renderAlerts } from "../stats/render.mjs";
|
|
102
105
|
import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
103
106
|
import { laneSize, SIZES } from "./budget.mjs";
|
|
107
|
+
import { requeueKey, REQUEUABLE } from "./batch.mjs";
|
|
104
108
|
|
|
105
109
|
// A dependency that finished like this no longer blocks its dependents.
|
|
106
110
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
111
|
+
const WATCH_EVERY_MS = 60_000; // #4595
|
|
107
112
|
// Times one issue may be requeued behind a dependency before its dependency-open row is final.
|
|
108
113
|
export const MAX_REQUEUES = 2;
|
|
109
114
|
// Deferrals other than a busy resource (UNCAPPED_DEFERRALS) one issue may take before its deferred row is final.
|
|
@@ -230,9 +235,9 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
230
235
|
* @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
|
|
231
236
|
*/
|
|
232
237
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
233
|
-
store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
238
|
+
store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
234
239
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
235
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
|
|
240
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, stopLane = null,
|
|
236
241
|
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS }) {
|
|
237
242
|
const saved = store?.load?.() ?? null;
|
|
238
243
|
const order = saved ? [...saved.order] : [];
|
|
@@ -257,6 +262,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
257
262
|
const holds = limits ?? memoryLimits(now);
|
|
258
263
|
const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
|
|
259
264
|
let tripped = saved?.breaker ?? null;
|
|
265
|
+
let opHeld = new Set(); // #4592: operator-held issues this pass (the batch `hold` file is the authority)
|
|
266
|
+
const opHeldHit = new Set(); // held issues that were pending this pass
|
|
260
267
|
// #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
|
|
261
268
|
let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
|
|
262
269
|
const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
|
|
@@ -295,6 +302,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
295
302
|
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
296
303
|
const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
|
|
297
304
|
const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
|
|
305
|
+
const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
|
|
306
|
+
const requeuedInfo = toMap(saved?.requeued); // #4550: issue -> {reason, by, at, prior: [earlier rows]}
|
|
298
307
|
const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
|
|
299
308
|
// #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
|
|
300
309
|
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
@@ -308,6 +317,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
308
317
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
309
318
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
310
319
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
320
|
+
...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
|
|
311
321
|
...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
|
|
312
322
|
...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
|
|
313
323
|
};
|
|
@@ -736,7 +746,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
736
746
|
}
|
|
737
747
|
save();
|
|
738
748
|
|
|
739
|
-
const heldDetail = (n) => cancelled ? "cancelled by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
749
|
+
const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
740
750
|
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
741
751
|
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
742
752
|
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
@@ -776,13 +786,67 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
776
786
|
update({ issue: null, outcome: "added", detail: list });
|
|
777
787
|
return fresh.length;
|
|
778
788
|
};
|
|
779
|
-
// #
|
|
789
|
+
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
790
|
+
// return to pending, the prior rows kept in history; edges stay, so a dependent still waits for the issue to merge.
|
|
791
|
+
const drainRequeues = async () => {
|
|
792
|
+
if (!getRequeued) return 0;
|
|
793
|
+
let got = [];
|
|
794
|
+
try { got = getRequeued() ?? []; } catch { got = []; }
|
|
795
|
+
const fresh = (Array.isArray(got) ? got : []).filter((e) => Number.isSafeInteger(e?.issue) && !requeueApplied.has(requeueKey(e)));
|
|
796
|
+
let n = 0;
|
|
797
|
+
for (const e of fresh) {
|
|
798
|
+
requeueApplied.add(requeueKey(e));
|
|
799
|
+
const row = results.get(e.issue);
|
|
800
|
+
if (!row || !REQUEUABLE(row.outcome) || running.has(e.issue)) continue;
|
|
801
|
+
const moved = [e.issue], priors = new Map([[e.issue, row]]);
|
|
802
|
+
results.delete(e.issue);
|
|
803
|
+
for (let i = 0; i < moved.length; i++) { // dependents skipped behind a requeued issue (fixpoint)
|
|
804
|
+
for (const [m, r] of [...results]) {
|
|
805
|
+
if (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i]) continue;
|
|
806
|
+
priors.set(m, r); results.delete(m); moved.push(m);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (const m of moved) {
|
|
810
|
+
for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
|
|
811
|
+
relabelled.delete(m);
|
|
812
|
+
if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
|
|
813
|
+
if (!pending.includes(m)) requeue(m, 0);
|
|
814
|
+
}
|
|
815
|
+
save();
|
|
816
|
+
const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
|
|
817
|
+
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
|
|
818
|
+
why: e.reason || "operator decision", alternative: "start a second batch" });
|
|
819
|
+
update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
|
|
820
|
+
n++;
|
|
821
|
+
}
|
|
822
|
+
if (fresh.length) save();
|
|
823
|
+
return n;
|
|
824
|
+
};
|
|
825
|
+
// #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result unless the cancel carries `stopRemote` (#4612).
|
|
780
826
|
const stopSent = new Set();
|
|
781
827
|
function stop(n, rec) {
|
|
782
|
-
if (!stopLane || rec?.remote || stopSent.has(n)) return;
|
|
828
|
+
if (!stopLane || (rec?.remote && cancelled?.stopRemote !== true) || stopSent.has(n)) return; // #4612: remote lanes only when the cancel asked for it
|
|
783
829
|
stopSent.add(n);
|
|
784
830
|
try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
|
|
785
831
|
}
|
|
832
|
+
// #4592: re-read the hold set; one decision and one row when it changes.
|
|
833
|
+
const refreshHeld = () => {
|
|
834
|
+
if (!getHeld) return;
|
|
835
|
+
let got = [];
|
|
836
|
+
try { got = getHeld() ?? []; } catch { got = opHeld.size ? [...opHeld] : []; }
|
|
837
|
+
const next = new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0));
|
|
838
|
+
const fmt = (ns) => ns.map((n) => `#${n}`).join(", ");
|
|
839
|
+
const held = [...next].filter((n) => !opHeld.has(n)), freed = [...opHeld].filter((n) => !next.has(n));
|
|
840
|
+
opHeld = next;
|
|
841
|
+
if (held.length) {
|
|
842
|
+
decided({ issue: null, what: `held ${fmt(held)} (forgedock orchestrate --hold)`, why: "operator request", alternative: "cancel the batch" });
|
|
843
|
+
update({ issue: null, outcome: "hold", detail: fmt(held) });
|
|
844
|
+
}
|
|
845
|
+
if (freed.length) {
|
|
846
|
+
decided({ issue: null, what: `released ${fmt(freed)} (forgedock orchestrate --release)`, why: "operator request", alternative: "resume the batch" });
|
|
847
|
+
update({ issue: null, outcome: "release", detail: fmt(freed) });
|
|
848
|
+
}
|
|
849
|
+
};
|
|
786
850
|
const checkCancel = () => {
|
|
787
851
|
if (!getCancelled) return;
|
|
788
852
|
let c = null;
|
|
@@ -811,25 +875,72 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
811
875
|
halted = true;
|
|
812
876
|
return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
|
|
813
877
|
};
|
|
878
|
+
// #4595: a skipped row whose blocker (`dependency #B ...`) was finished outside the lane is re-admitted: B is re-checked
|
|
879
|
+
// through `dispatchCheck` (at most every WATCH_EVERY_MS, and once before the batch would end); fail-open.
|
|
880
|
+
let lastWatch = null;
|
|
881
|
+
const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
|
|
882
|
+
const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
|
|
883
|
+
const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
|
|
884
|
+
const watchBlockers = async (final) => {
|
|
885
|
+
if (!watchable() || cancelled || tripped) return;
|
|
886
|
+
const t0 = now();
|
|
887
|
+
if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
|
|
888
|
+
lastWatch = t0;
|
|
889
|
+
const resolved = new Set();
|
|
890
|
+
const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null));
|
|
891
|
+
for (const b of roots) {
|
|
892
|
+
let g = null;
|
|
893
|
+
try { g = (await dispatchCheck(b)) ?? null; } catch { g = null; }
|
|
894
|
+
if (!g || !SATISFIED.has(g.outcome)) continue;
|
|
895
|
+
resolved.add(b);
|
|
896
|
+
const row = results.get(b);
|
|
897
|
+
if (row) { results.set(b, { ...row, outcome: g.outcome, pr: Number.isInteger(g.pr) ? g.pr : row.pr, detail: g.detail ?? row.detail }); update(results.get(b)); }
|
|
898
|
+
}
|
|
899
|
+
const back = new Set();
|
|
900
|
+
for (let grew = true; grew;) {
|
|
901
|
+
grew = false;
|
|
902
|
+
for (const n of results.keys()) {
|
|
903
|
+
const b = blockerOf(n);
|
|
904
|
+
if (b !== null && !back.has(n) && !readmitted.has(`${n}:${b}`) && (resolved.has(b) || back.has(b))) { back.add(n); grew = true; }
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
for (const n of back) {
|
|
908
|
+
const b = blockerOf(n);
|
|
909
|
+
readmitted.add(`${n}:${b}`);
|
|
910
|
+
results.delete(n);
|
|
911
|
+
pending.push(n);
|
|
912
|
+
decided({ issue: n, what: `re-admitted: dependency #${b} merged or closed after this issue was skipped`, why: "the blocker was finished outside this batch lane",
|
|
913
|
+
alternative: "leave it skipped until the next batch" });
|
|
914
|
+
}
|
|
915
|
+
if (back.size) save();
|
|
916
|
+
};
|
|
814
917
|
const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
|
|
815
918
|
try {
|
|
816
919
|
for (;;) {
|
|
817
920
|
if (signal?.aborted) throw halt();
|
|
818
921
|
await drainAdds();
|
|
922
|
+
await drainRequeues();
|
|
819
923
|
checkCancel();
|
|
924
|
+
await watchBlockers(!pending.length && !running.size);
|
|
820
925
|
if (!pending.length && !running.size) break;
|
|
821
926
|
// Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
|
|
822
|
-
for (let
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
927
|
+
for (let again = true; again;) { // #4595: to a fixpoint, so a chain's tail is skipped on its blocker, not as a "cycle"
|
|
928
|
+
again = false;
|
|
929
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
930
|
+
const n = pending[i];
|
|
931
|
+
if (results.has(n)) { pending.splice(i, 1); continue; }
|
|
932
|
+
const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
|
|
933
|
+
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); again = true; }
|
|
934
|
+
}
|
|
827
935
|
}
|
|
828
936
|
const t = now();
|
|
829
937
|
const holdUntil = heldUntil();
|
|
830
938
|
let started = 0;
|
|
831
939
|
driftHeld.clear();
|
|
940
|
+
opHeldHit.clear();
|
|
941
|
+
refreshHeld();
|
|
832
942
|
const isReady = (n) => {
|
|
943
|
+
if (opHeld.has(n)) { opHeldHit.add(n); return false; }
|
|
833
944
|
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
834
945
|
if (!drift?.holds) return true;
|
|
835
946
|
let pkg = null;
|
|
@@ -898,10 +1009,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
898
1009
|
if (holdUntil) waits.push(holdUntil);
|
|
899
1010
|
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
900
1011
|
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
901
|
-
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
1012
|
+
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
902
1013
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
903
|
-
if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
|
|
1014
|
+
if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1015
|
+
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
|
904
1016
|
if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
|
|
1017
|
+
if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
|
|
905
1018
|
if (tripped) wake = null;
|
|
906
1019
|
if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
|
|
907
1020
|
if (running.size) {
|
|
@@ -911,6 +1024,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
911
1024
|
else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
|
|
912
1025
|
else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
|
|
913
1026
|
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
1027
|
+
else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
|
|
914
1028
|
else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
|
|
915
1029
|
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
916
1030
|
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
@@ -925,7 +1039,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
925
1039
|
if (reg) withdrawExcept(new Set());
|
|
926
1040
|
await relabelQueued();
|
|
927
1041
|
save();
|
|
928
|
-
|
|
1042
|
+
const withRequeue = (r) => (r && requeuedInfo.has(r.issue) ? { ...r, requeued: { reason: requeuedInfo.get(r.issue).reason, by: requeuedInfo.get(r.issue).by, prior: requeuedInfo.get(r.issue).prior.map((p) => ({ outcome: p.outcome, detail: p.detail ?? null, pr: p.pr ?? null })) } } : r);
|
|
1043
|
+
return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
929
1044
|
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
930
1045
|
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
931
1046
|
}
|
|
@@ -954,6 +1069,8 @@ export const OUTCOME_LINE_MAX = 200;
|
|
|
954
1069
|
export function formatOutcomeLists(rows) {
|
|
955
1070
|
const out = [];
|
|
956
1071
|
const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
|
|
1072
|
+
const requeued = (rows ?? []).filter((r) => r?.requeued); // #4550: operator --requeue after a decision; the row is the latest outcome
|
|
1073
|
+
if (requeued.length) out.push(`requeued: ${requeued.map((r) => `#${r.issue} (${one(r.requeued.reason)})`).join(" ")}`);
|
|
957
1074
|
const rec = (rows ?? []).filter((r) => r?.recovered);
|
|
958
1075
|
if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
|
|
959
1076
|
for (const r of rows ?? []) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Named post-deploy checks (#4626). A post-deploy requirement names a check id (`[check:<id>]` in its follow-up item);
|
|
5
|
+
* the command lives in the checks file on the default branch, `.forge/post-deploy-checks.json`:
|
|
6
|
+
* { "<id>": { "run": "sh command", "pass": "exit 0 | /regex/flags", "timeout": 120 } } (a bare string is `run`)
|
|
7
|
+
* and is read with `git show origin/<default>:<file>`: reviewed default-branch code, never an issue body or comment
|
|
8
|
+
* (#4468). When the #4389 gate reports a follow-up ready, `measureFollowup` runs every item's check (no model call, the
|
|
9
|
+
* probe runner's minimal env): all pass -> a FORGE:PROBE record and the follow-up closes; a fail -> the record, and the
|
|
10
|
+
* parent reopens with the output. An item without a runnable check is `awaiting-measurement`: no lane, ever.
|
|
11
|
+
*/
|
|
12
|
+
import { noComments } from "./render.mjs";
|
|
13
|
+
import { MAX_TIMEOUT_S, OUTPUT_TAIL, PROBE_MARKER, probeEnv, probeResultOf, readProbeConfig, runProbe } from "./probe.mjs";
|
|
14
|
+
import { parseMinAge } from "./prefilter.mjs";
|
|
15
|
+
|
|
16
|
+
export const CHECKS_FILE = ".forge/post-deploy-checks.json";
|
|
17
|
+
export const CHECK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
18
|
+
export const AWAITING_MEASUREMENT_LABEL = "awaiting-measurement";
|
|
19
|
+
export const MONITORING_LABEL = "post-deploy-monitoring";
|
|
20
|
+
export const DEFAULT_EVERY_MS = 6 * 3600e3;
|
|
21
|
+
const ITEM_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
22
|
+
const CHECK_TOKEN_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:\s*\[check:\s*([^\]\s]*)\s*\]/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The checks file's runnable entries `{id: {run, pass, timeoutS, forMs?, everyMs?}}`; malformed input or entries are dropped.
|
|
26
|
+
* `for` (a window such as "7d") makes the check windowed (#4627); `every` (default 6h) is its cadence and needs a `for`.
|
|
27
|
+
*/
|
|
28
|
+
export function parseChecks(text) {
|
|
29
|
+
let j;
|
|
30
|
+
try { j = JSON.parse(String(text ?? "")); } catch { return {}; }
|
|
31
|
+
if (!j || typeof j !== "object" || Array.isArray(j)) return {};
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [id, v] of Object.entries(j)) {
|
|
34
|
+
const spec = typeof v === "string" ? { run: v } : v;
|
|
35
|
+
if (!CHECK_ID_RE.test(id) || !spec || typeof spec.run !== "string" || !spec.run.trim()) continue;
|
|
36
|
+
let pass = { kind: "exit" };
|
|
37
|
+
const p = String(spec.pass ?? "").trim();
|
|
38
|
+
if (p && !/^exit\s+0$/i.test(p)) {
|
|
39
|
+
const r = /^\/(.+)\/([a-z]*)$/.exec(p);
|
|
40
|
+
try { if (!r) continue; new RegExp(r[1], r[2]); } catch { continue; }
|
|
41
|
+
pass = { kind: "regex", re: r[1], flags: r[2] };
|
|
42
|
+
}
|
|
43
|
+
const t = spec.timeout == null ? null : Number(spec.timeout);
|
|
44
|
+
if (t != null && !(Number.isFinite(t) && t > 0)) continue;
|
|
45
|
+
const win = spec.for == null ? null : parseMinAge(spec.for);
|
|
46
|
+
const cad = spec.every == null ? null : parseMinAge(spec.every);
|
|
47
|
+
if ((spec.for != null && !win) || (spec.every != null && !cad) || (spec.every != null && spec.for == null)) continue;
|
|
48
|
+
out[id] = { run: spec.run.trim(), pass, timeoutS: t == null ? null : Math.min(Math.floor(t) || 1, MAX_TIMEOUT_S), ...(win ? { forMs: win, everyMs: cad ?? DEFAULT_EVERY_MS } : {}) };
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Per checklist item of a follow-up body: its `[check:<id>]` id, or null when it names none (or a malformed one). */
|
|
54
|
+
export function itemCheckIds(body) {
|
|
55
|
+
return noComments(String(body ?? ""), " ").split("\n").map((l) => ITEM_RE.exec(l)?.[1]).filter((t) => t != null)
|
|
56
|
+
.map((t) => { const m = CHECK_TOKEN_RE.exec(t); return m && CHECK_ID_RE.test(m[1]) ? m[1] : null; });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The checks file at each git ref (`git show <ref>:<file>`); a missing file or ref contributes nothing. */
|
|
60
|
+
export async function checksAt(git, refs, cwd) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const ref of refs) {
|
|
63
|
+
try { Object.assign(out, parseChecks(String(await git(["show", `${ref}:${CHECKS_FILE}`], { cwd })))); } catch { /* no file at this ref */ }
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
|
|
69
|
+
|
|
70
|
+
/** Run each distinct check id with the probe runner (minimal env, timeout, masked output): `[{id, result, exit, ms, output}]`. */
|
|
71
|
+
export async function runNamedChecks({ ids, checks, cwd, config, env, spawn }) {
|
|
72
|
+
const rows = [];
|
|
73
|
+
for (const id of [...new Set(ids)]) {
|
|
74
|
+
const r = await runProbe(checks[id], { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
|
|
75
|
+
rows.push({ id, ...r });
|
|
76
|
+
}
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The windowed-monitoring fields of a FORGE:PROBE record body (`at`, `window_until`, `every_ms`, `regression`, ...), or null. */
|
|
81
|
+
export function monitorRecord(body) {
|
|
82
|
+
const base = probeResultOf(body);
|
|
83
|
+
if (!base) return null;
|
|
84
|
+
const line = String(body).split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
|
|
85
|
+
try {
|
|
86
|
+
const j = JSON.parse(line.trim().replace(/^`|`$/g, ""));
|
|
87
|
+
const at = Date.parse(j.at), until = Date.parse(j.window_until);
|
|
88
|
+
return Number.isFinite(at) ? { ...base, at, defaultSha: j.default_sha ?? null, everyMs: Number(j.every_ms) || DEFAULT_EVERY_MS, until: Number.isFinite(until) ? until : null, regression: j.regression ?? null } : null;
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The post-deploy-monitoring label, created on first use; best effort. */
|
|
93
|
+
export async function addMonitoringLabel({ io, repo, issue }) {
|
|
94
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", MONITORING_LABEL]);
|
|
95
|
+
try { await edit(); } catch {
|
|
96
|
+
try { await io.gh(["label", "create", MONITORING_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up whose windowed check re-runs on later releases. Managed by ForgeDock."]); await edit(); } catch { /* the record still shows it */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Measure a ready post-deploy follow-up. Never runs a command that is not in the default branch's checks file.
|
|
102
|
+
* @returns {Promise<{state:"awaiting-measurement"|"pass"|"fail"|"recorded-fail"|"dry-run"|"monitoring", detail:string}>}
|
|
103
|
+
* When every item's check declares a window (#4627) a pass records the release and the window end and the follow-up stays
|
|
104
|
+
* open (`monitoring`); `sweepDue` (postdeploy-monitor.mjs) re-runs it.
|
|
105
|
+
*/
|
|
106
|
+
export async function measureFollowup({ iss, repo, parent, def, shas = [], cwd, io, dryRun = false, isTrusted = () => false, config = readProbeConfig(cwd), env = process.env, spawn, now = () => Date.now() }) {
|
|
107
|
+
const ids = itemCheckIds(iss.body);
|
|
108
|
+
const checks = await checksAt(io.git, [`origin/${def}`], cwd);
|
|
109
|
+
const missing = ids.filter((id) => !id || !checks[id]);
|
|
110
|
+
if (!ids.length || missing.length) return { state: "awaiting-measurement", detail: `no runnable check on ${def} for ${ids.length ? `${missing.map((m) => m ?? "an item without a check id").join(", ")}` : "any item"} (${CHECKS_FILE})` };
|
|
111
|
+
const fix = [...shas].sort().join(",");
|
|
112
|
+
const prior = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => probeResultOf(c?.body)).filter(Boolean).reverse().find((p) => p.fix === fix);
|
|
113
|
+
if (prior?.result === "fail") return { state: "recorded-fail", detail: `check failed for ${fix.slice(0, 12)} (FORGE:PROBE); waiting for a new fix of #${parent}` };
|
|
114
|
+
const windowed = ids.every((id) => checks[id].forMs);
|
|
115
|
+
const monitored = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => monitorRecord(c?.body)).filter(Boolean).find((p) => p.fix === fix && p.result === "pass" && p.until);
|
|
116
|
+
if (windowed && monitored) return { state: "monitoring", detail: `windowed checks ${[...new Set(ids)].join(", ")} monitored until ${new Date(monitored.until).toISOString()}` };
|
|
117
|
+
if (dryRun) return { state: "dry-run", detail: `checks ${[...new Set(ids)].join(", ")} not run (dry run)` };
|
|
118
|
+
let defaultSha = null;
|
|
119
|
+
try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
|
|
120
|
+
const rows = [];
|
|
121
|
+
if (prior?.result !== "pass") {
|
|
122
|
+
rows.push(...await runNamedChecks({ ids, checks, cwd, config, env, spawn }));
|
|
123
|
+
const ok = rows.every((r) => r.result === "pass");
|
|
124
|
+
const at = now();
|
|
125
|
+
const win = windowed && ok ? { at: new Date(at).toISOString(), window_until: new Date(at + Math.max(...ids.map((id) => checks[id].forMs))).toISOString(), every_ms: Math.min(...ids.map((id) => checks[id].everyMs)) } : {};
|
|
126
|
+
const body = [
|
|
127
|
+
`<!-- ${PROBE_MARKER} -->`, `## Post-deploy check: ${ok ? "pass" : "fail"}`,
|
|
128
|
+
JSON.stringify({ fix, default_sha: defaultSha, result: ok ? "pass" : "fail", ...win, checks: rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms })) }), "",
|
|
129
|
+
`Ran after the fix of #${parent} reached \`${def}\`; commands come from \`${CHECKS_FILE}\` on \`${def}\`.${ok ? (win.at ? ` Monitored until \`${win.window_until}\`, re-run on later releases.` : " The follow-up is closed.") : " The parent is reopened."}`,
|
|
130
|
+
...rows.filter((r) => r.result === "fail").flatMap((r) => ["", `**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output)]),
|
|
131
|
+
].join("\n").slice(0, OUTPUT_TAIL * 4);
|
|
132
|
+
await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
|
|
133
|
+
if (!ok) {
|
|
134
|
+
const bad = rows.filter((r) => r.result === "fail");
|
|
135
|
+
let note = "";
|
|
136
|
+
try { await io.gh(["issue", "reopen", String(parent), "-R", repo]); } catch (e) { note = ` (reopen failed: ${String(e?.message ?? e).slice(0, 120)})`; }
|
|
137
|
+
try {
|
|
138
|
+
await io.gh(["issue", "comment", String(parent), "-R", repo, "--body", `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} failed for the fix on \`${def}\` (follow-up #${iss.number}); reopened.\n\n${bad.map((r) => fenced(r.output)).join("\n\n")}`.slice(0, OUTPUT_TAIL * 4)]);
|
|
139
|
+
} catch { /* the follow-up comment carries the output */ }
|
|
140
|
+
return { state: "fail", detail: `check ${bad.map((r) => r.id).join(", ")} failed; #${parent} reopened${note}` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (windowed) {
|
|
144
|
+
await addMonitoringLabel({ io, repo, issue: iss.number });
|
|
145
|
+
return { state: "monitoring", detail: `checks ${[...new Set(ids)].join(", ")} passed; monitored on later releases` };
|
|
146
|
+
}
|
|
147
|
+
await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]);
|
|
148
|
+
return { state: "pass", detail: `checks ${[...new Set(ids)].join(", ")} passed` };
|
|
149
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 { parseChecks, itemCheckIds, measureFollowup, monitorRecord, CHECK_ID_RE } from "./postdeploy-check.mjs";
|
|
6
|
+
import { postDeployOnly } from "./prefilter.mjs";
|
|
7
|
+
import { validateContract, applyAmendments, precheck, followupItems } from "./conformance.mjs";
|
|
8
|
+
import { PROBE_MARKER } from "./probe.mjs";
|
|
9
|
+
|
|
10
|
+
const item = (id, why) => `- [ ] \`${id}: some quote here\` — post-deploy: ${why}`;
|
|
11
|
+
const body = (items) => `## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
|
|
12
|
+
const CHECKS = JSON.stringify({ smoke: { run: "echo ok", timeout: 5 }, bad: "echo nope", "bad id!": "x", rx: { run: "echo up", pass: "/up/" }, norun: {} });
|
|
13
|
+
|
|
14
|
+
function world({ file = CHECKS, spawn } = {}) {
|
|
15
|
+
const w = { gh: [], git: [], spawned: [] };
|
|
16
|
+
w.io = {
|
|
17
|
+
gh: async (a) => { w.gh.push(a); return ""; },
|
|
18
|
+
git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } return "deadbeef\n"; },
|
|
19
|
+
};
|
|
20
|
+
w.spawn = async (o) => { w.spawned.push(o.run); return spawn ? spawn(o) : { code: o.run === "echo nope" ? 1 : 0, stdout: o.run === "echo nope" ? "boom" : "ok", stderr: "" }; };
|
|
21
|
+
return w;
|
|
22
|
+
}
|
|
23
|
+
const run = (w, bodyText, extra = {}) => measureFollowup({ iss: { number: 41, body: bodyText, comments: [] }, repo: "o/r", parent: 40, def: "main", shas: ["abc"], cwd: "/repo", io: w.io, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...extra });
|
|
24
|
+
|
|
25
|
+
describe("post-deploy named checks (#4626)", () => {
|
|
26
|
+
it("parses the checks file; malformed entries are dropped", () => {
|
|
27
|
+
const c = parseChecks(CHECKS);
|
|
28
|
+
assert.deepEqual(Object.keys(c).sort(), ["bad", "rx", "smoke"]);
|
|
29
|
+
assert.equal(c.smoke.timeoutS, 5); assert.equal(c.rx.pass.kind, "regex");
|
|
30
|
+
assert.deepEqual(parseChecks("not json"), {});
|
|
31
|
+
assert.ok(CHECK_ID_RE.test("login-smoke") && !CHECK_ID_RE.test("Bad Id"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("postDeployOnly still matches items with and without a [check:id] token", () => {
|
|
35
|
+
assert.ok(postDeployOnly(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])));
|
|
36
|
+
});
|
|
37
|
+
it("extracts [check:id] per item; an item without one yields null", () => {
|
|
38
|
+
assert.deepEqual(itemCheckIds(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])), ["smoke", null]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("pass: runs the default-branch command, records FORGE:PROBE, closes the follow-up, no reopen", async () => {
|
|
42
|
+
const w = world();
|
|
43
|
+
const r = await run(w, body([item("R1", "[check:smoke] x")]));
|
|
44
|
+
assert.equal(r.state, "pass");
|
|
45
|
+
assert.deepEqual(w.spawned, ["echo ok"]);
|
|
46
|
+
assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
|
|
47
|
+
const cm = w.gh.find((a) => a[1] === "comment");
|
|
48
|
+
assert.match(cm[cm.indexOf("--body") + 1], new RegExp(PROBE_MARKER));
|
|
49
|
+
assert.ok(w.gh.some((a) => a[1] === "close" && a[2] === "41"));
|
|
50
|
+
assert.ok(!w.gh.some((a) => a[1] === "reopen"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("fail: reopens the parent with the output and does not close", async () => {
|
|
54
|
+
const w = world();
|
|
55
|
+
const r = await run(w, body([item("R1", "[check:bad] x")]));
|
|
56
|
+
assert.equal(r.state, "fail");
|
|
57
|
+
assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
|
|
58
|
+
assert.ok(w.gh.some((a) => a[1] === "comment" && a[2] === "40" && a[a.indexOf("--body") + 1].includes("boom")));
|
|
59
|
+
assert.ok(!w.gh.some((a) => a[1] === "close"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("no runnable check: awaiting-measurement, nothing runs, no model call", async () => {
|
|
63
|
+
for (const [file, b] of [[CHECKS, body([item("R1", "[check:ghost] x")])], [null, body([item("R1", "[check:smoke] x")])], [CHECKS, body([item("R1", "x"), item("R2", "[check:smoke] y")])]]) {
|
|
64
|
+
const w = world({ file });
|
|
65
|
+
assert.equal((await run(w, b)).state, "awaiting-measurement");
|
|
66
|
+
assert.deepEqual(w.spawned, []); assert.deepEqual(w.gh, []);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a command declared in the issue body never runs", async () => {
|
|
71
|
+
const w = world();
|
|
72
|
+
const b = body([item("R1", "[check:ghost] x")]) + "\n```forge-probe\nrun: rm -rf /\n```\n";
|
|
73
|
+
assert.equal((await run(w, b)).state, "awaiting-measurement");
|
|
74
|
+
assert.deepEqual(w.spawned, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("migration: the six open follow-up shapes (no check id) all park awaiting-measurement", async () => {
|
|
78
|
+
for (const n of [4415, 4421, 4438, 4488, 4495, 4498]) {
|
|
79
|
+
const w = world();
|
|
80
|
+
const r = await run(w, body([item(`R${n % 7}`, "only checkable after the next deploy")]));
|
|
81
|
+
assert.equal(r.state, "awaiting-measurement", `#${n}`);
|
|
82
|
+
assert.deepEqual(w.spawned, []);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a recorded fail for the same fix is not re-run", async () => {
|
|
87
|
+
const w = world();
|
|
88
|
+
const c = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "fail" })}` };
|
|
89
|
+
const r = await run(w, body([item("R1", "[check:smoke] x")]), { iss: { number: 41, body: body([item("R1", "[check:smoke] x")]), comments: [c] }, isTrusted: () => true });
|
|
90
|
+
assert.equal(r.state, "recorded-fail"); assert.deepEqual(w.spawned, []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("contract: post-deploy needs a check id; amendment too; precheck rejects an unknown id; followup item names it", () => {
|
|
94
|
+
const Q = "The deploy-time health row must exist after promotion.";
|
|
95
|
+
const c = { requirements: [{ id: "R1", quote: Q, disposition: "post-deploy", why: "w" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] };
|
|
96
|
+
assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
|
|
97
|
+
assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
|
|
98
|
+
const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
|
|
99
|
+
assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
|
|
100
|
+
const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
|
|
101
|
+
assert.equal(ok.contract.requirements[0].check, "smoke");
|
|
102
|
+
const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
|
|
103
|
+
assert.ok(pc.failures.some((f) => f.kind === "check_unknown"));
|
|
104
|
+
assert.match(followupItems([{ ...c.requirements[0], check: "smoke" }])[0].why, /^post-deploy: \[check:smoke\] w/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("windowed post-deploy checks (#4627)", () => {
|
|
109
|
+
const WIN = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }, soakd: { run: "echo ok", for: "2d" }, noFor: { run: "echo ok", every: "6h" }, badFor: { run: "echo ok", for: "soon" }, zero: { run: "echo ok", for: "0" }, plain: "echo ok" });
|
|
110
|
+
const NOW = Date.parse("2026-10-26T00:00:00Z");
|
|
111
|
+
it("parses for/every; every defaults to 6h; every without for or a bad duration drops the entry", () => {
|
|
112
|
+
const c = parseChecks(WIN);
|
|
113
|
+
assert.deepEqual(Object.keys(c).sort(), ["plain", "soak", "soakd"]);
|
|
114
|
+
assert.equal(c.soak.forMs, 7 * 86400e3); assert.equal(c.soak.everyMs, 6 * 3600e3);
|
|
115
|
+
assert.equal(c.soakd.everyMs, 6 * 3600e3);
|
|
116
|
+
assert.equal(c.plain.forMs, undefined);
|
|
117
|
+
});
|
|
118
|
+
it("first pass records the release and window, labels, and keeps the follow-up open", async () => {
|
|
119
|
+
const w = world({ file: WIN });
|
|
120
|
+
const r = await run(w, body([item("R1", "[check:soak] x")]), { now: () => NOW });
|
|
121
|
+
assert.equal(r.state, "monitoring");
|
|
122
|
+
assert.ok(!w.gh.some((a) => a[1] === "close") && !w.gh.some((a) => a[1] === "reopen"));
|
|
123
|
+
assert.ok(w.gh.some((a) => a[1] === "edit" && a.includes("post-deploy-monitoring")));
|
|
124
|
+
const cm = w.gh.find((a) => a[1] === "comment");
|
|
125
|
+
const rec = monitorRecord(cm[cm.indexOf("--body") + 1]);
|
|
126
|
+
assert.equal(rec.result, "pass"); assert.equal(rec.until, NOW + 7 * 86400e3); assert.equal(rec.everyMs, 6 * 3600e3); assert.equal(rec.defaultSha, "deadbeef");
|
|
127
|
+
});
|
|
128
|
+
it("a recorded windowed pass is not re-measured by the pre-filter path", async () => {
|
|
129
|
+
const w = world({ file: WIN });
|
|
130
|
+
const rec = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "pass", at: new Date(NOW).toISOString(), window_until: new Date(NOW + 1e9).toISOString() })}` };
|
|
131
|
+
const b = body([item("R1", "[check:soak] x")]);
|
|
132
|
+
const r = await run(w, b, { iss: { number: 41, body: b, comments: [rec] }, isTrusted: () => true });
|
|
133
|
+
assert.equal(r.state, "monitoring"); assert.deepEqual(w.spawned, []);
|
|
134
|
+
});
|
|
135
|
+
it("a non-windowed check still closes on pass (part 1 unchanged)", async () => {
|
|
136
|
+
const w = world({ file: WIN });
|
|
137
|
+
assert.equal((await run(w, body([item("R1", "[check:plain] x")]))).state, "pass");
|
|
138
|
+
assert.ok(w.gh.some((a) => a[1] === "close"));
|
|
139
|
+
});
|
|
140
|
+
it("a first-run fail of a windowed check reopens the parent as before", async () => {
|
|
141
|
+
const w = world({ file: JSON.stringify({ soak: { run: "echo nope", for: "7d" } }), spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
|
|
142
|
+
assert.equal((await run(w, body([item("R1", "[check:soak] x")]))).state, "fail");
|
|
143
|
+
assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|