forgedock 1.35.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.
Files changed (51) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +113 -18
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +1 -1
  8. package/bin/hooks/reread-nudge.mjs +15 -4
  9. package/bin/lean/batch.mjs +42 -2
  10. package/bin/lean/batch.test.mjs +101 -1
  11. package/bin/lean/conformance.mjs +14 -3
  12. package/bin/lean/conformance.test.mjs +26 -0
  13. package/bin/lean/effects.mjs +3 -0
  14. package/bin/lean/execution.mjs +2 -0
  15. package/bin/lean/i18n.mjs +65 -0
  16. package/bin/lean/i18n.test.mjs +47 -0
  17. package/bin/lean/lane.mjs +5 -2
  18. package/bin/lean/lean.mjs +37 -4
  19. package/bin/lean/lean.test.mjs +124 -5
  20. package/bin/lean/orchestrate.mjs +49 -5
  21. package/bin/lean/postdeploy-check.mjs +149 -0
  22. package/bin/lean/postdeploy-check.test.mjs +146 -0
  23. package/bin/lean/postdeploy-monitor.mjs +87 -0
  24. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  25. package/bin/lean/precheck.mjs +6 -4
  26. package/bin/lean/precheck.test.mjs +17 -2
  27. package/bin/lean/prefilter.mjs +29 -4
  28. package/bin/lean/prefilter.test.mjs +13 -5
  29. package/bin/lean/probe.mjs +2 -1
  30. package/bin/lean/prompts.mjs +10 -2
  31. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  32. package/bin/lean/sandbox-remote.mjs +7 -0
  33. package/bin/lean/schedd.mjs +24 -4
  34. package/bin/lean/schedd.test.mjs +54 -0
  35. package/bin/stats/aggregate.mjs +1 -1
  36. package/bin/stats/experiment.mjs +44 -0
  37. package/bin/stats/github-source.mjs +2 -1
  38. package/bin/stats/index.mjs +42 -2
  39. package/bin/stats/release.mjs +75 -0
  40. package/bin/stats/release.test.mjs +68 -0
  41. package/commands/orchestrate.md +8 -3
  42. package/docs/CONFIG.md +49 -0
  43. package/docs/EVENTS.md +68 -0
  44. package/docs/STATS.md +31 -0
  45. package/docs/TELEMETRY.md +3 -1
  46. package/docs/site/install.md +89 -0
  47. package/package.json +1 -1
  48. package/scripts/bench-lightlane.mjs +2 -1
  49. package/scripts/eval-gate-scorecard.mjs +7 -0
  50. package/scripts/file-deferred-followups.sh +1 -0
  51. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -328,3 +328,31 @@ describe("remote capacity (GET /status, #4540)", () => {
328
328
  assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
329
329
  });
330
330
  });
