forgedock 1.36.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.
Files changed (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -35,6 +35,7 @@
35
35
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
36
36
  import { createConnection, createServer } from "node:net";
37
37
  import { spawn } from "node:child_process";
38
+ import { tmpdir } from "node:os";
38
39
  import { join } from "node:path";
39
40
  import { schedDir, pidAlive } from "./sched.mjs";
40
41
  import { batchRoot, listBatches, openBatch } from "./batch.mjs";
@@ -48,17 +49,57 @@ export function scheddPaths(dir = schedDir()) {
48
49
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
49
50
  }
50
51
 
51
- // Env keys a batch keeps for its lanes (account and backend selectors, tool paths); persisted in plan.json.
52
- const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_ALL|TMPDIR|FORGEDOCK_.*|CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*)$/;
52
+ // Env keys a batch keeps for its lanes (account selectors, tool paths); persisted in plan.json (#4691). Session-scoped
53
+ // `CLAUDE_CODE_*` / `CLAUDE_PID` and `ANTHROPIC_*` are not kept, except the non-secret provider/endpoint/model selectors
54
+ // (`CLAUDE_CODE_USE_*`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, ...; #4734); more names come from `orchestration.env_passthrough`.
55
+ const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_.*|TMPDIR|CLAUDE_CONFIG_DIR|CLAUDE_CODE_USE_.*|ANTHROPIC_(BASE_URL|MODEL|SMALL_FAST_MODEL|DEFAULT_.*_MODEL)|FORGEDOCK_.*|GH_.*|GITHUB_.*)$/;
56
+ // What the 1.36.0 pin carried (plus FORGEDOCK_*): a key of these families not pinned now is named at submit (#4734).
57
+ const LEGACY_PIN = /^(CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*|FORGEDOCK_.*)$/;
58
+ const SESSION_SCOPED = /^(CLAUDE_PID|CLAUDE_CODE_(SESSION|MESSAGING|ENTRYPOINT|EXECPATH|SSE).*)$/;
59
+ // A secret never rides the allowlist: it resolves at lane start (env, keychain) unless the operator names it as a passthrough.
60
+ export const SECRET_ENV = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_?KEY)/i;
61
+ const SESSION_DIR = /(^|\/)(arg0|claude[^/]*)(\/|$)/;
53
62
 
54
- /** The allowlisted part of `env`. */
55
- export function batchEnv(env = process.env) {
56
- return Object.fromEntries(Object.entries(env).filter(([k, v]) => ENV_KEEP.test(k) && typeof v === "string"));
63
+ /** A forge.yaml glob (`*` within a segment, `**` across segments, `?`) as an anchored RegExp. */
64
+ export function globToRegExp(glob) {
65
+ const re = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
66
+ return new RegExp(`^${re}$`);
57
67
  }
58
68
 
59
- /** `base` without its allowlisted keys, plus `kept`: an account selector the client did not set never leaks in from the daemon. */
69
+ /** `path` without non-existent dirs, per-session temp dirs (an `arg0`/`claude*` dir under a temp root) and `exclude` glob matches. */
70
+ export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots = ["/tmp", "/var/tmp", tmpdir(), process.env.TMPDIR] } = {}) {
71
+ const globs = exclude.map(globToRegExp);
72
+ const roots = tmpRoots.filter(Boolean).map((r) => r.replace(/\/+$/, ""));
73
+ const keep = (d) => d && exists(d) && !globs.some((g) => g.test(d.replace(/\/+$/, "")))
74
+ && !(roots.some((r) => d === r || d.startsWith(`${r}/`)) && SESSION_DIR.test(d));
75
+ const seen = new Set();
76
+ return String(path).split(":").filter((d) => keep(d) && !seen.has(d) && seen.add(d)).join(":");
77
+ }
78
+
79
+ /**
80
+ * The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
81
+ * the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
82
+ */
83
+ export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
84
+ const extra = new Set(passthrough);
85
+ const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
86
+ if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
87
+ return out;
88
+ }
89
+
90
+ /** Sorted names (never values) of `env` keys the pre-allowlist pin carried that `kept` does not: the lane gets the daemon's value or resolves it at lane start (#4734). */
91
+ export function droppedEnv(env = process.env, kept = {}) {
92
+ return Object.keys(env).filter((k) => typeof env[k] === "string" && LEGACY_PIN.test(k) && !SESSION_SCOPED.test(k) && !(k in kept)).sort();
93
+ }
94
+
95
+ /** `env` with secret-looking values redacted (`--show-env`). */
96
+ export function redactEnv(env) {
97
+ return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
98
+ }
99
+
100
+ /** `base` without its allowlisted non-secret keys (secret-named ones, e.g. GH_TOKEN, stay from the daemon: never pinned), plus `kept`: an account selector the client did not set never leaks in from the daemon. */
60
101
  export function mergeEnv(base, kept) {
61
- const out = Object.fromEntries(Object.entries(base).filter(([k]) => !ENV_KEEP.test(k)));
102
+ const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
62
103
  return { ...out, ...kept };
63
104
  }
