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.
- package/README.md +78 -324
- package/bin/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +113 -18
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/lean/batch.mjs +42 -2
- package/bin/lean/batch.test.mjs +101 -1
- package/bin/lean/conformance.mjs +14 -3
- package/bin/lean/conformance.test.mjs +26 -0
- package/bin/lean/effects.mjs +3 -0
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +37 -4
- package/bin/lean/lean.test.mjs +124 -5
- package/bin/lean/orchestrate.mjs +49 -5
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +24 -4
- package/bin/lean/schedd.test.mjs +54 -0
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +49 -0
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
|
@@ -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; }
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -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}
|
|
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) {
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -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;
|
package/bin/stats/index.mjs
CHANGED
|
@@ -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
|
+
});
|
package/commands/orchestrate.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|