forgedock 1.34.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -0,0 +1,152 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Versioned outbound lifecycle events (#4630, docs/EVENTS.md). The engine hands the same run-log shaped records it gives
5
+ * the telemetry seam to `makeEventEmitter`, which maps them to a stable envelope, appends it to a local JSONL file and,
6
+ * when forge.yaml `events.webhook_url` and the secret env var are both set, POSTs it signed with HMAC-SHA256. No model call.
7
+ * The emitter NEVER throws; FORGE_EVENTS=off disables it. Values are ids, counts, codes and costs only.
8
+ */
9
+ import { createHmac, randomBytes } from "node:crypto";
10
+ import { appendFileSync, mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ export const EVENT_SCHEMA = "forgedock.event";
15
+ export const EVENT_VERSION = 1;
16
+ export const DEFAULT_SECRET_ENV = "FORGE_EVENTS_SECRET";
17
+ export const WEBHOOK_TIMEOUT_MS = 5000;
18
+
19
+ /** type -> data keys (documented in docs/EVENTS.md; the contract test pins them). */
20
+ export const EVENT_TYPES = {
21
+ "lane.picked_up": ["lane", "path"],
22
+ "contract.frozen": ["hash", "requirements", "deferred", "derived"],
23
+ "pr.opened": [],
24
+ "pr.merged": ["milestone", "code"],
25
+ "release.promoted": ["fix", "default_branch"],
26
+ "probe.passed": ["detail", "fix"],
27
+ "probe.failed": ["detail", "fix"],
28
+ "regression.detected": ["subject", "metric", "detail"],
29
+ "experiment.verdict": ["experiment", "verdict", "detail"],
30
+ };
31
+
32
+ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "boolean" || (typeof v === "string" && v !== ""));
33
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
34
+ const pickData = (type, src) => Object.fromEntries(EVENT_TYPES[type].filter((k) => scalar(src?.[k])).map((k) => [k, typeof src[k] === "string" ? src[k].slice(0, 200) : src[k]]));
35
+
36
+ /** Envelope for one event; null for an unknown type. `ctx` = {repo, issue, run, pr, cost_usd, wall_ms}. */
37
+ export function buildEvent(type, data = {}, ctx = {}, now = new Date()) {
38
+ if (!Object.hasOwn(EVENT_TYPES, type)) return null;
39
+ const int = (v) => (Number.isSafeInteger(v) && v > 0 ? v : null);
40
+ return {
41
+ schema: EVENT_SCHEMA, v: EVENT_VERSION, id: `${Math.floor(now.getTime() / 1000)}-${randomBytes(6).toString("hex")}`, type,
42
+ ts: now.toISOString().replace(/\.\d+Z$/, "Z"), repo: ctx.repo ?? null, issue: int(ctx.issue), pr: int(ctx.pr), run: ctx.run ?? null,
43
+ ...(Number.isFinite(ctx.cost_usd) ? { cost_usd: round6(ctx.cost_usd) } : {}), ...(Number.isFinite(ctx.wall_ms) ? { wall_ms: Math.round(ctx.wall_ms) } : {}),
44
+ data: pickData(type, data),
45
+ };
46
+ }
47
+
48
+ /** Probe sweep row ({issue, outcome, detail, fix}) -> event type + data; null unless pass or fail. */
49
+ export function probeEvent(row) {
50
+ if (row?.outcome !== "pass" && row?.outcome !== "fail") return null;
51
+ return { type: row.outcome === "pass" ? "probe.passed" : "probe.failed", data: { detail: row.detail, fix: row.fix }, ctx: { issue: row.issue } };
52
+ }
53
+
54
+ /** Monitor sweep row ({issue, outcome, detail, regression}) -> regression.detected; null unless a fail that filed a regression issue. */
55
+ export function regressionEvent(row) {
56
+ if (row?.outcome !== "fail" || !Number.isSafeInteger(row.regression) || row.regression < 1) return null;
57
+ return { type: "regression.detected", data: { subject: `post-deploy check of #${row.issue}`, metric: "post_deploy_check", detail: row.detail }, ctx: { issue: row.issue } };
58
+ }
59
+
60
+ /** Experiment report ({id, targets, targets_met}) -> experiment.verdict (met | missed | inconclusive); null without an id. */
61
+ export function experimentEvent(report) {
62
+ if (!report || typeof report.id !== "string" || !report.id) return null;
63
+ const passes = Object.values(report.targets ?? {}).map((t) => t?.pass);
64
+ const verdict = report.targets_met === true ? "met" : passes.includes(false) ? "missed" : "inconclusive";
65
+ return { type: "experiment.verdict", data: { experiment: report.id, verdict, detail: `${passes.length} target(s)` }, ctx: {} };
66
+ }
67
+
68
+ /** A fix reached the default branch -> release.promoted; null without a fix. Reserved: no detection emits it yet. */
69
+ export function promotedEvent({ fix, default_branch } = {}) {
70
+ return typeof fix === "string" && fix ? { type: "release.promoted", data: { fix, default_branch }, ctx: {} } : null;
71
+ }
72
+
73
+ /**
74
+ * Pure mapping of one run-log record to {type, data, ctx} or null. `cost` is the lane's running spend, kept by the caller
75
+ * from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
76
+ */
77
+ export function mapRunRecord(rec, { cost = 0 } = {}) {
78
+ if (!rec || typeof rec !== "object") return null;
79
+ switch (rec.event) {
80
+ case "RUN_START": return { type: "lane.picked_up", data: rec, ctx: {} };
81
+ case "CONTRACT_FROZEN": return { type: "contract.frozen", data: rec, ctx: {} };
82
+ case "PR_OPENED": return { type: "pr.opened", data: {}, ctx: { pr: rec.pr } };
83
+ case "RUN_TERMINAL":
84
+ if (rec.reason !== "merged") return null;
85
+ return { type: "pr.merged", data: rec, ctx: { pr: rec.pr, cost_usd: cost, wall_ms: rec.wall_ms } };
86
+ default: return null;
87
+ }
88
+ }
89
+
90
+ /** HMAC-SHA256 hex of `${ts}.${body}`; the receiver recomputes it from X-Forge-Timestamp and the raw body. */
91
+ export const sign = (secret, ts, body) => createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
92
+
93
+ /** https, or http only for a loopback host; anything else is not a usable webhook. */
94
+ export function webhookUrlOk(u) {
95
+ try {
96
+ const url = new URL(u);
97
+ return url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
98
+ } catch { return false; }
99
+ }
100
+
101
+ /** POST one envelope; resolves true on 2xx. Never throws. */
102
+ export async function postWebhook(event, { url, secret, fetchFn = globalThis.fetch, timeoutMs = WEBHOOK_TIMEOUT_MS, now = Date.now } = {}) {
103
+ try {
104
+ if (!url || !secret || !webhookUrlOk(url) || typeof fetchFn !== "function") return false;
105
+ const body = JSON.stringify(event);
106
+ const ts = String(Math.floor(now() / 1000));
107
+ const res = await fetchFn(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
108
+ headers: { "content-type": "application/json", "x-forge-event": event.type, "x-forge-timestamp": ts, "x-forge-signature": `sha256=${sign(secret, ts, body)}` }, body });
109
+ return Boolean(res?.ok);
110
+ } catch { return false; }
111
+ }
112
+
113
+ /** Local sink path: `dir` (config or FORGE_EVENTS_DIR), else ~/.forge/events; one file per repo. */
114
+ export const sinkPath = (repo, dir) => join(dir || join(homedir(), ".forge", "events"), `${String(repo || "unknown").replace(/[^\w.-]/g, "_")}.jsonl`);
115
+
116
+ /**
117
+ * @param {{repo?: string|null, config?: {webhookUrl?: string, secretEnv?: string, dir?: string}, env?: object, fetchFn?: Function, now?: () => Date, run?: string}} o
118
+ * @returns {{emit: (rec: object) => Promise<void>, emitEvent: (type: string, data?: object, ctx?: object) => Promise<object|null>}}
119
+ */
120
+ export function makeEventEmitter({ repo = null, config = {}, env = process.env, fetchFn, now = () => new Date(), run = null } = {}) {
121
+ let cost = 0, runId = run;
122
+ const off = () => (env.FORGE_EVENTS || "on") === "off";
123
+ async function emitEvent(type, data = {}, ctx = {}) {
124
+ try {
125
+ if (off()) return null;
126
+ const ev = buildEvent(type, data, { repo, run: runId, ...ctx }, now());
127
+ if (!ev) return null;
128
+ try {
129
+ const file = sinkPath(repo, env.FORGE_EVENTS_DIR || config.dir);
130
+ mkdirSync(join(file, ".."), { recursive: true });
131
+ appendFileSync(file, `${JSON.stringify(ev)}\n`);
132
+ } catch { /* a lost local line costs completeness only */ }
133
+ const secret = env[config.secretEnv || DEFAULT_SECRET_ENV];
134
+ if (config.webhookUrl && secret) await postWebhook(ev, { url: config.webhookUrl, secret, fetchFn, now: () => now().getTime() });
135
+ return ev;
136
+ } catch { return null; }
137
+ }
138
+ async function emit(rec) {
139
+ try {
140
+ if (rec?.event === "PHASE_ATTEMPT_USAGE") {
141
+ const c = Number.isFinite(rec.cost_usd_delta) ? rec.cost_usd_delta : rec.cost_usd;
142
+ if (Number.isFinite(c)) cost += c;
143
+ return;
144
+ }
145
+ if (rec?.event === "RUN_START" && typeof rec.run === "string") runId = rec.run;
146
+ const m = mapRunRecord(rec, { cost });
147
+ if (!m) return;
148
+ await emitEvent(m.type, m.data, { issue: rec.issue, ...m.ctx });
149
+ } catch { /* events never fail a run */ }
150
+ }
151
+ return { emit, emitEvent };
152
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Contract test for the outbound event stream (#4630, docs/EVENTS.md): changing a type, key or the signature is a breaking change.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { join } from "node:path";
9
+ import { createHmac } from "node:crypto";
10
+ import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
11
+
12
+ const tmp = () => tmpDir("forge-events-");
13
+ const fixed = () => new Date("2026-10-26T12:00:00Z");
14
+ const lines = (dir) => readFileSync(join(dir, "o_r.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
15
+
16
+ describe("event contract (v1)", () => {
17
+ it("documents exactly the named lifecycle types", () => {
18
+ assert.deepEqual(Object.keys(EVENT_TYPES), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged", "release.promoted", "probe.passed", "probe.failed", "regression.detected", "experiment.verdict"]);
19
+ });
20
+
21
+ it("every envelope has the same top-level keys and only documented data keys", () => {
22
+ for (const [type, keys] of Object.entries(EVENT_TYPES)) {
23
+ const data = Object.fromEntries(keys.map((k) => [k, "x"]));
24
+ const e = buildEvent(type, { ...data, secret: "no" }, { repo: "o/r", issue: 3, pr: 4, run: "core_3_1", cost_usd: 1.23456789, wall_ms: 10.4 }, fixed());
25
+ assert.deepEqual(Object.keys(e), ["schema", "v", "id", "type", "ts", "repo", "issue", "pr", "run", "cost_usd", "wall_ms", "data"]);
26
+ assert.equal(e.schema, "forgedock.event"); assert.equal(e.v, EVENT_VERSION); assert.equal(e.ts, "2026-10-26T12:00:00Z");
27
+ assert.equal(e.cost_usd, 1.234568); assert.equal(e.wall_ms, 10);
28
+ assert.deepEqual(Object.keys(e.data), keys);
29
+ }
30
+ assert.equal(buildEvent("nope", {}, {}, fixed()), null);
31
+ });
32
+
33
+ it("maps run-log records and sums attempt cost onto pr.merged", async () => {
34
+ const dir = tmp();
35
+ const { emit } = makeEventEmitter({ repo: "o/r", config: { dir }, env: {}, now: fixed });
36
+ await emit({ event: "RUN_START", run: "core_9_5", issue: 9, lane: "staging", path: "core" });
37
+ await emit({ event: "CONTRACT_FROZEN", issue: 9, hash: "abc", requirements: 2, deferred: 1 });
38
+ await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 5, cost_usd_delta: 0.5 });
39
+ await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 0.25 });
40
+ await emit({ event: "PR_OPENED", issue: 9, pr: 40 });
41
+ await emit({ event: "RUN_TERMINAL", issue: 9, reason: "needs-human", wall_ms: 1 });
42
+ await emit({ event: "RUN_TERMINAL", issue: 9, reason: "merged", pr: 40, wall_ms: 90000, code: "ok", milestone: "m1" });
43
+ const got = lines(dir);
44
+ assert.deepEqual(got.map((e) => e.type), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged"]);
45
+ assert.deepEqual(got[0].data, { lane: "staging", path: "core" });
46
+ assert.equal(got[0].run, "core_9_5"); assert.equal(got[3].run, "core_9_5");
47
+ assert.deepEqual(got[1].data, { hash: "abc", requirements: 2, deferred: 1 });
48
+ assert.equal(got[2].pr, 40);
49
+ assert.equal(got[3].cost_usd, 0.75); assert.equal(got[3].wall_ms, 90000); assert.equal(got[3].issue, 9);
50
+ assert.equal(mapRunRecord({ event: "CI_PENDING_WAIT" }), null);
51
+ });
52
+
53
+ it("maps probe rows: only pass and fail", () => {
54
+ assert.equal(probeEvent({ issue: 1, outcome: "awaiting-promotion" }), null);
55
+ assert.equal(probeEvent({ issue: 1, outcome: "pass", detail: "exit 0", fix: "abc" }).type, "probe.passed");
56
+ assert.equal(probeEvent({ issue: 1, outcome: "fail" }).type, "probe.failed");
57
+ });
58
+ });
59
+
60
+ describe("regression, experiment and promoted builders (#4632)", () => {
61
+ it("regressionEvent: only a fail row that filed a regression issue", () => {
62
+ assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
63
+ assert.equal(regressionEvent({ issue: 5, outcome: "fail" }), null);
64
+ const r = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "broke" });
65
+ assert.equal(r.type, "regression.detected"); assert.equal(r.ctx.issue, 5);
66
+ assert.deepEqual(r.data, { subject: "post-deploy check of #5", metric: "post_deploy_check", detail: "broke" });
67
+ });
68
+ it("experimentEvent: met, missed, inconclusive", () => {
69
+ const t = (...p) => Object.fromEntries(p.map((pass, i) => [`t${i}`, { pass }]));
70
+ assert.equal(experimentEvent({ id: "e", targets: t(true), targets_met: true }).data.verdict, "met");
71
+ assert.equal(experimentEvent({ id: "e", targets: t(true, false), targets_met: false }).data.verdict, "missed");
72
+ assert.equal(experimentEvent({ id: "e", targets: t(null), targets_met: false }).data.verdict, "inconclusive");
73
+ assert.equal(experimentEvent({}), null);
74
+ });
75
+ it("promotedEvent needs a fix; types and keys stay pinned", () => {
76
+ assert.equal(promotedEvent({}), null);
77
+ assert.deepEqual(promotedEvent({ fix: "abc", default_branch: "main" }).data, { fix: "abc", default_branch: "main" });
78
+ assert.deepEqual(EVENT_TYPES["regression.detected"], ["subject", "metric", "detail"]);
79
+ assert.deepEqual(EVENT_TYPES["experiment.verdict"], ["experiment", "verdict", "detail"]);
80
+ });
81
+ it("a regression row is written to the sink", async () => {
82
+ const dir = tmp();
83
+ const p = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "d" });
84
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: {} }).emitEvent(p.type, p.data, p.ctx);
85
+ const ev = JSON.parse(readFileSync(join(dir, "o_r.jsonl"), "utf8"));
86
+ assert.equal(ev.type, "regression.detected"); assert.equal(ev.issue, 5);
87
+ });
88
+ });
89
+
90
+ describe("webhook", () => {
91
+ const cfg = { webhookUrl: "https://hooks.example/x", secretEnv: "MY_SECRET", dir: undefined };
92
+ it("signs `${timestamp}.${body}` with HMAC-SHA256 from the env secret", async () => {
93
+ const calls = [];
94
+ const fetchFn = async (url, init) => { calls.push([url, init]); return { ok: true }; };
95
+ const { emitEvent } = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: tmp() }, env: { MY_SECRET: "s3" }, fetchFn, now: fixed });
96
+ const ev = await emitEvent("pr.opened", {}, { issue: 1, pr: 2 });
97
+ assert.equal(calls.length, 1);
98
+ const [url, init] = calls[0];
99
+ assert.equal(url, cfg.webhookUrl); assert.equal(init.method, "POST");
100
+ assert.equal(init.body, JSON.stringify(ev));
101
+ const ts = init.headers["x-forge-timestamp"];
102
+ assert.equal(ts, String(Math.floor(fixed().getTime() / 1000)));
103
+ assert.equal(init.headers["x-forge-signature"], `sha256=${createHmac("sha256", "s3").update(`${ts}.${init.body}`).digest("hex")}`);
104
+ assert.equal(init.headers["x-forge-event"], "pr.opened");
105
+ assert.equal(sign("s3", ts, init.body), init.headers["x-forge-signature"].slice(7));
106
+ });
107
+
108
+ it("sends nothing without the secret, with a non-https URL, or when off", async () => {
109
+ let n = 0;
110
+ const fetchFn = async () => { n++; return { ok: true }; };
111
+ for (const [config, env] of [[cfg, {}], [{ ...cfg, webhookUrl: "http://evil.example/x" }, { MY_SECRET: "s" }], [cfg, { MY_SECRET: "s", FORGE_EVENTS: "off" }]]) {
112
+ await makeEventEmitter({ repo: "o/r", config: { ...config, dir: tmp() }, env, fetchFn }).emitEvent("pr.opened", {}, { issue: 1 });
113
+ }
114
+ assert.equal(n, 0);
115
+ assert.equal(webhookUrlOk("http://localhost:3000/h"), true); assert.equal(webhookUrlOk("ftp://x"), false);
116
+ });
117
+
118
+ it("off writes no file; sink and webhook failures never throw", async () => {
119
+ const dir = tmp();
120
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: { FORGE_EVENTS: "off" } }).emitEvent("pr.opened", {}, { issue: 1 });
121
+ assert.equal(existsSync(join(dir, "o_r.jsonl")), false);
122
+ const boom = async () => { throw new Error("net"); };
123
+ writeFileSync(join(dir, "f"), ""); const bad = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: join(dir, "f", "x") }, env: { MY_SECRET: "s" }, fetchFn: boom });
124
+ assert.ok(await bad.emitEvent("pr.opened", {}, { issue: 1 }));
125
+ await bad.emit({ event: "PR_OPENED", pr: 1, issue: 1 });
126
+ assert.equal(await postWebhook({ type: "x" }, { url: cfg.webhookUrl, secret: "s", fetchFn: boom }), false);
127
+ });
128
+ });
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
5
5
  * seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