331
+
332
+ describe("remote stop at the checkpoint (#4612)", () => {
333
+ it("httpTransport.stop: POST .../stop, 2xx true, 404/405 false, other statuses throw", async () => {
334
+ const calls = [];
335
+ const mk = (status) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url, init) => { calls.push([init.method, url]); return { status, ok: status < 400, text: async () => "", headers: { get: () => null } }; } });
336
+ assert.equal(await mk(202).stop("a b"), true);
337
+ assert.deepEqual(calls[0], ["POST", "https://pool.example/v1/sandboxes/a%20b/stop"]);
338
+ assert.equal(await mk(404).stop("x"), false);
339
+ assert.equal(await mk(405).stop("x"), false);
340
+ await assert.rejects(mk(500).stop("x"), /stop: HTTP 500/);
341
+ });
342
+
343
+ it("provider.stop and providerLanes.stopLane signal the transport, never destroy; no transport stop = false", async () => {
344
+ const root = tmpDir("remote-stop-");
345
+ const batch = openBatch(root, "bt");
346
+ const { provider, transport } = remoteFixture(batch);
347
+ const stopped = [];
348
+ const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor: () => null });
349
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), false, "the transport has no stop");
350
+ transport.stop = async (id) => { stopped.push(id); return true; };
351
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), true);
352
+ assert.equal(await lanes.stopLane(1, {}), false, "no handle");
353
+ transport.stop = async () => { throw new Error("boom"); };
354
+ assert.equal(await provider.stop({ id: "sbx" }), false);
355
+ assert.deepEqual(stopped, ["sbx"]);
356
+ assert.deepEqual(transport.destroyed, []);
357
+ });
358
+ });
@@ -44,6 +44,7 @@ export function isActionsEndpoint(endpoint) {
44
44
  * GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
45
45
  * GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`.
46
46
  * 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
47
+ * Optional `POST /sandboxes/:id/stop` (#4612) asks the pool to SIGTERM the lane so its teardown checkpoints and it reports `deferred run.interrupted`; 2xx = sent, 404/405 = unsupported or gone (never a DELETE fallback).
47
48
  * Optional `GET /status -> {free: N}` is the pool's free slots (#4540); anything else (404, error, malformed) is unknown (null).
48
49
  */
49
50
  export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
@@ -73,6 +74,7 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
73
74
  async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
74
75
  async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
75
76
  async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
77
+ async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
76
78
  async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
77
79
  async capacity() {
78
80
  // `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
@@ -167,6 +169,11 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
167
169
  async capacity() {
168
170
  try { return (await transport.capacity?.()) ?? null; } catch { return null; }
169
171
  },
172
+ /** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
173
+ async stop(h) {
174
+ if (typeof transport.stop !== "function") return false;
175
+ try { return (await transport.stop(h.id)) === true; } catch { return false; }
176
+ },
170
177
  async teardown(h) {
171
178
  await minutesOf(h);
172
179
  try { return (await transport.destroy(h.id)) !== false; } catch { return false; }
@@ -11,9 +11,10 @@
11
11
  * {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
12
  * ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
13
13
  * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
+ * {op:"requeue", id, issues, reason, by} → {type:"ok", requeued, refused, hosted, reopened} (#4550): see batch.requeue; a finished batch is hosted again
14
15
  * {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
15
16
  * hosted runner reads on its next pass and an unhosted batch on its next resume
16
- * {op:"cancel", id} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
+ * {op:"cancel", id, stopRemote?} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
18
  * admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
18
19
  * and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
19
20
  * {type:"ok", cancelled:false, finished:true}, nothing written
@@ -41,6 +42,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
41
42
  export const IDLE_MS = 10 * 60 * 1000;
42
43
  export const STOP_MS = 10 * 1000;
43
44
  export const LOCK_CHECK_MS = 5 * 1000;
45
+ export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
44
46
 
45
47
  export function scheddPaths(dir = schedDir()) {
46
48
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
@@ -83,7 +85,7 @@ function takeLock(lock, pid, alive) {
83
85
  */
84
86
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
85
87
  root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
86
- lockCheckMs = LOCK_CHECK_MS } = {}) {
88
+ lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS } = {}) {
87
89
  const p = scheddPaths(dir);
88
90
  mkdirSync(dir, { recursive: true });
89
91
  const lock = takeLock(p.lock, pid, alive);
@@ -142,6 +144,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
142
144
  const { added, skipped } = b.addIssues(req.issues ?? []);
143
145
  return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
144
146
  }
147
+ if (req.op === "requeue") {
148
+ // #4550: the runner applies the entries on its next pass; a finished, unhosted batch is reopened (hosted as a resume).
149
+ const b = openBatch(root, req.id);
150
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
151
+ const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
152
+ const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
153
+ if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
154
+ return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
155
+ }
145
156
  if (req.op === "hold" || req.op === "release") {
146
157
  const b = openBatch(root, req.id);
147
158
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
@@ -152,7 +163,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
152
163
  const b = openBatch(root, req.id);
153
164
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
154
165
  if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
155
- b.requestCancel({ reason: "operator" });
166
+ b.requestCancel({ reason: "operator", stopRemote: req.stopRemote === true });
156
167
  return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
157
168
  }
158
169
  const sub = (m) => send(m, m.type === "done" || m.type === "error");
@@ -173,7 +184,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
173
184
  const b = openBatch(root, req.id);
174
185
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
175
186
  const rep = b.readReport();
176
- if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
187
+ if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
177
188
  return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
178
189
  }
179
190
  const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
@@ -212,6 +223,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
212
223
  if (closing) return closing;
213
224
  clearInterval(idleTimer);
214
225
  clearInterval(lockTimer);
226
+ if (dueTimer) clearInterval(dueTimer);
215
227
  for (const c of conns) c.destroy();
216
228
  const stopping = [...runs.keys()];
217
229
  for (const ac of runs.values()) ac.abort();
@@ -241,6 +253,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
241
253
  else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
