forgedock 1.30.0 → 1.32.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 (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -68,6 +68,7 @@ export const OUTCOME_CODES = Object.freeze({
68
68
  BUILDER_BLOCK_UNTYPED: "builder.block_untyped",
69
69
  BUILDER_NO_EVIDENCE: "builder.no_evidence",
70
70
  BUILDER_NO_COMMIT: "builder.no_commit",
71
+ BUDGET_EXCEEDED: "budget.exceeded", // forge#4392: the implement turn budget for the lane's size was spent and the wrap-up did not finish; digest in detail
71
72
  REVIEW_FAILED: "review.failed",
72
73
  REVIEW_NO_VERDICT: "review.no_verdict",
73
74
  // Core work-on conformance gate (bin/lean/conformance.mjs).
@@ -182,13 +183,13 @@ export function codeForTerminalReason(reason) {
182
183
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
183
184
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
184
185
  */
185
- export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
186
+ export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.BUDGET_EXCEEDED]);
186
187
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
187
188
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
188
189
 
189
190
  /**
190
191
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
191
- * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `contract.invalid` after a mid-run issue
192
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `budget.exceeded`, `contract.invalid` after a mid-run issue
192
193
  * edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
193
194
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
194
195
  */
@@ -1,17 +1,21 @@
1
1
  /**
2
2
  * Append-only per-issue run-log (the crash-safe local hot path).
3
- * One JSON event per line at {dir}/{issue}.jsonl. seq is assigned monotonically.
3
+ * One JSON event per line at {dir}/{owner}__{repo}__{issue}.jsonl (#4432: issue numbers are per repo), or the legacy
4
+ * {dir}/{issue}.jsonl when no repo is given. seq is assigned monotonically.
4
5
  */
5
- import { readFileSync, appendFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
 
8
- function logPath(dir, issue) { return join(dir, `${issue}.jsonl`); }
9
+ const segment = (s) => { const v = String(s ?? "").replace(/[^A-Za-z0-9._-]/g, "_"); return !v || /^\.+$/.test(v) ? "_" : v; };
10
+ /** #4432: `<owner>__<repo>__<issue>` with a repo (same scheme as pitfalls.mjs), else the legacy `<issue>`. */
11
+ export const runLogKey = (issue, repo = null) => (repo ? `${String(repo).split("/").map(segment).join("__")}__${issue}` : String(issue));
12
+ export function runLogPath(dir, issue, repo = null) { return join(dir, `${runLogKey(issue, repo)}.jsonl`); }
9
13
 
10
- /** Append an event; assigns the next seq based on the current line count. */
11
- export function appendEvent(dir, issue, event) {
14
+ /** Append an event; assigns the next seq based on the current line count (of the repo file only). */
15
+ export function appendEvent(dir, issue, event, { repo = null } = {}) {
12
16
  mkdirSync(dir, { recursive: true });
13
- const path = logPath(dir, issue);
14
- const seq = readLog(dir, issue).length + 1;
17
+ const path = runLogPath(dir, issue, repo);
18
+ const seq = parseLog(path).length + 1;
15
19
  // forge#3696: wall-clock ISO-8601 `ts` on every appended event. Placed before the
16
20
  // spread so a caller-supplied `ts` wins. rewriteLog() deliberately does not stamp:
17
21
  // a hydrated rewrite time would be a false timestamp. Old logs lack `ts` and still parse.
@@ -23,16 +27,39 @@ export function appendEvent(dir, issue, event) {
23
27
  * Used to reconstruct the local log from a remote FORGE:STATE index on
24
28
  * hydrate (C2), so the local log and the GitHub-wins state stay consistent.
25
29
  */
26
- export function rewriteLog(dir, issue, events) {
30
+ export function rewriteLog(dir, issue, events, { repo = null } = {}) {
27
31
  mkdirSync(dir, { recursive: true });
28
- const path = logPath(dir, issue);
32
+ const path = runLogPath(dir, issue, repo);
29
33
  const lines = events.map((e, i) => JSON.stringify({ seq: i + 1, ...e }));
30
34
  writeFileSync(path, lines.length ? lines.join("\n") + "\n" : "");
31
35
  }
32
36
 
33
- /** Read all complete events; a truncated final line (crash mid-write) is ignored. */
34
- export function readLog(dir, issue) {
35
- const path = logPath(dir, issue);
37
+ /**
38
+ * Read all complete events; a truncated final line (crash mid-write) is ignored.
39
+ * #4432: with a repo whose file does not exist yet, the legacy `<issue>.jsonl` slices of that repo are returned
40
+ * (read fallback for one release: remove after one release, together with migrateRunLogs).
41
+ */
42
+ export function readLog(dir, issue, { repo = null } = {}) {
43
+ const path = runLogPath(dir, issue, repo);
44
+ if (repo && !existsSync(path)) return repoSlices(parseLog(runLogPath(dir, issue)), repo);
45
+ return parseLog(path);
46
+ }
47
+
48
+ /** The RUN_START slices of `events` whose RUN_START.repo is `repo` (events before the first RUN_START excluded). */
49
+ function repoSlices(events, repo) {
50
+ let cur = null;
51
+ const out = [];
52
+ for (const e of events) {
53
+ if (e.event === "RUN_START") cur = e.repo ?? null;
54
+ if (cur != null && cur === repo) out.push(e);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** #4432: the events of one run-log file by path (for enumerators that list the dir); same parse rules as readLog. */
60
+ export const readLogFile = (path) => parseLog(path);
61
+
62
+ function parseLog(path) {
36
63
  if (!existsSync(path)) return [];
37
64
  const lines = readFileSync(path, "utf-8").split("\n");
38
65
  // Drop trailing empty lines to find the last non-empty line.
@@ -55,6 +82,49 @@ export function readLog(dir, issue) {
55
82
  return out;
56
83
  }
57
84
 
85
+ /**
86
+ * #4432: split each legacy `<issue>.jsonl` (shared by every repo on the host) into the repo files by RUN_START.repo.
87
+ * The file is claimed by an atomic rename (one winner); migrated slices go before any events already in the repo
88
+ * file, seq renumbered 1..n (appendEvent derives seq from the line count), other fields kept. Slices without a repo
89
+ * (and events before the first RUN_START) stay in `<issue>.jsonl`. Idempotent, never throws. Remove after one release.
90
+ */
91
+ export function migrateRunLogs(dir) {
92
+ const res = { files: 0, slices: 0, kept: 0 };
93
+ let names = [];
94
+ try { names = readdirSync(dir); } catch { return res; }
95
+ for (const f of names) {
96
+ const m = /^(\d+)\.jsonl$/.exec(f);
97
+ if (!m) continue;
98
+ const legacy = join(dir, f), claim = `${legacy}.migrating-${process.pid}`;
99
+ try { renameSync(legacy, claim); } catch { continue; } // another process won, or it is gone
100
+ try {
101
+ let events = [];
102
+ try { events = parseLog(claim); } catch { renameSync(claim, legacy); continue; } // corrupt: leave as it was
103
+ const byRepo = new Map(), kept = [];
104
+ let cur = null;
105
+ for (const e of events) {
106
+ if (e.event === "RUN_START") { cur = e.repo ?? null; if (cur) { byRepo.set(cur, byRepo.get(cur) ?? []); res.slices++; } else res.kept++; }
107
+ (cur ? byRepo.get(cur) : kept).push(e);
108
+ }
109
+ for (const [repo, evs] of byRepo) {
110
+ const target = runLogPath(dir, m[1], repo), tmp = `${target}.tmp-${process.pid}`;
111
+ const all = [...evs, ...parseLog(target)].map(({ seq, ...e }, i) => JSON.stringify({ seq: i + 1, ...e }));
112
+ writeFileSync(tmp, all.join("\n") + "\n");
113
+ renameSync(tmp, target);
114
+ }
115
+ if (kept.length) {
116
+ const n = parseLog(legacy).length; // a repo-less writer may have recreated it meanwhile
117
+ appendFileSync(legacy, kept.map(({ seq, ...e }, i) => JSON.stringify({ seq: n + i + 1, ...e })).join("\n") + "\n");
118
+ }
119
+ unlinkSync(claim);
120
+ res.files++;
121
+ } catch {
122
+ try { if (existsSync(claim)) renameSync(claim, legacy); } catch { /* best effort */ }
123
+ }
124
+ }
125
+ return res;
126
+ }
127
+
58
128
  /** Fold events into a RunState. The commit rule lives here. */
59
129
  /**
60
130
  * #4109: the run log is shared by the full and the core path. Keep only the events of runs whose `path` (from RUN_START;
@@ -3,8 +3,10 @@
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, *_tokens)
7
- * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would and the pr)
6
+ * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
7
+ * lean adds baseline_tokens and lane_profile, forge#4395)
8
+ * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
9
+ * builder_tier/class/model/escalated 0|1, forge#4394)
8
10
  * SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
9
11
  * STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
10
12
  * lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
@@ -51,15 +53,20 @@ export function mapRecord(rec, wallMs) {
51
53
  if (!rec || typeof rec !== "object") return null;
52
54
  if (rec.event === "PHASE_ATTEMPT_USAGE") {
53
55
  return { event: "phase_attempt", phase: rec.phase, payload: compact({
54
- attempt: rec.attempt, model: rec.model, backend: rec.backend ?? undefined, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
56
+ attempt: rec.attempt, trigger: rec.trigger ?? undefined, restart_of: rec.restart_of ?? undefined, model: rec.model, backend: rec.backend ?? undefined, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
55
57
  cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
58
+ cost_usd_delta: Number.isFinite(rec.cost_usd_delta) ? Math.round(rec.cost_usd_delta * 1e6) / 1e6 : undefined, // #4407
59
+ cost_usd_session: Number.isFinite(rec.cost_usd_session) ? Math.round(rec.cost_usd_session * 1e6) / 1e6 : undefined,
56
60
  input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
57
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,
58
63
  }) };
59
64
  }
60
65
  if (rec.event === "RUN_TERMINAL") {
61
66
  return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
62
- milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would }) };
67
+ milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would,
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 }) };
63
70
  }
64
71
  if (rec.event === "SANDBOX_USAGE") {
65
72
  return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,
@@ -14,8 +14,26 @@ function harness(over = {}) {
14
14
  }
15
15
 
16
16
  describe("engine telemetry seam", () => {
17
+ it("#4362: phase_attempt carries the trigger, omitted when absent", () => {
18
+ assert.equal(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 3, trigger: "ci_red" }, 0).payload.trigger, "ci_red");
19
+ assert.equal("trigger" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 1, trigger: null }, 0).payload, false);
20
+ assert.equal("trigger" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 1 }, 0).payload, false);
21
+ });
22
+
23
+ it("forge#4395: phase_attempt carries baseline_tokens and lane_profile, omitted when absent", () => {
24
+ const p = mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 1, baseline_tokens: 21000, lane_profile: "lane" }, 0).payload;
25
+ assert.equal(p.baseline_tokens, 21000); assert.equal(p.lane_profile, "lane");
26
+ const q = mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 1 }, 0).payload;
27
+ assert.equal("baseline_tokens" in q, false); assert.equal("lane_profile" in q, false);
28
+ });
29
+
17
30
  it("forge#4257: phase_attempt carries the backend, omitted when unreported", () => {
18
31
  assert.equal(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: "api" }, 0).payload.backend, "api");
32
+ // #4407: the per-attempt cost and the session running total ride along; absent fields are omitted
33
+ const c = mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 3, cost_usd: 2.5, cost_usd_delta: 0.123456789, cost_usd_session: 2.5 }, 0).payload;
34
+ assert.deepEqual([c.cost_usd, c.cost_usd_delta, c.cost_usd_session], [2.5, 0.123457, 2.5]);
35
+ const none = mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt: 1, cost_usd: 1, cost_usd_delta: null }, 0).payload;
36
+ assert.equal("cost_usd_delta" in none, false); assert.equal("cost_usd_session" in none, false);
19
37
  assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: null }, 0).payload, false);
20
38
  assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1 }, 0).payload, false);
21
39
  });