forgedock 1.35.0 → 1.37.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/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4651: sign-off rewrite of a lane's own unpushed commits, on a real temp git repo (bare origin + clone).
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { ensureSignedOff, needsSignoff, offendersOf } from "./signoff.mjs";
|
|
11
|
+
|
|
12
|
+
const CLEAN = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
|
|
13
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...CLEAN, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }).trim();
|
|
14
|
+
const PLACEHOLDER = ["-c", "user.name=forgedock", "-c", "user.email=forgedock@localhost"];
|
|
15
|
+
|
|
16
|
+
function fixture() {
|
|
17
|
+
const root = tmpDir("signoff-"), origin = join(root, "origin.git"), wt = join(root, "wt");
|
|
18
|
+
sh(root, "init", "--bare", "-b", "staging", origin);
|
|
19
|
+
sh(root, "clone", origin, wt);
|
|
20
|
+
sh(wt, "config", "user.name", "Lane Bot"); sh(wt, "config", "user.email", "lane@example.com");
|
|
21
|
+
const commit = (file, msg, ...flags) => { writeFileSync(join(wt, file), `${file}\n`); sh(wt, "add", file); sh(wt, ...(flags.includes("placeholder") ? PLACEHOLDER : []), "commit", "-q", "-m", msg, ...flags.filter((f) => f !== "placeholder")); return sh(wt, "rev-parse", "HEAD"); };
|
|
22
|
+
commit("base.txt", "base");
|
|
23
|
+
sh(wt, "push", "-q", "origin", "HEAD:staging");
|
|
24
|
+
sh(wt, "checkout", "-q", "-b", "feat/x");
|
|
25
|
+
const git = async (a, o = {}) => execFileSync("git", a, { cwd: wt, encoding: "utf8", env: { ...CLEAN, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", ...(o.env ?? {}) } }).trim();
|
|
26
|
+
return { wt, commit, git, sh: (...a) => sh(wt, ...a) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("needsSignoff / offendersOf", () => {
|
|
30
|
+
it("flags placeholder authors and missing or mismatched sign-offs", () => {
|
|
31
|
+
assert.equal(needsSignoff({ authorEmail: "a@x.io", committerEmail: "a@x.io", signedOff: ["A <a@x.io>"] }), false);
|
|
32
|
+
assert.equal(needsSignoff({ authorEmail: "a@x.io", committerEmail: "a@x.io", signedOff: [] }), true);
|
|
33
|
+
assert.equal(needsSignoff({ authorEmail: "a@x.io", committerEmail: "a@x.io", signedOff: ["B <b@x.io>"] }), true);
|
|
34
|
+
assert.equal(needsSignoff({ authorEmail: "forgedock@localhost", committerEmail: "forgedock@localhost", signedOff: ["F <forgedock@localhost>"] }), true);
|
|
35
|
+
assert.deepEqual(offendersOf(""), []);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("ensureSignedOff", () => {
|
|
40
|
+
it("rewrites unpushed placeholder-author commits and leaves pushed commits alone", async () => {
|
|
41
|
+
const f = fixture();
|
|
42
|
+
const pushed = f.commit("a.txt", "pushed unsigned", "placeholder");
|
|
43
|
+
f.sh("push", "-q", "origin", "feat/x");
|
|
44
|
+
const bad = f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
45
|
+
const unsigned = f.commit("c.txt", "unpushed unsigned");
|
|
46
|
+
const good = f.commit("d.txt", "unpushed signed", "--signoff");
|
|
47
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
48
|
+
assert.equal(r.rewritten, 2);
|
|
49
|
+
const rows = f.sh("log", "--format=%H|%ae|%(trailers:key=Signed-off-by,valueonly,separator=;)", "origin/feat/x..HEAD").split("\n").map((l) => l.split("|"));
|
|
50
|
+
assert.equal(rows.length, 3);
|
|
51
|
+
for (const [, email, so] of rows) { assert.equal(email, "lane@example.com"); assert.match(so, /Lane Bot <lane@example\.com>/); }
|
|
52
|
+
const shas = rows.map((x) => x[0]);
|
|
53
|
+
assert.ok(!shas.includes(bad) && !shas.includes(unsigned), "originals are gone from the pushed range");
|
|
54
|
+
assert.equal(f.sh("rev-parse", "origin/feat/x"), pushed);
|
|
55
|
+
assert.equal(f.sh("merge-base", "--is-ancestor", pushed, "HEAD") ?? "", "");
|
|
56
|
+
assert.equal(f.sh("log", "-1", "--format=%ae", pushed), "forgedock@localhost"); // the pushed commit itself is untouched
|
|
57
|
+
assert.equal(f.sh("rev-parse", "HEAD~3"), pushed);
|
|
58
|
+
assert.equal(f.sh("show", "-s", "--format=%an", "HEAD"), "Lane Bot"); // the already-signed one is authored by the lane here, so it is unchanged
|
|
59
|
+
assert.ok(good);
|
|
60
|
+
});
|
|
61
|
+
it("never rewrites commits already on origin/<lane>, nor a clean range", async () => {
|
|
62
|
+
const f = fixture();
|
|
63
|
+
f.sh("checkout", "-q", "staging");
|
|
64
|
+
const onLane = f.commit("l.txt", "lane commit unsigned", "placeholder");
|
|
65
|
+
f.sh("push", "-q", "origin", "staging");
|
|
66
|
+
f.sh("checkout", "-q", "feat/x"); f.sh("merge", "-q", "--no-edit", "staging");
|
|
67
|
+
f.commit("m.txt", "mine", "--signoff");
|
|
68
|
+
const before = f.sh("rev-parse", "HEAD");
|
|
69
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
70
|
+
assert.equal(r.rewritten, 0);
|
|
71
|
+
assert.equal(f.sh("rev-parse", "HEAD"), before);
|
|
72
|
+
assert.equal(f.sh("merge-base", "--is-ancestor", onLane, "HEAD") ?? "", "");
|
|
73
|
+
});
|
|
74
|
+
it("rewrites with a merge commit in the range and keeps lane commits intact", async () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
f.sh("checkout", "-q", "staging");
|
|
77
|
+
const onLane = f.commit("l.txt", "lane commit", "placeholder");
|
|
78
|
+
f.sh("push", "-q", "origin", "staging");
|
|
79
|
+
f.sh("checkout", "-q", "feat/x");
|
|
80
|
+
f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
81
|
+
f.sh("merge", "-q", "--no-edit", "staging");
|
|
82
|
+
const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
|
|
83
|
+
assert.equal(r.rewritten, 1);
|
|
84
|
+
assert.equal(f.sh("merge-base", "--is-ancestor", onLane, "HEAD") ?? "", "");
|
|
85
|
+
assert.equal(f.sh("log", "--format=%ae", "origin/staging..HEAD", "--no-merges"), "lane@example.com");
|
|
86
|
+
assert.equal(f.sh("log", "-1", "--format=%ae", onLane), "forgedock@localhost");
|
|
87
|
+
});
|
|
88
|
+
it("uses GIT_AUTHOR_* from the env over the worktree config (sandbox lanes)", async () => {
|
|
89
|
+
const f = fixture();
|
|
90
|
+
f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
91
|
+
await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: { GIT_AUTHOR_NAME: "Host Dev", GIT_AUTHOR_EMAIL: "host@example.com" } });
|
|
92
|
+
assert.equal(f.sh("log", "-1", "--format=%ae|%an"), "host@example.com|Host Dev");
|
|
93
|
+
assert.match(f.sh("log", "-1", "--format=%(trailers:key=Signed-off-by,valueonly)"), /Host Dev <host@example\.com>/);
|
|
94
|
+
});
|
|
95
|
+
it("throws noIdentity (and rewrites nothing) when the worktree has no usable identity", async () => {
|
|
96
|
+
const f = fixture();
|
|
97
|
+
const bad = f.commit("b.txt", "unpushed placeholder", "placeholder");
|
|
98
|
+
f.sh("config", "user.email", "forgedock@localhost");
|
|
99
|
+
await assert.rejects(ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.noIdentity === true);
|
|
100
|
+
assert.equal(f.sh("rev-parse", "HEAD"), bad);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
|
|
5
|
+
*
|
|
6
|
+
* One durable state file `<batchRoot>/trains/<id>/state.json` (tmp + fsync + rename, like a batch) holds the order, the
|
|
7
|
+
* active set, each milestone's status and lifecycle state (#4657) and the typed STOP, if any. Deterministic, 0 LLM calls.
|
|
8
|
+
* Each milestone runs as one orchestrate batch; its lanes resolve their base from the issue's milestone (lane.mjs), so
|
|
9
|
+
* the train never steers them through `branches.staging` and never writes forge.yaml. After a batch the lifecycle seam
|
|
10
|
+
* (`onBatchEnd`) records `shipped:staging` and closes at `milestones.close_on`. A slug is a milestone title.
|
|
11
|
+
* Caps have one source, forge.yaml `orchestration.global_parallel` (see `resolveCaps`); a conflicting override is ignored.
|
|
12
|
+
* Order is defined once, in the state; `insertMilestone` is the only way to add one and re-runs dependency validation.
|
|
13
|
+
*/
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { writeAtomic } from "./batch.mjs";
|
|
17
|
+
import { classifyEdges } from "../../scripts/plan-validate-live.mjs";
|
|
18
|
+
|
|
19
|
+
export const STOP_REASONS = ["dependency-unmet", "needs-human", "breaker", "budget", "cancelled", "milestone-open", "error"];
|
|
20
|
+
export const DEFAULT_LANE_PARALLEL = 3;
|
|
21
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
|
|
23
|
+
export const trainRoot = (batchRoot) => join(batchRoot, "trains");
|
|
24
|
+
export const newTrainId = (now = Date.now()) => `train-${new Date(now).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${process.pid}`;
|
|
25
|
+
|
|
26
|
+
/** Handle on one train's state file. */
|
|
27
|
+
export function openTrain(root, id) {
|
|
28
|
+
if (!ID_RE.test(String(id))) throw new Error(`invalid train id: ${id}`);
|
|
29
|
+
const file = join(trainRoot(root), id, "state.json");
|
|
30
|
+
return {
|
|
31
|
+
id, file,
|
|
32
|
+
exists: () => existsSync(file),
|
|
33
|
+
load() { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } },
|
|
34
|
+
save(state) { writeAtomic(file, `${JSON.stringify(state, null, 2)}\n`); },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The only cap source is `orchestration.global_parallel` (host-wide lane cap, shared with `forgedock sched cap`); unset
|
|
40
|
+
* it is DEFAULT_LANE_PARALLEL. A `requested` lane cap (`--parallel`) that differs is ignored with a warning.
|
|
41
|
+
* Active milestones split the cap, at least 1 lane each.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveCaps({ requested = null, globalParallel = null, parallelMilestones = 1 }) {
|
|
44
|
+
const cap = globalParallel ?? DEFAULT_LANE_PARALLEL;
|
|
45
|
+
const warnings = [];
|
|
46
|
+
if (requested != null && requested !== cap) warnings.push(`train: --parallel ${requested} conflicts with the lane cap ${cap} (${globalParallel ? "orchestration.global_parallel" : "default"}); ignored`);
|
|
47
|
+
return { cap, parallelMilestones, perMilestone: Math.max(1, Math.floor(cap / parallelMilestones)), warnings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Throws when `order` puts a milestone before one its issues depend on (`live`: {issues} as plan-validate-live reads them). */
|
|
51
|
+
export function validateOrder(order, live) {
|
|
52
|
+
const { ok, edges } = classifyEdges({ order, issues: live.issues });
|
|
53
|
+
if (!ok) {
|
|
54
|
+
const bad = edges.filter((e) => e.state === "invalid").map((e) => `#${e.from} (${e.fromMilestone}) depends on #${e.to} (${e.toMilestone}), which is later in the train`);
|
|
55
|
+
throw new Error(`train order invalid: ${bad.join("; ")}`);
|
|
56
|
+
}
|
|
57
|
+
return edges;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createTrain({ slugs, parallelMilestones = 1, repo = null, live, now = () => new Date().toISOString() }) {
|
|
61
|
+
const order = [...new Set(slugs)];
|
|
62
|
+
if (!order.length) throw new Error("train needs at least one milestone slug");
|
|
63
|
+
if (!Number.isInteger(parallelMilestones) || parallelMilestones < 1) throw new Error("--parallel-milestones must be a positive integer");
|
|
64
|
+
if (live) validateOrder(order, live);
|
|
65
|
+
return { v: 1, repo, created: now(), parallelMilestones, order, active: [], stop: null,
|
|
66
|
+
milestones: Object.fromEntries(order.map((s) => [s, { status: "pending", lifecycle: null, batch: null }])) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Insert `slug` at the end, or before/after another; re-validates dependencies and leaves the state unchanged on a refusal. */
|
|
70
|
+
export function insertMilestone(state, slug, { before = null, after = null, live = null } = {}) {
|
|
71
|
+
if (state.milestones[slug]) throw new Error(`milestone ${slug} is already in the train`);
|
|
72
|
+
const anchor = before ?? after;
|
|
73
|
+
if (anchor && !state.milestones[anchor]) throw new Error(`milestone ${anchor} is not in the train`);
|
|
74
|
+
const order = [...state.order];
|
|
75
|
+
order.splice(anchor ? order.indexOf(anchor) + (after ? 1 : 0) : order.length, 0, slug);
|
|
76
|
+
if (live) validateOrder(order, live);
|
|
77
|
+
return { ...state, order, milestones: { ...state.milestones, [slug]: { status: "pending", lifecycle: null, batch: null } } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A milestone is ready when every earlier-in-train milestone its issues depend on is done (shipped to staging or later). */
|
|
81
|
+
export function readyMilestones(state, live) {
|
|
82
|
+
const edges = live ? classifyEdges({ order: state.order, issues: live.issues }).edges : [];
|
|
83
|
+
return state.order.filter((s) => state.milestones[s].status === "pending"
|
|
84
|
+
&& edges.every((e) => e.fromMilestone !== s || state.milestones[e.toMilestone]?.status === "done"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Typed STOP reason of a failed milestone run: its rows' outcomes, else `error`. */
|
|
88
|
+
export function stopOf(result) {
|
|
89
|
+
const outcomes = (result?.rows ?? []).map((r) => String(r.outcome ?? ""));
|
|
90
|
+
const has = (re) => outcomes.find((o) => re.test(o));
|
|
91
|
+
if (has(/^dependency-/)) return "dependency-unmet";
|
|
92
|
+
if (has(/^needs-human/)) return "needs-human";
|
|
93
|
+
if (has(/breaker/)) return "breaker";
|
|
94
|
+
if (has(/budget/)) return "budget";
|
|
95
|
+
if (has(/cancel/)) return "cancelled";
|
|
96
|
+
return "error";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run the train until done or a STOP. `runMilestone(slug, {parallel, resume})` -> {ok, batch?, rows?, stop?}; `onBatchEnd([slug])`
|
|
101
|
+
* is the #4657 lifecycle seam. Saves after every transition. A resume (state loaded from disk) clears the STOP and re-runs
|
|
102
|
+
* milestones left `running` through their batch (`resume`). Never writes forge.yaml.
|
|
103
|
+
* @returns {Promise<{ok: boolean, state: object}>}
|
|
104
|
+
*/
|
|
105
|
+
export async function runTrain({ train, state, runMilestone, onBatchEnd = async () => [], readLive = async () => null, caps, now = () => new Date().toISOString(), log = () => {} }) {
|
|
106
|
+
state = { ...state, stop: null, active: [], milestones: { ...state.milestones } };
|
|
107
|
+
for (const [s, m] of Object.entries(state.milestones)) if (m.status === "running" || m.status === "stopped") state.milestones[s] = { ...m, status: "pending", resume: m.batch };
|
|
108
|
+
const save = () => train.save(state);
|
|
109
|
+
const set = (slug, patch) => { state.milestones[slug] = { ...state.milestones[slug], ...patch }; };
|
|
110
|
+
const running = new Map();
|
|
111
|
+
const finish = async (slug) => {
|
|
112
|
+
let r;
|
|
113
|
+
try { r = await runMilestone(slug, { parallel: caps.perMilestone, resume: state.milestones[slug].resume ?? null, onBatch: (b) => { set(slug, { batch: b }); save(); } }); } catch (e) { r = { ok: false, stop: "error", detail: e?.message ?? String(e) }; }
|
|
114
|
+
state.active = state.active.filter((s) => s !== slug);
|
|
115
|
+
if (!r?.ok) { set(slug, { status: "stopped", batch: r?.batch ?? state.milestones[slug].batch }); state.stop ??= { reason: STOP_REASONS.includes(r?.stop) ? r.stop : stopOf(r), milestone: slug, detail: r?.detail ?? "", at: now() }; return save(); }
|
|
116
|
+
let life = null;
|
|
117
|
+
try { life = (await onBatchEnd([slug]))?.find((x) => x.title === slug) ?? null; } catch (e) { log(`train: lifecycle for ${slug}: ${e?.message ?? e}`); }
|
|
118
|
+
if (life && !["shipped:staging", "deployed:default", "closed"].includes(life.state)) {
|
|
119
|
+
set(slug, { status: "stopped", lifecycle: life.state });
|
|
120
|
+
state.stop ??= { reason: "milestone-open", milestone: slug, detail: "open issues remain after the batch", at: now() };
|
|
121
|
+
} else set(slug, { status: "done", lifecycle: life?.state ?? "shipped:staging", batch: r.batch ?? state.milestones[slug].batch });
|
|
122
|
+
save();
|
|
123
|
+
};
|
|
124
|
+
save();
|
|
125
|
+
for (;;) {
|
|
126
|
+
while (!state.stop && running.size < state.parallelMilestones) {
|
|
127
|
+
const next = readyMilestones(state, await readLive()).find((s) => !running.has(s));
|
|
128
|
+
if (!next) break;
|
|
129
|
+
set(next, { status: "running" }); state.active.push(next); save();
|
|
130
|
+
log(`train: starting ${next}`);
|
|
131
|
+
running.set(next, finish(next).finally(() => running.delete(next)));
|
|
132
|
+
}
|
|
133
|
+
if (!running.size) break;
|
|
134
|
+
await Promise.race(running.values());
|
|
135
|
+
}
|
|
136
|
+
const pending = state.order.filter((s) => state.milestones[s].status === "pending");
|
|
137
|
+
if (!state.stop && pending.length) { state.stop = { reason: "dependency-unmet", milestone: pending[0], detail: `waiting on an earlier milestone: ${pending.join(", ")}`, at: now() }; save(); }
|
|
138
|
+
return { ok: !state.stop, state };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const valueOf = (argv, name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
|
|
142
|
+
const intOf = (v, name) => { if (v == null) return null; const n = Number(v); if (!Number.isInteger(n) || n < 1) throw new Error(`${name} must be a positive integer`); return n; };
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `forgedock milestone train <slug...> [--parallel-milestones N] [--parallel K]` | `--resume <id>` | `--insert <id> <slug> [--before s|--after s]` | `--show <id>`.
|
|
146
|
+
* deps: {root, readLive, runMilestone, onBatchEnd, globalParallel, repo, print}. Resolves {ok, id, state}.
|
|
147
|
+
*/
|
|
148
|
+
export async function trainCli(argv, deps) {
|
|
149
|
+
const { root, readLive, print = (l) => console.log(l) } = deps;
|
|
150
|
+
const usage = "usage: forgedock milestone train <slug...> [--parallel-milestones N] [--parallel K] [--repo owner/repo] | --resume <id> | --insert <id> <slug> [--before <slug>|--after <slug>] | --show <id>";
|
|
151
|
+
const valued = new Set(["--parallel-milestones", "--parallel", "--repo", "--resume", "--before", "--after"]);
|
|
152
|
+
const operands = []; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--insert" || argv[i] === "--show") continue; if (valued.has(argv[i])) i++; else if (!argv[i].startsWith("--")) operands.push(argv[i]); }
|
|
153
|
+
const open = (id) => { const t = openTrain(root, id); const state = t.load(); if (!state) throw new Error(`no train ${id} in ${trainRoot(root)}`); return { t, state }; };
|
|
154
|
+
if (argv.includes("--show")) { const id = valueOf(argv, "--show"); const { state } = open(id); print(`train ${id}: ${state.order.map((s) => `${s}=${state.milestones[s].status}${state.milestones[s].lifecycle ? `(${state.milestones[s].lifecycle})` : ""}`).join(", ")}${state.stop ? `; STOP ${state.stop.reason} at ${state.stop.milestone}` : ""}`); return { ok: !state.stop, id, state }; }
|
|
155
|
+
if (argv.includes("--insert")) {
|
|
156
|
+
const id = valueOf(argv, "--insert"); const { t, state } = open(id); const slug = operands[1]; // operands[0] is the train id
|
|
157
|
+
if (!slug) throw new Error(usage);
|
|
158
|
+
const next = insertMilestone(state, slug, { before: valueOf(argv, "--before"), after: valueOf(argv, "--after"), live: await readLive() });
|
|
159
|
+
t.save(next); print(`train ${id}: inserted ${slug}; order: ${next.order.join(", ")}`); return { ok: true, id, state: next };
|
|
160
|
+
}
|
|
161
|
+
let train, state, id;
|
|
162
|
+
const pm = intOf(valueOf(argv, "--parallel-milestones"), "--parallel-milestones");
|
|
163
|
+
if (argv.includes("--resume")) { id = valueOf(argv, "--resume"); ({ t: train, state } = open(id)); }
|
|
164
|
+
else {
|
|
165
|
+
if (!operands.length) throw new Error(usage);
|
|
166
|
+
id = deps.id ?? newTrainId(); train = openTrain(root, id);
|
|
167
|
+
if (train.exists()) throw new Error(`train ${id} already exists`);
|
|
168
|
+
state = createTrain({ slugs: operands, parallelMilestones: pm ?? 1, repo: deps.repo ?? null, live: await readLive() });
|
|
169
|
+
}
|
|
170
|
+
const caps = resolveCaps({ requested: intOf(valueOf(argv, "--parallel"), "--parallel"), globalParallel: deps.globalParallel ?? null, parallelMilestones: pm ?? state.parallelMilestones });
|
|
171
|
+
for (const w of caps.warnings) print(w);
|
|
172
|
+
if (pm && argv.includes("--resume")) state = { ...state, parallelMilestones: pm };
|
|
173
|
+
print(`train: ${id} (${train.file}); resume with: forgedock milestone train --resume ${id}`);
|
|
174
|
+
const r = await runTrain({ train, state, runMilestone: deps.runMilestone, onBatchEnd: deps.onBatchEnd, readLive, caps, log: print });
|
|
175
|
+
print(r.ok ? `train ${id}: done` : `train ${id}: STOP ${r.state.stop.reason} at ${r.state.stop.milestone}${r.state.stop.detail ? ` (${r.state.stop.detail})` : ""}; resume with: forgedock milestone train --resume ${id}`);
|
|
176
|
+
return { ok: r.ok, id, state: r.state };
|
|
177
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { openTrain, createTrain, insertMilestone, resolveCaps, runTrain, stopOf, trainCli, STOP_REASONS } from "./train.mjs";
|
|
9
|
+
|
|
10
|
+
const live = { issues: [
|
|
11
|
+
{ number: 1, milestone: "m1", state: "OPEN", body: "" },
|
|
12
|
+
{ number: 2, milestone: "m2", state: "OPEN", body: "Depends on #1" },
|
|
13
|
+
{ number: 3, milestone: "m3", state: "OPEN", body: "" },
|
|
14
|
+
] };
|
|
15
|
+
const tmp = () => tmpDir("train-");
|
|
16
|
+
const okRun = (calls) => async (slug, o) => { calls.push([slug, o]); return { ok: true, batch: `b-${slug}`, rows: [] }; };
|
|
17
|
+
const life = async (titles) => titles.map((title) => ({ title, state: "shipped:staging" }));
|
|
18
|
+
|
|
19
|
+
test("createTrain keeps order, refuses a dependency on a later milestone", () => {
|
|
20
|
+
const s = createTrain({ slugs: ["m1", "m2"], live });
|
|
21
|
+
assert.deepEqual(s.order, ["m1", "m2"]);
|
|
22
|
+
assert.equal(s.milestones.m1.status, "pending");
|
|
23
|
+
assert.throws(() => createTrain({ slugs: ["m2", "m1"], live }), /train order invalid/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("insertMilestone re-validates and leaves the state unchanged on refusal", () => {
|
|
27
|
+
const s = createTrain({ slugs: ["m1", "m2"], live });
|
|
28
|
+
const n = insertMilestone(s, "m3", { after: "m1", live });
|
|
29
|
+
assert.deepEqual(n.order, ["m1", "m3", "m2"]);
|
|
30
|
+
assert.deepEqual(s.order, ["m1", "m2"]);
|
|
31
|
+
assert.throws(() => insertMilestone(s, "m1", { live }), /already in the train/);
|
|
32
|
+
const live2 = { issues: [...live.issues, { number: 4, milestone: "m4", state: "OPEN", body: "Depends on #2" }, { number: 5, milestone: "m2", state: "OPEN", body: "Depends on #4" }] };
|
|
33
|
+
assert.throws(() => insertMilestone(s, "m4", { live: live2 }), /train order invalid/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("runTrain runs in order, persists lifecycle, and resumes from the state file", async () => {
|
|
37
|
+
const root = tmp(), train = openTrain(root, "t1"), calls = [];
|
|
38
|
+
const r = await runTrain({ train, state: createTrain({ slugs: ["m1", "m2"], live }), runMilestone: okRun(calls), onBatchEnd: life, readLive: async () => live, caps: resolveCaps({}) });
|
|
39
|
+
assert.equal(r.ok, true);
|
|
40
|
+
assert.deepEqual(calls.map((c) => c[0]), ["m1", "m2"]);
|
|
41
|
+
const saved = train.load();
|
|
42
|
+
assert.equal(saved.milestones.m2.status, "done");
|
|
43
|
+
assert.equal(saved.milestones.m2.lifecycle, "shipped:staging");
|
|
44
|
+
assert.equal(saved.milestones.m1.batch, "b-m1");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a failed milestone ends in a typed STOP and --resume continues through its batch", async () => {
|
|
48
|
+
const root = tmp(), train = openTrain(root, "t2"), calls = [];
|
|
49
|
+
let fail = true;
|
|
50
|
+
const run = async (slug, o) => { calls.push([slug, o.resume]); return fail && slug === "m1" ? { ok: false, batch: "b1", rows: [{ issue: 1, outcome: "needs-human" }] } : { ok: true, rows: [] }; };
|
|
51
|
+
const args = { train, runMilestone: run, onBatchEnd: life, readLive: async () => live, caps: resolveCaps({}) };
|
|
52
|
+
const a = await runTrain({ ...args, state: createTrain({ slugs: ["m1", "m2"], live }) });
|
|
53
|
+
assert.equal(a.ok, false);
|
|
54
|
+
assert.equal(a.state.stop.reason, "needs-human");
|
|
55
|
+
assert.ok(STOP_REASONS.includes(a.state.stop.reason));
|
|
56
|
+
assert.equal(a.state.milestones.m2.status, "pending");
|
|
57
|
+
fail = false;
|
|
58
|
+
const b = await runTrain({ ...args, state: train.load() });
|
|
59
|
+
assert.equal(b.ok, true);
|
|
60
|
+
assert.deepEqual(calls.slice(1), [["m1", "b1"], ["m2", null]]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("open issues left after a batch stop the train with milestone-open", async () => {
|
|
64
|
+
const r = await runTrain({ train: openTrain(tmp(), "t3"), state: createTrain({ slugs: ["m1"] }), runMilestone: okRun([]), onBatchEnd: async (t) => [{ title: t[0], state: "building" }], caps: resolveCaps({}) });
|
|
65
|
+
assert.equal(r.state.stop.reason, "milestone-open");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("stopOf maps row outcomes to typed reasons", () => {
|
|
69
|
+
assert.equal(stopOf({ rows: [{ outcome: "dependency-open" }] }), "dependency-unmet");
|
|
70
|
+
assert.equal(stopOf({ rows: [{ outcome: "budget-exceeded" }] }), "budget");
|
|
71
|
+
assert.equal(stopOf({ rows: [] }), "error");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parallel milestones run together and split one cap", async () => {
|
|
75
|
+
const caps = resolveCaps({ globalParallel: 6, parallelMilestones: 2 });
|
|
76
|
+
assert.equal(caps.perMilestone, 3);
|
|
77
|
+
let live_ = 0, max = 0;
|
|
78
|
+
const run = async () => { live_++; max = Math.max(max, live_); await new Promise((r) => setTimeout(r, 10)); live_--; return { ok: true, rows: [] }; };
|
|
79
|
+
const r = await runTrain({ train: openTrain(tmp(), "t4"), state: createTrain({ slugs: ["m1", "m3"], parallelMilestones: 2 }), runMilestone: run, onBatchEnd: life, caps });
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.equal(max, 2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a conflicting --parallel override warns and is ignored; the cap source is global_parallel", () => {
|
|
85
|
+
const c = resolveCaps({ requested: 32, globalParallel: 12 });
|
|
86
|
+
assert.equal(c.cap, 12);
|
|
87
|
+
assert.match(c.warnings[0], /conflicts.*ignored/);
|
|
88
|
+
assert.equal(resolveCaps({ requested: 12, globalParallel: 12 }).warnings.length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("trainCli: forge.yaml is byte-identical after a run and lanes get no branches.staging override", async () => {
|
|
92
|
+
const root = tmp(), cwd = tmp(), out = [], calls = [];
|
|
93
|
+
const yaml = "branches:\n staging: staging\norchestration:\n global_parallel: 4\n";
|
|
94
|
+
writeFileSync(join(cwd, "forge.yaml"), yaml);
|
|
95
|
+
const before = readFileSync(join(cwd, "forge.yaml"));
|
|
96
|
+
const r = await trainCli(["m1", "m2", "--parallel", "32"], { root, id: "t5", globalParallel: 4, readLive: async () => live, runMilestone: okRun(calls), onBatchEnd: life, print: (l) => out.push(l) });
|
|
97
|
+
assert.equal(r.ok, true);
|
|
98
|
+
assert.ok(out.some((l) => /conflicts with the lane cap 4/.test(l)));
|
|
99
|
+
assert.equal(calls[0][1].parallel, 4);
|
|
100
|
+
assert.deepEqual(readFileSync(join(cwd, "forge.yaml")), before);
|
|
101
|
+
assert.equal(openTrain(root, "t5").load().milestones.m2.status, "done");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("trainCli --insert adds a milestone explicitly; --resume clears nothing it did not own", async () => {
|
|
105
|
+
const root = tmp(), out = [];
|
|
106
|
+
const deps = { root, id: "t6", readLive: async () => live, runMilestone: okRun([]), onBatchEnd: life, print: (l) => out.push(l) };
|
|
107
|
+
await trainCli(["m1"], deps);
|
|
108
|
+
const r = await trainCli(["--insert", "t6", "m3"], deps);
|
|
109
|
+
assert.deepEqual(r.state.order, ["m1", "m3"]);
|
|
110
|
+
const done = await trainCli(["--resume", "t6"], deps);
|
|
111
|
+
assert.equal(done.state.milestones.m3.status, "done");
|
|
112
|
+
await assert.rejects(() => trainCli(["--resume", "nope"], deps), /no train/);
|
|
113
|
+
});
|
package/bin/pool/cli.mjs
CHANGED
|
@@ -42,12 +42,14 @@ export async function runPoolCli(argv, deps = {}) {
|
|
|
42
42
|
if (r.status !== 0) throw new Error(`install-router.sh exited ${r.status}`);
|
|
43
43
|
break;
|
|
44
44
|
}
|
|
45
|
+
process.title = "forgedock-pool-router"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
45
46
|
const cfg = routerConfig();
|
|
46
47
|
(deps.startRouter ?? startRouter)(cfg);
|
|
47
48
|
console.log(`computeshare router on ${cfg.listen.host}:${cfg.listen.port} (public ${cfg.public_url})`);
|
|
48
49
|
break;
|
|
49
50
|
}
|
|
50
51
|
case "agent": {
|
|
52
|
+
process.title = "forgedock-pool-agent";
|
|
51
53
|
const cfg = agentConfig();
|
|
52
54
|
startAgent(cfg, { docker: dockerCli });
|
|
53
55
|
console.log(`computeshare agent ${cfg.name} on ${cfg.listen.host}:${cfg.listen.port}: ${cfg.allow_cpus} cpus, ${cfg.allow_mem_gb} GB reserved for lanes`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const sh = (cmd, args) => { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }); return r.status === 0 ? r.stdout : null; };
|
|
6
|
+
|
|
7
|
+
/** Last exit reason in journal lines, or null. Matches systemd's "Main process exited, code=killed, status=15/TERM" and oom-kill results. */
|
|
8
|
+
export function lastExitReason(lines) {
|
|
9
|
+
let reason = null;
|
|
10
|
+
for (const l of lines) {
|
|
11
|
+
const m = /Main process exited, (code=\S+, status=\S+)/.exec(l) || /(Failed with result '(?:oom-kill|signal|exit-code)')/.exec(l);
|
|
12
|
+
if (m) reason = m[1];
|
|
13
|
+
}
|
|
14
|
+
return reason;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Did a systemd --user unit restart in the last hour? `run(cmd,args)` returns stdout or null (injected for tests).
|
|
19
|
+
* "skip" when the unit is absent or systemd is unavailable. Restarts come from the journal because NRestarts is only cumulative.
|
|
20
|
+
*/
|
|
21
|
+
export function restartCheck({ unit, run = sh } = {}) {
|
|
22
|
+
const show = run("systemctl", ["--user", "show", unit, "-p", "NRestarts", "-p", "LoadState"]);
|
|
23
|
+
if (!show || /LoadState=not-found/.test(show)) return { status: "skip" };
|
|
24
|
+
const total = Number(/NRestarts=(\d+)/.exec(show)?.[1] ?? 0);
|
|
25
|
+
const lines = (run("journalctl", ["--user", "-u", unit, "--since", "1 hour ago", "--no-pager", "-o", "cat"]) ?? "").split("\n");
|
|
26
|
+
const recent = lines.filter((l) => /Scheduled restart job/.test(l)).length;
|
|
27
|
+
if (recent === 0) return { status: "pass", detail: `no restarts in the last hour (NRestarts=${total})` };
|
|
28
|
+
const why = lastExitReason(lines);
|
|
29
|
+
return { status: "warn", detail: `${unit} restarted ${recent}x in the last hour (NRestarts=${total})${why ? `; last exit: ${why}` : ""}. Check for OOM kills (earlyoom/systemd-oomd) under memory pressure` };
|
|
30
|
+
}
|