242
254
  }, lockCheckMs);
243
255
  lockTimer.unref?.();
256
+ // #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
257
+ let dueRunning = false;
258
+ const dueTimer = due ? setInterval(async () => {
259
+ if (closing || dueRunning) return;
260
+ dueRunning = true;
261
+ try { await due(); } catch (e) { log(`schedd: due run failed: ${e?.message ?? e}`); } finally { dueRunning = false; }
262
+ }, dueMs) : null;
263
+ dueTimer?.unref?.();
244
264
 
245
265
  // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
246
266
  if (resumeOnStart) {
@@ -208,6 +208,32 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
208
208
  } finally { await h.cleanup(); }
209
209
  });
210
210
 
211
+ it("#4550: --requeue on a finished batch with a report reopens it through the daemon and runs the issue again; --add still skips it", async () => {
212
+ const h = host();
213
+ try {
214
+ await h.daemon();
215
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
216
+ await withRoot(h, () => capture(async () => {
217
+ writeFileSync(h.release, "");
218
+ const first = await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
219
+ assert.deepEqual(first.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
220
+ const [id] = listBatches(h.root);
221
+ const b = openBatch(h.root, id);
222
+ // The lane ended needs-human and the operator decided it: the finished batch holds that terminal row.
223
+ b.store.save({ ...b.store.load(), results: { 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
224
+ const rep = b.readReport();
225
+ b.writeReport({ ...rep, rows: [{ issue: 1, outcome: "needs-human" }] });
226
+ assert.deepEqual((await orchestrateFromCli(["--add", id, "1"], client)).skipped.map((x) => x.reason), ["already in batch"]);
227
+ const res = await orchestrateFromCli(["--requeue", id, "1", "--reason", "decided"], client);
228
+ assert.deepEqual(res.requeued, [1]);
229
+ await until(() => h.launched().length === 2);
230
+ await until(() => b.store.load()?.results?.[1]?.outcome === "merged" && b.readReport()?.rows?.[0]?.outcome === "merged");
231
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
232
+ assert.equal(b.readReport().rows[0].requeued.reason, "decided");
233
+ }));
234
+ } finally { await h.cleanup(); }
235
+ });
236
+
211
237
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
212
238
  const h = host();
213
239
  try {
@@ -487,3 +513,31 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
487
513
  assert.deepEqual(env, { NODE_OPTIONS: "x", PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
488
514
  });
489
515
  });
516
+
517
+ describe("schedd due timer (#4627)", () => {
518
+ const boot = async (opts) => startDaemon({ dir: tmpDir("schedd-due-"), root: tmpDir("schedd-due-b-"), runBatch: async () => ({}), resumeOnStart: false, log: () => {}, ...opts });
519
+
520
+ it("runs the due callback on its interval, never overlapped, and survives a throwing callback", async () => {
521
+ let calls = 0, active = 0, overlap = false;
522
+ const logs = [];
523
+ const d = await boot({ dueMs: 15, log: (m) => logs.push(m), due: async () => {
524
+ calls++; active++; if (active > 1) overlap = true;
525
+ await new Promise((r) => setTimeout(r, 40));
526
+ active--; if (calls === 1) throw new Error("boom");
527
+ } });
528
+ try {
529
+ await until(() => calls >= 3);
530
+ assert.equal(overlap, false);
531
+ assert.ok(logs.some((m) => /due run failed: boom/.test(m)));
532
+ assert.ok(existsSync(d.sock));
533
+ } finally { await d.close(); }
534
+ const n = calls;
535
+ await new Promise((r) => setTimeout(r, 80));
536
+ assert.equal(calls, n, "the timer is cleared on close");
537
+ });
538
+
539
+ it("without a due callback there is no timer", async () => {
540
+ const d = await boot({ dueMs: 10 });
541
+ await d.close();
542
+ });
543
+ });
@@ -86,7 +86,7 @@ function costSection(runs) {
86
86
  n, cacheReadMean: mean(cr), cacheReadMedian: percentile(cr, 50), perPhase,
87
87
  outputMean: n ? sum(runs.map((r) => sum(Object.values(r.phases).map((p) => p.out ?? 0)))) / n : null,
88
88
  perMergedIssue: merged ? sum(cr) / merged : null,
89
- usdN: usd.length, usdMean: mean(usd), usdTotal: usd.length ? sum(usd) : null,
89
+ usdN: usd.length, usdMean: mean(usd), usdMedian: percentile(usd, 50), usdTotal: usd.length ? sum(usd) : null,
90
90
  };
91
91
  }
92
92
 
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4629: `forgedock stats --experiment <id>`. Finished runs tagged with the experiment id, grouped by arm; each metric
5
+ * is null below the declared min n (release.mjs rule), a target is null when a value it needs is null, never a pass.
6
+ */
7
+ import { aggregate } from "./aggregate.mjs";
8
+ import { releaseMetrics } from "./release.mjs";
9
+ import { benchRecord } from "../experiment.mjs";
10
+
11
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
12
+
13
+ /** One target against the per-arm metrics: absolute limit, or limit x the other arm's value. */
14
+ export function evalTarget(t, byArm) {
15
+ const value = byArm[t.arm]?.[t.metric], base = t.vs ? byArm[t.vs]?.[t.metric] : 1;
16
+ const limit = isNum(base) ? t.limit * base : null;
17
+ const pass = isNum(value) && isNum(limit) ? (t.op === "<=" ? value <= limit : value >= limit) : null;
18
+ return { pass, value: isNum(value) ? value : null, limit };
19
+ }
20
+
21
+ export function experimentReport(runs, exp) {
22
+ const mine = runs.filter((r) => r.finished && r.experiment === exp.id);
23
+ const arms = {}, byArm = {};
24
+ for (const arm of exp.arms) {
25
+ const rs = mine.filter((r) => r.arm === arm).map((r) => ({ ...r, version: "0.0.0" })); // one group per arm
26
+ const g = aggregate({ runs: rs }, { minN: exp.minN }).groups[0];
27
+ const m = g ? releaseMetrics([g], { minN: exp.minN }).versions[0] : null;
28
+ byArm[arm] = Object.fromEntries(exp.metrics.map((k) => [k, m?.[k] ?? null]));
29
+ arms[arm] = { n: rs.length, ...byArm[arm] };
30
+ }
31
+ const targets = Object.fromEntries(exp.targets.map((t) => [t.text, evalTarget(t, byArm)]));
32
+ const verdicts = Object.values(targets);
33
+ return { id: exp.id, minN: exp.minN, ...benchRecord({ source: "stats", arms, targets }),
34
+ targets_met: verdicts.length > 0 && verdicts.every((v) => v.pass === true) };
35
+ }
36
+
37
+ const fmt = (v) => (v == null ? "n/a" : Number.isInteger(v) ? String(v) : v.toFixed(2));
38
+ export function renderExperiment(r) {
39
+ const keys = Object.keys(Object.values(r.arms)[0] ?? {}).filter((k) => k !== "n");
40
+ const line = (c) => `| ${c.join(" | ")} |`;
41
+ const rows = Object.entries(r.arms).map(([a, v]) => line([a, String(v.n), ...keys.map((k) => fmt(v[k]))]));
42
+ const tg = Object.entries(r.targets).map(([t, v]) => `${t}: ${v.pass == null ? "n/a" : v.pass ? "met" : "missed"} (${fmt(v.value)} vs ${fmt(v.limit)})`);
43
+ return [`Experiment ${r.id} (min n ${r.minN}; n/a below it)`, line(["arm", "n", ...keys]), line(["---", "---", ...keys.map(() => "---")]), ...rows, ...tg, `targets met: ${r.targets_met}`].join("\n");
44
+ }
@@ -77,9 +77,10 @@ export function runsFromRecords(records) {
77
77
  const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
78
78
  const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
79
79
  finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
80
- phases: {}, builderAttempts: [], _first: null };
80
+ phases: {}, builderAttempts: [], experiment: null, arm: null, _first: null };
81
81
  byRun.set(key, run);
82
82
  if (!run._first || r.ts < run._first) run._first = r.ts;
83
+ if (r.experiment != null && r.arm != null) { run.experiment = String(r.experiment); run.arm = String(r.arm); } // #4629
83
84
  if (r.event === "run_terminal") {
84
85
  run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
85
86
  continue;
@@ -16,6 +16,9 @@ import { parseForgeYaml } from "../forge-utils.mjs";
16
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
17
  import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
18
18
  import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyLines, formatCompareLines, parseWindows } from "./efficiency.mjs";
19
+ import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
20
+ import { experimentReport, renderExperiment } from "./experiment.mjs";
21
+ import { readExperiment } from "../experiment.mjs";
19
22
  import { poolUtilisation, formatPoolLines } from "./pool.mjs";
20
23
  import { batchRoot } from "../lean/batch.mjs";
21
24
 
@@ -23,6 +26,8 @@ const here = dirname(fileURLToPath(import.meta.url));
23
26
 
24
27
  export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
25
28
  [--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
29
+ forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
30
+ forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
26
31
  forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
27
32
  forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
28
33
 
@@ -42,6 +47,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
42
47
  --release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
43
48
  (default: git tags v* of the ForgeDock checkout)
44
49
 
50
+ Per-release regression rule (forge#4628):
51
+ --require-met with --experiment: exit 1 unless every target is met (#4634; for a named post-deploy check)
52
+ --experiment <id> arms of the forge.yaml experiment: section (arms, ratio, metrics, targets, min_n) against its targets; n/a below min n
53
+ --emit-event with --experiment: emit one experiment.verdict event (met or missed; docs/EVENTS.md)
54
+ --by version median cost per run, wall p50, first-pass rate and needs-human rate of each release against the
55
+ previous one; a metric under --min-n runs is null and its verdict null, never a pass
56
+ --post-metrics <pr> post (or update) the <!-- FORGE:METRICS v1 --> snapshot comment on that release PR
57
+
45
58
  ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
46
59
  (DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
47
60
  thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
@@ -81,8 +94,11 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
81
94
  --issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
82
95
  `;
83
96
 
97
+ /** #4634: 0, or 1 when --require-met and the experiment's targets are not all met (a named post-deploy check gates on it). */
98
+ export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
99
+
84
100
  export function parseArgs(argv) {
85
- const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
101
+ const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
86
102
  const val = (i, name) => {
87
103
  const v = argv[i + 1];
88
104
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -112,8 +128,13 @@ export function parseArgs(argv) {
112
128
  try { if (!v || !Object.keys(parseSince(v)).length) throw bad(); } catch { throw bad(); }
113
129
  o.compare = v;
114
130
  }
131
+ else if (name === "--by") { const v = take(); if (v !== "version") throw new Error("--by must be version"); o.by = v; }
132
+ else if (name === "--experiment") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error("--experiment must be an experiment id"); o.experiment = v; }
133
+ else if (name === "--emit-event") o.emitEvent = true;
134
+ else if (name === "--post-metrics") { const v = take(); if (!/^[1-9]\d*$/.test(v)) throw new Error("--post-metrics must be a PR number"); o.postMetrics = Number(v); }
135
+ else if (name === "--require-met") o.requireMet = true;
115
136
  else if (name === "--offline") o.offline = true;
116
- else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
137
+ else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); o.minNSet = true; }
117
138
  else if (name === "--json") o.json = true;
118
139
  else if (name === "--refresh") o.refresh = true;
119
140
  else if (name === "--compact") o.compact = true;
@@ -125,6 +146,7 @@ export function parseArgs(argv) {
125
146
  if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
126
147
  if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
127
148
  if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
149
+ if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
128
150
  return o;
129
151
  }
130
152
 
@@ -287,6 +309,12 @@ export async function buildReport(opts, deps = {}) {
287
309
  }
288
310
  const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
289
311
  { minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
312
+ if (opts.experiment) { // forge#4629
313
+ const exp = deps.experiment ?? readExperiment();
314
+ if (!exp || exp.id !== opts.experiment) throw new Error(`experiment ${opts.experiment} is not declared in forge.yaml (experiment: id, arms, ...)`);
315
+ return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { experiment: experimentReport([...model.runs, ...local], { ...exp, minN: opts.minNSet ? opts.minN : exp.minN }) } };
316
+ }
317
+ if (opts.by) return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { release_metrics: releaseMetrics(report.groups, { minN: opts.minN, repo }) } };
290
318
  // forge#4354: fail-soft alerts from the local run logs only (no gh call)
291
319
  const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
292
320
  return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
@@ -299,6 +327,10 @@ export async function runStats(argv, deps = {}) {
299
327
  return 2;
300
328
  }
301
329
  if (opts.help) { process.stdout.write(HELP); return 0; }
330
+ if (opts.requireMet && !opts.experiment) {
331
+ process.stderr.write(`forgedock stats: --require-met needs --experiment <id>\n`);
332
+ return 2;
333
+ }
302
334
  opts.repo = resolveRepo(opts.repo);
303
335
  if (!opts.repo || !isValidRepo(opts.repo)) {
304
336
  process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
@@ -306,6 +338,14 @@ export async function runStats(argv, deps = {}) {
306
338
  }
307
339
  try {
308
340
  const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
341
+ if (report.query?.release_metrics) {
342
+ const rm = report.query.release_metrics;
343
+ if (opts.postMetrics) await postMetrics({ gh: deps.gh ?? defaultGh, repo: opts.repo, pr: opts.postMetrics, body: metricsComment(rm) });
344
+ process.stdout.write(`${opts.json ? renderJson(report) : renderReleaseMetrics(rm)}\n`);
345
+ return 0;
346
+ }
347
+ if (report.query?.experiment && opts.emitEvent) await (deps.emitExperiment ?? (async (r, x) => (await import("../engine-cli.mjs")).experimentEventSink(r, x)))(opts.repo, report.query.experiment);
348
+ if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
309
349
  if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
310
350
  if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
311
351
  if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4628: per-release regression rule over the `aggregate()` groups. Each metric is null when its own sample is
5
+ * below minN; a verdict is null when either side is null. There is no "pass" value, only regressed|improved|flat|null.
6
+ */
7
+ import { DEFAULT_MIN_N, UNKNOWN, compareVersions } from "./aggregate.mjs";
8
+
9
+ export const METRICS_MARKER = "<!-- FORGE:METRICS v1 -->";
10
+ const isNum = (x) => typeof x === "number" && Number.isFinite(x);
11
+ // lower is better for all but the first-pass rate
12
+ const METRICS = [
13
+ { key: "usd_median", label: "median cost/run", lowerBetter: true, flat: 0.05 },
14
+ { key: "wall_p50_ms", label: "wall p50", lowerBetter: true, flat: 0.05 },
15
+ { key: "first_pass_rate", label: "first-pass rate", lowerBetter: false, flat: 0.02 },
16
+ { key: "needs_human_rate", label: "needs-human rate", lowerBetter: true, flat: 0.02 },
17
+ ];
18
+
19
+ /** The metric values of one group, each null below minN (its own n). */
20
+ function valuesOf(g, minN) {
21
+ const pick = (n, v) => (isNum(n) && n >= minN && isNum(v) ? v : null);
22
+ return {
23
+ n: g.n,
24
+ usd_median: pick(g.cost?.usdN, g.cost?.usdMedian),
25
+ wall_p50_ms: pick(g.time?.wall?.n, g.time?.wall?.p50),
26
+ first_pass_rate: pick(g.firstPass?.n, g.firstPass?.rate),
27
+ needs_human_rate: pick(g.flow?.n, g.flow?.needsHumanPct),
28
+ };
29
+ }
30
+
31
+ /** Relative change for cost and time, absolute for rates; flat inside the tolerance. */
32
+ function verdict(m, cur, prev) {
33
+ if (!isNum(cur) || !isNum(prev)) return null;
34
+ const rate = m.key.endsWith("_rate");
35
+ const d = rate ? cur - prev : prev === 0 ? (cur === 0 ? 0 : Infinity) : (cur - prev) / prev;
36
+ if (Math.abs(d) <= m.flat) return "flat";
37
+ return (d < 0) === m.lowerBetter ? "improved" : "regressed";
38
+ }
39
+
40
+ /** Known versions of one repo, oldest first, each with its metrics and the verdicts against the previous one. */
41
+ export function releaseMetrics(groups, { minN = DEFAULT_MIN_N, repo = null } = {}) {
42
+ const known = groups.filter((g) => g.version !== UNKNOWN && (repo == null || g.repo === repo))
43
+ .sort((a, b) => compareVersions(a.version, b.version));
44
+ const versions = known.map((g, i) => {
45
+ const metrics = valuesOf(g, minN), prev = i ? valuesOf(known[i - 1], minN) : null;
46
+ const verdicts = Object.fromEntries(METRICS.map((m) => [m.key, prev ? verdict(m, metrics[m.key], prev[m.key]) : null]));
47
+ return { version: g.version, against: i ? known[i - 1].version : null, ...metrics, verdicts };
48
+ });
49
+ return { repo, minN, versions };
50
+ }
51
+
52
+ const fmt = (key, v) => (v == null ? "n/a" : key === "usd_median" ? `$${v.toFixed(2)}` : key === "wall_p50_ms" ? `${(v / 60000).toFixed(1)}m` : `${(v * 100).toFixed(0)}%`);
53
+
54
+ export function renderReleaseMetrics(rm) {
55
+ const rows = rm.versions.map((v) => [v.version, String(v.n), ...METRICS.map((m) => `${fmt(m.key, v[m.key])} (${v.verdicts[m.key] ?? "n/a"})`)]);
56
+ const head = ["version", "n", ...METRICS.map((m) => m.label)];
57
+ const line = (r) => `| ${r.join(" | ")} |`;
58
+ return [`Release metrics (min n ${rm.minN}; n/a below it)`, line(head), line(head.map(() => "---")), ...rows.map(line)].join("\n");
59
+ }
60
+
61
+ /** The FORGE:METRICS comment: marker, table and the snapshot as fenced JSON. */
62
+ export function metricsComment(rm) {
63
+ return `${METRICS_MARKER}\n${renderReleaseMetrics(rm)}\n\n\`\`\`json\n${JSON.stringify(rm)}\n\`\`\`\n`;
64
+ }
65
+
66
+ /** Create the FORGE:METRICS comment on the PR, or update the existing one (argv-based gh). */
67
+ export async function postMetrics({ gh, repo, pr, body }) {
68
+ const raw = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`]);
69
+ const pages = String(raw ?? "").trim().replace(/\]\s*\[/g, ",");
70
+ const list = pages ? JSON.parse(pages.startsWith("[") ? pages : `[${pages}]`) : [];
71
+ const mine = list.filter((c) => typeof c?.body === "string" && c.body.startsWith(METRICS_MARKER)).pop();
72
+ if (mine) await gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${mine.id}`, "-f", `body=${body}`]);
73
+ else await gh(["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
74
+ return mine ? "updated" : "created";
75
+ }
@@ -0,0 +1,68 @@
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 { aggregate } from "./aggregate.mjs";
6
+ import { parseArgs } from "./index.mjs";
7
+ import { METRICS_MARKER, metricsComment, postMetrics, releaseMetrics, renderReleaseMetrics } from "./release.mjs";
8
+
9
+ const trig = (fp) => [{ trigger: "build" }, ...(fp ? [] : [{ trigger: "review" }])];
10
+ function runs(version, n, { usd, wall, fp, human }) {
11
+ return Array.from({ length: n }, (_, i) => ({
12
+ source: "github", repo: "o/r", version, issue: i + (version === "1.2.0" ? 100 : 0),
13
+ finished: true, usageEvents: 1, reason: i < human ? "needs-human" : "merged", wallMs: wall, endTs: "2026-10-01T00:00:00Z",
14
+ phases: { build: { usd, cr: 1, ms: 1 } }, builderAttempts: trig(i < fp),
15
+ }));
16
+ }
17
+ const groupsFor = (all, minN = 10) => aggregate({ runs: all }, { minN }).groups;
18
+
19
+ describe("releaseMetrics (#4628)", () => {
20
+ const prev = runs("1.1.0", 12, { usd: 2, wall: 600000, fp: 6, human: 1 });
21
+ it("compares each metric with the previous release and never reports a pass", () => {
22
+ const cur = runs("1.2.0", 12, { usd: 3, wall: 300000, fp: 11, human: 1 });
23
+ const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
24
+ const v = rm.versions[1];
25
+ assert.equal(v.against, "1.1.0");
26
+ assert.deepEqual(v.verdicts, { usd_median: "regressed", wall_p50_ms: "improved", first_pass_rate: "improved", needs_human_rate: "flat" });
27
+ assert.equal(rm.versions[0].verdicts.usd_median, null);
28
+ assert.ok(!JSON.stringify(rm).includes("pass\""));
29
+ });
30
+ it("is null below min n, and the verdict is null when either side is", () => {
31
+ const cur = runs("1.2.0", 3, { usd: 9, wall: 1, fp: 0, human: 3 });
32
+ const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
33
+ const v = rm.versions[1];
34
+ assert.deepEqual([v.usd_median, v.wall_p50_ms, v.first_pass_rate, v.needs_human_rate], [null, null, null, null]);
35
+ assert.deepEqual(Object.values(v.verdicts), [null, null, null, null]);
36
+ assert.match(renderReleaseMetrics(rm), /n\/a/);
37
+ });
38
+ it("skips unknown versions", () => {
39
+ const rm = releaseMetrics(groupsFor(runs(null, 12, { usd: 1, wall: 1, fp: 1, human: 0 })), { minN: 1 });
40
+ assert.deepEqual(rm.versions, []);
41
+ });
42
+ });
43
+
44
+ describe("FORGE:METRICS comment (#4628)", () => {
45
+ const rm = releaseMetrics([], { minN: 10, repo: "o/r" });
46
+ it("carries the marker and the JSON snapshot", () => {
47
+ const b = metricsComment(rm);
48
+ assert.ok(b.startsWith(METRICS_MARKER));
49
+ assert.deepEqual(JSON.parse(/```json\n(.*)\n```/s.exec(b)[1]), rm);
50
+ });
51
+ it("creates the comment, then updates it", async () => {
52
+ const calls = [];
53
+ const mk = (list) => (args) => { calls.push(args.join(" ")); return args[0] === "api" && args.length === 3 ? JSON.stringify(list) : ""; };
54
+ assert.equal(await postMetrics({ gh: mk([{ id: 1, body: "x" }]), repo: "o/r", pr: 7, body: "b" }), "created");
55
+ assert.match(calls.at(-1), /-X POST repos\/o\/r\/issues\/7\/comments/);
56
+ assert.equal(await postMetrics({ gh: mk([{ id: 5, body: `${METRICS_MARKER}\nold` }]), repo: "o/r", pr: 7, body: "b" }), "updated");
57
+ assert.match(calls.at(-1), /-X PATCH repos\/o\/r\/issues\/comments\/5/);
58
+ });
59
+ });
60
+
61
+ describe("--by / --post-metrics parsing (#4628)", () => {
62
+ it("accepts version and a PR number; rejects the rest", () => {
63
+ assert.equal(parseArgs(["--by", "version", "--post-metrics", "12"]).postMetrics, 12);
64
+ assert.throws(() => parseArgs(["--by", "repo"]), /--by must be version/);
65
+ assert.throws(() => parseArgs(["--post-metrics", "12"]), /needs --by version/);
66
+ assert.throws(() => parseArgs(["--by", "version", "--post-metrics", "x"]), /PR number/);
67
+ });
68
+ });
@@ -102,7 +102,10 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
102
102
  `--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
103
103
  `forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
104
104
  `forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
105
- for them. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
105
+ for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
106
+ (`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
107
+ `forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
108
+ return to pending with it; a finished batch is reopened); `--add` skips such an issue as `already in batch`. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
106
109
  retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
107
110
  the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
108
111
  deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
@@ -137,7 +140,8 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
137
140
  ran (no lease, comment or worktree). Report the code and its detail (for example a missing `milestone/<slug>`
138
141
  branch), and mark the issue's dependents skipped. Do not retry it with a different `--lane`.
139
142
  - `dependency-blocked`: its dependency is open and labelled `needs-human`. Report it as blocked by that issue,
140
- do not label it `needs-human`, and mark its dependents skipped.
143
+ do not label it `needs-human`, and mark its dependents skipped. Once the blocker is cleared, `forgedock orchestrate --requeue <id> N...`
144
+ returns the issue and its skipped dependents to the batch.
141
145
  - `decomposed`: the builder split the issue and the engine filed sub-issues (listed in its output, each with
142
146
  `**Parent**` and `**Depends on**` lines). Add them to the DAG, launch them, and let the parent's dependents
143
147
  wait on the sub-issues.
@@ -159,7 +163,8 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
159
163
  - `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
160
164
  external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
161
165
  the issue's last comment for the reason, mark its dependents skipped, and add it to the escalation list (step 4). Only
162
- explicit or logical edges skip a dependent; an issue that merely overlaps the same file keeps running.
166
+ explicit or logical edges skip a dependent (after you decide the block in scope, clear the label and run
167
+ `forgedock orchestrate --requeue <id> N... --reason "..."`, never a second batch); an issue that merely overlaps the same file keeps running.
163
168
  - `needs-human` with a **terminal code** that is neither recoverable nor human (for example a `conformance.*` code, or
164
169
  `contract.invalid` without a mid-run issue edit): the runner does not recover it and it is not an escalation. The
165
170
  batch report lists it as `unrecovered: #N (<code>: <detail>)`, like a recovery that did not merge. Mark its dependents