64
105
 
@@ -141,8 +182,11 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
141
182
  if (req.op === "add") {
142
183
  const b = openBatch(root, req.id);
143
184
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
144
- const { added, skipped } = b.addIssues(req.issues ?? []);
145
- return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
185
+ const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
186
+ // #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
187
+ const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
188
+ if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
189
+ return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
146
190
  }
147
191
  if (req.op === "requeue") {
148
192
  // #4550: the runner applies the entries on its next pass; a finished, unhosted batch is reopened (hosted as a resume).
@@ -3,12 +3,12 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
10
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
11
- import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
11
+ import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
12
12
  import { schedDir } from "./sched.mjs";
13
13
  import { formatTable, orchestrate } from "./orchestrate.mjs";
14
14
  import { orchestrateFromCli } from "../engine-cli.mjs";
@@ -507,10 +507,82 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
507
507
  });
508
508
 
509
509
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
510
- const kept = batchEnv({ PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s" });
511
- assert.deepEqual(kept, { PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
510
+ const kept = batchEnv({ PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
511
+ assert.deepEqual(kept, { PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
512
512
  const env = mergeEnv({ PATH: "/d", ANTHROPIC_API_KEY: "daemon-key", NODE_OPTIONS: "x" }, kept);
513
- assert.deepEqual(env, { NODE_OPTIONS: "x", PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
513
+ assert.equal(mergeEnv({ GH_TOKEN: "d" }, kept).GH_TOKEN, "d", "a secret-named daemon key (never pinned) stays for the lane");
514
+ assert.deepEqual(env, { NODE_OPTIONS: "x", ANTHROPIC_API_KEY: "daemon-key", PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
515
+ });
516
+
517
+ it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
518
+ const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
519
+ FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
520
+ const kept = batchEnv(client);
521
+ assert.deepEqual(Object.keys(kept).sort(), ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CONFIG_DIR", "PATH"]);
522
+ assert.deepEqual(droppedEnv(client, kept), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN", "GH_TOKEN"]);
523
+ assert.deepEqual(droppedEnv(client, batchEnv(client, { passthrough: ["GH_TOKEN"] })), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN"]);
524
+ assert.deepEqual(droppedEnv({ PATH: "/", CLAUDE_CODE_SESSION_ID: "s" }, {}), [], "nothing to name: no warning");
525
+ assert.equal(mergeEnv({ ANTHROPIC_BASE_URL: "daemon", GH_TOKEN: "d" }, kept).ANTHROPIC_BASE_URL, "http://proxy");
526
+ });
527
+
528
+ it("#4691: the pinned env drops session tokens and ids, session temp PATH dirs and path_exclude dirs", () => {
529
+ const work = tmpDir("schedd-env-");
530
+ const keep = join(work, "bin"), session = join(work, "claude-1", "arg0"), wrapper = join(work, "wrap", "shims");
531
+ for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
532
+ const session_env = { PATH: [keep, session, wrapper, join(work, "gone")].join(":"), HOME: "/h", CLAUDE_CODE_MESSAGING_TOKEN: "tok", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1",
533
+ CLAUDE_CODE_SESSION_ATTENDED: "1", ANTHROPIC_API_KEY: "k", FORGEDOCK_SANDBOX_TOKEN: "t", MY_FLAG: "1", MY_SECRET_PASS: "p", GH_HOST: "h" };
534
+ const pin = batchEnv(session_env, { passthrough: ["MY_FLAG", "MY_SECRET_PASS"], pathExclude: [`${work}/wrap/*`], tmpRoots: [work] });
535
+ assert.equal(pin.PATH, keep, "only the live, non-session, non-excluded dir stays");
536
+ assert.deepEqual(Object.keys(pin).sort(), ["GH_HOST", "HOME", "MY_FLAG", "MY_SECRET_PASS", "PATH"]);
537
+ assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: [work] }), keep);
538
+ assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: ["/nowhere"] }), `${session}:${keep}`, "an arg0 dir outside a temp root is not session-scoped");
539
+ assert.deepEqual(redactEnv({ A: "1", GH_TOKEN: "x" }), { A: "1", GH_TOKEN: "<redacted>" });
540
+ });
541
+
542
+ it("#4691: plan.json holds none of the session token, temp PATH dir or excluded wrapper dir; --show/--set/--unset-env edit it", async () => {
543
+ const h = host();
544
+ const work = tmpDir("schedd-pin-");
545
+ const keep = join(work, "bin"), session = join(work, "claude-9", "arg0"), wrapper = join(work, "wrap", "shims");
546
+ for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
547
+ writeFileSync(join(work, "forge.yaml"), `orchestration:\n path_exclude: ${work}/wrap/*\n`);
548
+ const saved = { cwd: process.cwd(), path: process.env.PATH, tmp: process.env.TMPDIR };
549
+ let a;
550
+ try {
551
+ await h.daemon();
552
+ process.chdir(work);
553
+ process.env.PATH = [keep, session, wrapper].join(":");
554
+ process.env.TMPDIR = work;
555
+ process.env.CLAUDE_CODE_MESSAGING_TOKEN = "msg-token";
556
+ process.env.CLAUDE_CODE_SESSION_ID = "sess";
557
+ await withRoot(h, async () => {
558
+ a = capture(() => orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
559
+ await until(() => h.stubPids().length === 1 && h.lanePids().length === 1);
560
+ });
561
+ const [id] = listBatches(h.root);
562
+ const raw = readFileSync(join(h.root, id, "plan.json"), "utf8");
563
+ const env = JSON.parse(raw).env;
564
+ assert.equal(env.PATH, keep);
565
+ for (const bad of ["msg-token", "sess", "CLAUDE_CODE_", session, wrapper]) assert.equal(raw.includes(bad), false, `${bad} not pinned`);
566
+ await withRoot(h, async () => {
567
+ await capture(() => orchestrateFromCli(["--set-env", id, "MY_VAR=1"], h.client));
568
+ await capture(() => orchestrateFromCli(["--set-env", id, "GH_TOKEN=abc"], h.client));
569
+ assert.equal(openBatch(h.root, id).readPlan().env.MY_VAR, "1");
570
+ const { lines } = await capture(() => orchestrateFromCli(["--show-env", id], h.client));
571
+ assert.match(lines.join("\n"), /^GH_TOKEN=<redacted>$/m);
572
+ assert.match(lines.join("\n"), /^MY_VAR=1$/m);
573
+ assert.equal(lines.join("\n").includes("abc"), false);
574
+ await capture(() => orchestrateFromCli(["--unset-env", id, "MY_VAR"], h.client));
575
+ assert.equal("MY_VAR" in openBatch(h.root, id).readPlan().env, false);
576
+ await assert.rejects(() => orchestrateFromCli(["--set-env", id, "NOEQUALS"], h.client), /KEY=VAL/);
577
+ });
578
+ writeFileSync(h.release, "");
579
+ } finally {
580
+ process.chdir(saved.cwd); process.env.PATH = saved.path;
581
+ if (saved.tmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = saved.tmp;
582
+ delete process.env.CLAUDE_CODE_MESSAGING_TOKEN; delete process.env.CLAUDE_CODE_SESSION_ID;
583
+ await h.cleanup();
584
+ }
585
+ await a;
514
586
  });
515
587
  });
516
588
 
@@ -457,3 +457,27 @@ export async function generatorProblems({ git, base, ref = "HEAD" }) {
457
457
  }
458
458
  return problems;
459
459
  }
460
+
461
+ /**
462
+ * #4661: journal `when` ordering. Newest-only migrators (drizzle `migrate()`) skip an entry stamped older than one already
463
+ * applied, so every entry the lane adds to a drizzle-style journal (`<dir>/meta/_journal.json`) must have `when` above the
464
+ * base journal's max, and `when` must increase within the lane journal. No journal or base journal: []. @returns {Promise<string[]>}
465
+ */
466
+ export async function journalOrderProblems({ git, base, ref = "HEAD" }) {
467
+ const g = out(git), problems = [];
468
+ const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", `${base}...${ref}`])).filter((f) => /(?:^|\/)meta\/_journal\.json$/.test(f) && !SKIP_RE.test(f));
469
+ const entriesAt = async (r, jp) => { try { const e = JSON.parse(await g(["show", `${r}:${jp}`])).entries; return Array.isArray(e) ? e : null; } catch { return null; } };
470
+ for (const jp of changed) {
471
+ const now = await entriesAt(ref, jp);
472
+ if (!now) continue;
473
+ const was = (await entriesAt(base, jp)) ?? [], wasTags = new Set(was.map((e) => e?.tag));
474
+ const max = Math.max(-Infinity, ...was.map((e) => Number(e?.when)).filter(Number.isFinite));
475
+ for (let i = 0; i < now.length; i++) {
476
+ const e = now[i] ?? {}, w = Number(e.when);
477
+ if (wasTags.has(e.tag)) continue;
478
+ if (Number.isFinite(max) && !(w > max)) problems.push(`${jp}: ${e.tag} when ${e.when} is not above the base max when ${max} (stale stamp: a newest-only migrator skips it on a persistent database)`);
479
+ else if (i > 0 && Number.isFinite(Number(now[i - 1]?.when)) && !(w > Number(now[i - 1].when))) problems.push(`${jp}: ${e.tag} when ${e.when} is not after ${now[i - 1].tag} (${now[i - 1].when})`);
480
+ }
481
+ }
482
+ return problems;
483
+ }
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
11
- import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
12
12
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
13
13
  import { sequencePrompt, sequenceSyncNote, migrationGeneratorPrompt, builderImplementPrompt } from "./prompts.mjs";
14
14
 
@@ -492,3 +492,33 @@ describe("hand-named migrations (#4591)", () => {
492
492
  assert.match(builderImplementPrompt("local"), /Never name or number migration files by hand/);
493
493
  });
494
494
  });
495
+
496
+ describe("journal when ordering across parallel milestone branches (#4661)", () => {
497
+ const J = (...es) => `${JSON.stringify({ version: "7", entries: es.map(([tag, when], idx) => ({ idx, tag, when })) }, null, 2)}\n`;
498
+ function repo(laneEntries) {
499
+ const d = tmpDir("when-");
500
+ sh(d, "init", "-q", "-b", "main");
501
+ put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000]) });
502
+ commit(d, "init");
503
+ sh(d, "checkout", "-qb", "lane");
504
+ if (laneEntries) { put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000], ...laneEntries) }); commit(d, "lane"); }
505
+ return d;
506
+ }
507
+ it("flags an added entry stamped at or below the base max when", async () => {
508
+ const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 7000]])), base: "main" });
509
+ assert.equal(p.length, 1);
510
+ assert.match(p[0], /db\/meta\/_journal\.json: 2_c when 7000 is not above the base max when 9000/);
511
+ });
512
+ it("passes entries above the base max, an unchanged journal, and no journal", async () => {
513
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(repo([["2_c", 9001]])), base: "main" }), []);
514
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(repo(null)), base: "main" }), []);
515
+ const d = tmpDir("when-none-");
516
+ sh(d, "init", "-q", "-b", "main"); put(d, { "x.txt": "x" }); commit(d, "i"); sh(d, "checkout", "-qb", "lane");
517
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(d), base: "main" }), []);
518
+ });
519
+ it("flags non-increasing when inside the lane's added entries; the prompt asks for a re-stamp", async () => {
520
+ const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 9100], ["3_d", 9050]])), base: "main" });
521
+ assert.match(p[0], /3_d when 9050 is not after 2_c/);
522
+ assert.match(migrationGeneratorPrompt(p), /re-stamp/);
523
+ });
524
+ });
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * DCO sign-off for a lane's own unpushed commits (#4651). A resumed lane keeps the commits of an earlier run; one from a
5
+ * pool agent that predates the identity fix is authored `*@localhost` with no `Signed-off-by`, and DCO fails on the release
6
+ * PR. Before a push, the commits the push would add (reachable from HEAD, not from origin/<branch> nor origin/<lane>) are
7
+ * checked, and the offenders are rewritten with the lane identity and --signoff. Commits already on either remote ref are
8
+ * never in the range, so they are never rewritten. Deterministic, 0 LLM calls.
9
+ */
10
+ const SEP = "\x1f", END = "\x1e";
11
+ const PLACEHOLDER_RE = /@localhost$/i;
12
+
13
+ /** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
14
+ export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
15
+ if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;
16
+ const a = String(authorEmail ?? "").toLowerCase();
17
+ return !signedOff.some((s) => s.toLowerCase().includes(`<${a}>`));
18
+ }
19
+
20
+ /** Parse `git log` output of FORMAT into the commits that need a rewrite, oldest first (the log is newest first). */
21
+ export function offendersOf(out) {
22
+ return String(out ?? "").split(END).map((r) => r.replace(/^\n/, "")).filter(Boolean).map((r) => {
23
+ const [sha, authorEmail, committerEmail, trailers = "", parents = ""] = r.split(SEP);
24
+ return { sha, authorEmail, committerEmail, signedOff: trailers.split("\n").map((l) => l.trim()).filter(Boolean), merge: parents.trim().split(/\s+/).filter(Boolean).length > 1 };
25
+ }).filter((c) => !c.merge && needsSignoff(c)).reverse();
26
+ }
27
+
28
+ const FORMAT = `%H${SEP}%ae${SEP}%ce${SEP}%(trailers:key=Signed-off-by,valueonly,separator=%x0a)${SEP}%P${END}`;
29
+
30
+ // filter-branch rewrites only the commits in the range (the excluded remote refs stay as they are), and only the listed ones change.
31
+ const IS_BAD = 'case " $FD_BAD " in *" $GIT_COMMIT "*)';
32
+ const ENV_FILTER = `${IS_BAD} export GIT_AUTHOR_NAME="$FD_NAME" GIT_AUTHOR_EMAIL="$FD_EMAIL" GIT_COMMITTER_NAME="$FD_NAME" GIT_COMMITTER_EMAIL="$FD_EMAIL";; esac`;
33
+ const MSG_FILTER = `${IS_BAD} git interpret-trailers --trailer "Signed-off-by: $FD_NAME <$FD_EMAIL>";; *) cat;; esac`;
34
+
35
+ /**
36
+ * @param {object} o
37
+ * @param {(args: string[], opts?: {env?: object}) => Promise<string>} o.git runs git in the lane worktree
38
+ * @param {string} o.lane the lane (base) branch
39
+ * @param {string} o.branch the lane's own branch
40
+ * @param {object} [o.env] where GIT_AUTHOR_NAME/EMAIL are read from (default process.env)
41
+ * @returns {Promise<{rewritten: number}>}
42
+ * @throws Error with `.laneCode` unset: callers type it. `.noIdentity` when a rewrite is needed but no usable identity exists.
43
+ */
44
+ export async function ensureSignedOff({ git, lane, branch, env = process.env }) {
45
+ const ok = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
46
+ try { await git(["fetch", "origin", branch]); } catch { /* not pushed yet, or offline: the tracking refs we have decide */ }
47
+ const exclude = [];
48
+ for (const ref of [`origin/${branch}`, `origin/${lane}`]) if (await ok(["rev-parse", "--verify", "-q", `refs/remotes/${ref}`])) exclude.push(`^${ref}`);
49
+ const log = await ok(["log", `--format=${FORMAT}`, "HEAD", ...exclude]);
50
+ const bad = offendersOf(log);
51
+ if (!bad.length) return { rewritten: 0 };
52
+ // the lane identity: GIT_AUTHOR_* (what the sandbox sets and what a commit would use), else the worktree config
53
+ let name = env.GIT_AUTHOR_NAME || await ok(["config", "user.name"]), email = env.GIT_AUTHOR_EMAIL || await ok(["config", "user.email"]);
54
+ if (!name || !email) { // the runner's own git env (what a commit here would use), e.g. GIT_AUTHOR_* set only on the git runner
55
+ const m = /^(.*?)\s*<([^>]*)>/.exec(await ok(["var", "GIT_AUTHOR_IDENT"]));
56
+ if (m) { name ||= m[1]; email ||= m[2]; }
57
+ }
58
+ if (!name || !email || PLACEHOLDER_RE.test(email)) {
59
+ throw Object.assign(new Error(`${bad.length} unpushed commit(s) fail DCO (placeholder author or no sign-off: ${bad.slice(0, 5).map((c) => c.sha.slice(0, 8)).join(", ")}) and the worktree has no usable git identity to rewrite them`), { noIdentity: true });
60
+ }
61
+ const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
62
+ try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
63
+ catch (e) { throw Object.assign(new Error(`could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`), { noIdentity: false }); }
64
+ await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
65
+ return { rewritten: bad.length };
66
+ }
@@ -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
+ }