6
- * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
6
+ * PHASE_ATTEMPT_USAGE -> `phase_attempt` (experiment + arm when forge.yaml declares one, forge#4629; attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
7
7
  * lean adds baseline_tokens and lane_profile, forge#4395)
8
8
  * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
9
9
  * builder_tier/class/model/escalated 0|1, forge#4394)
@@ -59,14 +59,15 @@ export function mapRecord(rec, wallMs) {
59
59
  cost_usd_session: Number.isFinite(rec.cost_usd_session) ? Math.round(rec.cost_usd_session * 1e6) / 1e6 : undefined,
60
60
  input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
61
61
  cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
62
- baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile,
62
+ baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile, experiment: rec.experiment, arm: rec.arm, // #4629
63
63
  }) };
64
64
  }
65
65
  if (rec.event === "RUN_TERMINAL") {
66
66
  return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
67
67
  milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would,
68
68
  builder_tier: rec.builder_tier, builder_class: rec.builder_class, builder_model: rec.builder_model, // #4394: the model per merge
69
- builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined }) };
69
+ builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined,
70
+ experiment: rec.experiment, arm: rec.arm }) }; // #4629
70
71
  }
71
72
  if (rec.event === "SANDBOX_USAGE") {
72
73
  return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,