forgedock 1.16.1 → 1.17.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/bin/engine/outcome-codes.mjs +92 -0
- package/bin/engine/phases.mjs +105 -25
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +12 -1
- package/bin/engine.mjs +151 -39
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +6 -3
- package/commands/orchestrate/phase-4-execution.md +25 -10
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/quality-gate.md +4 -4
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +134 -57
- package/commands/review-pr.md +135 -119
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +5 -4
- package/commands/work-on/build.md +143 -80
- package/commands/work-on/close.md +19 -8
- package/commands/work-on/decompose.md +50 -8
- package/commands/work-on/investigate.md +14 -7
- package/commands/work-on/remediate.md +40 -14
- package/commands/work-on/review.md +22 -5
- package/commands/work-on.md +6 -5
- package/docs/CONFIG.md +37 -2
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +14 -1
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +41 -3
- package/scripts/diff-size.test.sh +47 -0
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -5
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/forge-resolve.sh +13 -4
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/remediate-liveness.sh +132 -0
- package/scripts/review-acceptance-gate.sh +141 -0
- package/scripts/review-acceptance-gate.test.sh +103 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed, versioned enum of engine outcome codes (forge#3833).
|
|
3
|
+
*
|
|
4
|
+
* Every PHASE_FAILED, blocked PHASE_COMMIT and RUN_TERMINAL run-log event carries
|
|
5
|
+
* one `code` from this enum so stall causes can be counted and grouped without a
|
|
6
|
+
* model reading prose. Codes are derived in code from structured evidence
|
|
7
|
+
* (REVIEW_RESULT fields, FORGE markers, `err.code`), never from free-text `detail`.
|
|
8
|
+
* The free-text `reason`/`detail` stays alongside for humans. Documented in
|
|
9
|
+
* docs/OUTCOME-CODES.md (a test asserts enum/doc parity). Pure module: no I/O.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bump when a code is renamed or removed; adding a code is additive. */
|
|
13
|
+
export const OUTCOME_CODES_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/** Fallback for an absent or unknown code; the raw reason is kept alongside. */
|
|
16
|
+
export const UNCLASSIFIED = "unclassified";
|
|
17
|
+
|
|
18
|
+
export const OUTCOME_CODES = Object.freeze({
|
|
19
|
+
REVIEW_CHANGES_REQUESTED: "review.changes_requested",
|
|
20
|
+
REVIEW_ESCALATED: "review.escalated",
|
|
21
|
+
REVIEW_REREVIEW_SKIPPED: "review.rereview_skipped",
|
|
22
|
+
BUILD_ACCEPTANCE_FAILED: "build.acceptance_failed",
|
|
23
|
+
BUILD_ACCEPTANCE_MALFORMED: "build.acceptance_malformed",
|
|
24
|
+
BUILD_SIZE_OVER_GATE: "build.size_over_gate",
|
|
25
|
+
BUILD_COHESIVE: "build.cohesive",
|
|
26
|
+
REMEDIATE_NO_MARKER: "remediate.no_marker",
|
|
27
|
+
REMEDIATE_DEFERRED_LIVE_RUN: "remediate.deferred_live_run",
|
|
28
|
+
REMEDIATE_RE_ESCALATED: "remediate.re_escalated",
|
|
29
|
+
REMEDIATE_UNFIXABLE: "remediate.unfixable",
|
|
30
|
+
CI_RED: "ci.red",
|
|
31
|
+
CI_TIMEOUT: "ci.timeout",
|
|
32
|
+
BASE_CONFLICT: "base.conflict",
|
|
33
|
+
RATE_LIMITED: "rate_limited",
|
|
34
|
+
ENGINE_ERROR: "engine.error",
|
|
35
|
+
LEASE_HELD: "lease.held",
|
|
36
|
+
INVALID: "invalid",
|
|
37
|
+
DECOMPOSED: "decomposed",
|
|
38
|
+
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
39
|
+
COMPLETED: "completed",
|
|
40
|
+
AWAITING_MERGE: "awaiting_merge",
|
|
41
|
+
UNCLASSIFIED,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const CODE_SET = new Set(Object.values(OUTCOME_CODES));
|
|
45
|
+
|
|
46
|
+
/** Every code, in declaration order. */
|
|
47
|
+
export const ALL_OUTCOME_CODES = Object.freeze([...CODE_SET]);
|
|
48
|
+
|
|
49
|
+
export function isOutcomeCode(code) {
|
|
50
|
+
return typeof code === "string" && CODE_SET.has(code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Absent or unknown -> `unclassified`; never throws. */
|
|
54
|
+
export function normalizeCode(code) {
|
|
55
|
+
return isOutcomeCode(code) ? code : UNCLASSIFIED;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Code for a thrown runner error, from `err.code` / session-limit evidence only. */
|
|
59
|
+
export function codeForRunnerError(err) {
|
|
60
|
+
if (!err || typeof err !== "object") return UNCLASSIFIED;
|
|
61
|
+
if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
|
|
62
|
+
return OUTCOME_CODES.RATE_LIMITED;
|
|
63
|
+
switch (err.code) {
|
|
64
|
+
case "NO_API_KEY":
|
|
65
|
+
case "NO_SDK":
|
|
66
|
+
case "CLI_BACKEND_FAILED":
|
|
67
|
+
case "PHASE_ARGS_INVALID":
|
|
68
|
+
case "CLI_MAX_TURNS":
|
|
69
|
+
return OUTCOME_CODES.ENGINE_ERROR;
|
|
70
|
+
default:
|
|
71
|
+
return UNCLASSIFIED;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default code for a terminal reason when the producer supplied none. Only reasons that
|
|
77
|
+
* name one cause map; `needs-human` has many causes, so it stays `unclassified`.
|
|
78
|
+
*/
|
|
79
|
+
export function codeForTerminalReason(reason) {
|
|
80
|
+
switch (reason) {
|
|
81
|
+
case "merged":
|
|
82
|
+
case "phase-complete":
|
|
83
|
+
case "investigation":
|
|
84
|
+
return OUTCOME_CODES.COMPLETED;
|
|
85
|
+
case "awaiting-merge": return OUTCOME_CODES.AWAITING_MERGE;
|
|
86
|
+
case "invalid": return OUTCOME_CODES.INVALID;
|
|
87
|
+
case "decomposed": return OUTCOME_CODES.DECOMPOSED;
|
|
88
|
+
case "engine-error": return OUTCOME_CODES.ENGINE_ERROR;
|
|
89
|
+
case "deferred": return OUTCOME_CODES.LEASE_HELD;
|
|
90
|
+
default: return UNCLASSIFIED;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// imports the identical registry, so the two can no longer drift apart the way
|
|
13
13
|
// they did in forge#2375/PR#2395.
|
|
14
14
|
import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
|
|
15
|
+
// forge#3833: closed outcome-code enum; every code below comes from structured evidence, never `detail` prose.
|
|
16
|
+
import { OUTCOME_CODES as OC } from "./outcome-codes.mjs";
|
|
15
17
|
|
|
16
18
|
// forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
|
|
17
19
|
// failures (e.g. an exhausted retry loop where the runner itself never once
|
|
@@ -48,12 +50,22 @@ export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed
|
|
|
48
50
|
*/
|
|
49
51
|
export function isTrustedComment(c) {
|
|
50
52
|
if (!c || typeof c !== "object") return false;
|
|
53
|
+
return isTrustedActor({ association: c.author_association, type: c.user?.type, login: c.user?.login });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The ONE actor predicate behind `isTrustedComment` and the issue-body
|
|
58
|
+
* FORGE:STATE gate (projector). `association` is the GitHub author association,
|
|
59
|
+
* `type` the actor's `__typename`/`user.type` ("Bot", "User"...), `login` its
|
|
60
|
+
* login. Missing fields fail closed.
|
|
61
|
+
*/
|
|
62
|
+
export function isTrustedActor({ association, type, login } = {}) {
|
|
51
63
|
const list = (v) => String(v).split(",").map((x) => x.trim()).filter(Boolean);
|
|
52
64
|
const assoc = list(process.env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR");
|
|
53
65
|
const logins = list(process.env.FORGE_TRAIL_TRUSTED_LOGINS ?? "");
|
|
54
|
-
return assoc.includes(
|
|
55
|
-
|| (
|
|
56
|
-
|| logins.includes(
|
|
66
|
+
return assoc.includes(association ?? "")
|
|
67
|
+
|| (type ?? "") === "Bot"
|
|
68
|
+
|| logins.includes(login ?? "");
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
@@ -97,6 +109,12 @@ async function issueMarkers(issue, io) {
|
|
|
97
109
|
|
|
98
110
|
/** Non-terminal held-resume record (remediate.md Phase M8, forge#3685): header and sentinel are the same line; never a COMPLETE marker. */
|
|
99
111
|
const REMEDIATION_HELD_MARKER = "FORGE:REMEDIATION_HELD";
|
|
112
|
+
/** Non-terminal deferral record (remediate.md Phase M0 liveness step): a live earlier run holds the lease. Never a COMPLETE/trail marker. */
|
|
113
|
+
const REMEDIATION_DEFERRED_MARKER = "FORGE:REMEDIATION_DEFERRED";
|
|
114
|
+
/** Per-dispatch record the engine posts on every remediate dispatch (forge#3807): scopes a DEFERRED record to its own dispatch. Never a COMPLETE/trail marker. */
|
|
115
|
+
const REMEDIATION_DISPATCH_MARKER = "FORGE:REMEDIATION_DISPATCH";
|
|
116
|
+
/** Lease holder ids the engine mints (`cli_<host>_<pid>`) are passed to remediate only when they match this shape (same as remediate-liveness.sh). */
|
|
117
|
+
const LEASE_HOLDER_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
100
118
|
const reEscape = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
101
119
|
/** True when `body` STARTS with the typed header `<!-- {header}` (e.g. `FORGE:BUILDER`). */
|
|
102
120
|
function startsWithHeader(body, header) {
|
|
@@ -182,15 +200,32 @@ export async function postCycleMarker(issue, n, io) {
|
|
|
182
200
|
* ALREADY_DONE when the newest gate marker is blocked (forge#3725).
|
|
183
201
|
*/
|
|
184
202
|
function latestGateState(comments) {
|
|
203
|
+
return latestGateMarker(comments).state;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Single newest-first scan shared by latestGateState and latestBlockedIndex so their
|
|
207
|
+
* predicates cannot drift. Returns the newest gate marker's state and its comment index
|
|
208
|
+
* (`{state: "none", index: -1}` when there is none).
|
|
209
|
+
*/
|
|
210
|
+
function latestGateMarker(comments) {
|
|
185
211
|
const gate = "FORGE:ACCEPTANCE_GATE";
|
|
186
212
|
for (let i = comments.length - 1; i >= 0; i--) {
|
|
187
213
|
const b = comments[i];
|
|
188
|
-
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return "blocked";
|
|
214
|
+
if (startsWithHeader(b, "FORGE:BUILD_BLOCKED")) return { state: "blocked", index: i };
|
|
189
215
|
if (!startsWithHeader(b, gate)) continue;
|
|
190
|
-
if (hasSentinelLine(b, `${gate}:PASSED`)) return "pass";
|
|
191
|
-
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return "blocked";
|
|
216
|
+
if (hasSentinelLine(b, `${gate}:PASSED`)) return { state: "pass", index: i };
|
|
217
|
+
if (hasSentinelLine(b, `${gate}:FAILED`) || hasSentinelLine(b, `${gate}:BLOCKED`)) return { state: "blocked", index: i };
|
|
192
218
|
}
|
|
193
|
-
return "none";
|
|
219
|
+
return { state: "none", index: -1 };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Comment index of the newest blocked gate marker, or -1 when the newest gate marker is
|
|
223
|
+
* not blocked (a newer PASSED clears it). Recency anchor for build.detectOutcome so an
|
|
224
|
+
* older blocked marker cannot shadow a strictly newer size-gate routing marker.
|
|
225
|
+
*/
|
|
226
|
+
function latestBlockedIndex(comments) {
|
|
227
|
+
const m = latestGateMarker(comments);
|
|
228
|
+
return m.state === "blocked" ? m.index : -1;
|
|
194
229
|
}
|
|
195
230
|
/**
|
|
196
231
|
* Investigation exit evidence (forge#3534): a trusted comment led by
|
|
@@ -261,18 +296,22 @@ function overrideHasJustification(body) {
|
|
|
261
296
|
* chronological order (see trustedCommentBodies); an untrusted comment never
|
|
262
297
|
* reaches this function.
|
|
263
298
|
*/
|
|
264
|
-
function
|
|
299
|
+
function sizeGateRouteIndex(comments) {
|
|
265
300
|
const gate = PHASE_MARKERS.build.sizeGateMarker;
|
|
266
301
|
const override = PHASE_MARKERS.build.sizeOverrideMarker;
|
|
267
302
|
const starts = (c, m) => typeof c === "string" && c.trimStart().startsWith(`<!-- ${m}`);
|
|
268
|
-
if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return
|
|
303
|
+
if (comments.some((c) => starts(c, "FORGE:DECOMPOSED"))) return -1;
|
|
269
304
|
let idx = -1;
|
|
270
305
|
for (let i = comments.length - 1; i >= 0; i--) if (starts(comments[i], gate)) { idx = i; break; }
|
|
271
|
-
if (idx < 0) return
|
|
306
|
+
if (idx < 0) return -1;
|
|
272
307
|
const body = comments[idx].replace(/\r\n/g, "\n");
|
|
273
|
-
const m = /^result:[ \t]*(OK|OVERRIDDEN|OVER)[ \t]*$/m.exec(body);
|
|
274
|
-
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return
|
|
275
|
-
return
|
|
308
|
+
const m = /^result:[ \t]*(OK|OVERRIDDEN|COHESIVE|OVER)[ \t]*$/m.exec(body);
|
|
309
|
+
if (!m || m[1] !== "OVER" || !/^###[ \t]+Split Proposal\b/m.test(body)) return -1;
|
|
310
|
+
return comments.slice(idx + 1).some((c) => starts(c, override) && overrideHasJustification(c)) ? -1 : idx;
|
|
311
|
+
}
|
|
312
|
+
/** Boolean form of sizeGateRouteIndex (index of the routing DIFF_SIZE comment, or -1). */
|
|
313
|
+
function sizeGateRoutesToDecompose(comments) {
|
|
314
|
+
return sizeGateRouteIndex(comments) >= 0;
|
|
276
315
|
}
|
|
277
316
|
/**
|
|
278
317
|
* Decompose loop guard (mirrors build.md B5.5 and decompose.md Guard 1). Returns a
|
|
@@ -621,7 +660,7 @@ export const PHASES = [
|
|
|
621
660
|
const invalid = `<!-- ${PHASE_MARKERS.investigate.invalidMarker} -->`;
|
|
622
661
|
const complete = `<!-- ${PHASE_MARKERS.investigate.completionMarker} -->`;
|
|
623
662
|
if (closing === invalid)
|
|
624
|
-
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
663
|
+
return { status: "committed", terminalReason: "invalid", code: OC.INVALID, outputs: { verdict: "INVALID" } };
|
|
625
664
|
if (closing === complete) {
|
|
626
665
|
// investigate.md emits `### Decomposition Assessment` then `**YES**|**NO**`.
|
|
627
666
|
if (isDecomposed(report)) {
|
|
@@ -632,7 +671,7 @@ export const PHASES = [
|
|
|
632
671
|
console.error(`[engine] decompose suppressed for #${state.issue}: ${guard}; treating as not decomposed`);
|
|
633
672
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
634
673
|
}
|
|
635
|
-
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
674
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: { decompose: true } };
|
|
636
675
|
}
|
|
637
676
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
638
677
|
}
|
|
@@ -661,7 +700,7 @@ export const PHASES = [
|
|
|
661
700
|
async detectOutcome(state, io) {
|
|
662
701
|
const { comments } = await issueMarkers(state.issue, io);
|
|
663
702
|
if (hasMarker(comments, PHASE_MARKERS.decompose.header, PHASE_MARKERS.decompose.completionMarker))
|
|
664
|
-
return { status: "committed", terminalReason: "decomposed", outputs: {} };
|
|
703
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.DECOMPOSED, outputs: {} };
|
|
665
704
|
return { status: "failed", detail: `no ${PHASE_MARKERS.decompose.completionMarker} marker` };
|
|
666
705
|
},
|
|
667
706
|
// Always terminal: decomposition spawns independent sub-issues, each of
|
|
@@ -718,8 +757,15 @@ export const PHASES = [
|
|
|
718
757
|
// review. Non-retryable: a retry would only re-read the same append-only markers.
|
|
719
758
|
// A newer PASSED clears this: either the in-build repair loop posts it, or a fresh
|
|
720
759
|
// build invocation does via build.md B0 gate resume (re-enters B6.5; forge#3725).
|
|
760
|
+
// forge#3712: an OVER + Split Proposal size-gate marker STRICTLY NEWER (comment order)
|
|
761
|
+
// than the newest blocked marker is the retry's real outcome; route to decompose.
|
|
762
|
+
// Equal/absent index stays fail-closed, as does a newer blocked marker.
|
|
763
|
+
if (!complete && latestGateState(comments) === "blocked" && mayRouteToDecompose(comments)
|
|
764
|
+
&& sizeGateRouteIndex(comments) > latestBlockedIndex(comments)) {
|
|
765
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
|
|
766
|
+
}
|
|
721
767
|
if (latestGateState(comments) === "blocked") {
|
|
722
|
-
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, retryable: false };
|
|
768
|
+
return { status: "failed", detail: `acceptance gate blocked (builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"})`, code: OC.BUILD_ACCEPTANCE_FAILED, retryable: false };
|
|
723
769
|
}
|
|
724
770
|
if (complete && ahead > 0) {
|
|
725
771
|
return { status: "committed", outputs: { branch, ...buildChildEvidence(comments) } };
|
|
@@ -735,7 +781,7 @@ export const PHASES = [
|
|
|
735
781
|
// needs-human. Non-retryable by construction: a retry re-measures and
|
|
736
782
|
// re-emits the same exit.
|
|
737
783
|
if (!complete && mayRouteToDecompose(comments) && sizeGateRoutesToDecompose(comments)) {
|
|
738
|
-
return { status: "committed", terminalReason: "decomposed", outputs: branch ? { branch } : {} };
|
|
784
|
+
return { status: "committed", terminalReason: "decomposed", code: OC.BUILD_SIZE_OVER_GATE, outputs: branch ? { branch } : {} };
|
|
739
785
|
}
|
|
740
786
|
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
741
787
|
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
@@ -799,7 +845,7 @@ export const PHASES = [
|
|
|
799
845
|
// The PR number from GitHub (openPrFor) always wins over parsed text.
|
|
800
846
|
const outputs = { pr: pr.number };
|
|
801
847
|
// Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
|
|
802
|
-
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
|
|
848
|
+
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true, code: OC.REVIEW_ESCALATED };
|
|
803
849
|
const rr = parseReviewResult(result?.text);
|
|
804
850
|
// forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
|
|
805
851
|
// remediate phase can post the matching bound marker. Untrusted model output: only
|
|
@@ -808,7 +854,7 @@ export const PHASES = [
|
|
|
808
854
|
if (rr && rr.status === "NEXT" && rr.next === "remediate" && rr.remediation)
|
|
809
855
|
outputs.remediation = isRemediationKind(rr.remediation) ? rr.remediation : "unknown";
|
|
810
856
|
if (pr.needsHuman || pr.remediating) return escalated;
|
|
811
|
-
if (rr && rr.status === "NEXT" && rr.next === "remediate") return escalated;
|
|
857
|
+
if (rr && rr.status === "NEXT" && rr.next === "remediate") return { ...escalated, code: OC.REVIEW_CHANGES_REQUESTED };
|
|
812
858
|
// An explicit REVIEW_RESULT status is authoritative: BLOCKED/COMPLETE exits
|
|
813
859
|
// (phase trail, ci gate, base conflict, merge refusal) also label the issue
|
|
814
860
|
// needs-human but are NOT remediation handoffs, so skip the label fallback.
|
|
@@ -844,11 +890,23 @@ export const PHASES = [
|
|
|
844
890
|
buildArgs: async (state, ctx, io) => {
|
|
845
891
|
const pr = need(state.pr, "pr", /^[0-9]+$/);
|
|
846
892
|
const kind = state.remediationKind ?? null;
|
|
847
|
-
|
|
893
|
+
// Pass the dispatching lease holder so remediate.md M0 can tell a live OTHER run from this engine's own lease.
|
|
894
|
+
const holderArgs = () => (typeof ctx.agentId === "string" && LEASE_HOLDER_RE.test(ctx.agentId) ? ["--lease-holder", ctx.agentId] : []);
|
|
895
|
+
const legacyArgs = () => [pr, "--issue", String(state.issue), ...repoArgs(ctx), ...baseArgs(state), ...holderArgs()];
|
|
848
896
|
// Legacy handoff with no kind (needs-human label fallback, or no REVIEW_RESULT): there
|
|
849
897
|
// is nothing to bind, so dispatch as before. remediate.md M0 then applies its default
|
|
850
898
|
// single-attempt guard. A present-but-unknown kind is never dispatched.
|
|
851
|
-
|
|
899
|
+
// forge#3807: every dispatch (kind-bound or legacy) posts a dispatch marker AFTER the bound marker,
|
|
900
|
+
// never deduped, so detectOutcome can tell a stale deferral from an earlier dispatch from this one's.
|
|
901
|
+
const postDispatchMarker = async () => {
|
|
902
|
+
try {
|
|
903
|
+
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
904
|
+
`<!-- ${REMEDIATION_DISPATCH_MARKER}: pr=${pr} -->\nEngine dispatching remediation for PR #${pr}.`]);
|
|
905
|
+
} catch (e) {
|
|
906
|
+
throw new PhaseArgsError(`github-unavailable: could not post ${REMEDIATION_DISPATCH_MARKER}; remediation not invoked (${e?.message || e})`);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
if (kind === null) { await postDispatchMarker(); return legacyArgs(); }
|
|
852
910
|
if (!isRemediationKind(kind))
|
|
853
911
|
throw new PhaseArgsError(`unknown remediation kind: ${JSON.stringify(kind)}`);
|
|
854
912
|
const boundMarker = REMEDIATION_BOUND_MARKERS[kind];
|
|
@@ -864,6 +922,7 @@ export const PHASES = [
|
|
|
864
922
|
} catch (e) {
|
|
865
923
|
throw new PhaseArgsError(`github-unavailable: could not post FORGE:${boundMarker}; remediation not invoked (${e?.message || e})`);
|
|
866
924
|
}
|
|
925
|
+
await postDispatchMarker();
|
|
867
926
|
return legacyArgs();
|
|
868
927
|
},
|
|
869
928
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
|
@@ -924,7 +983,25 @@ export const PHASES = [
|
|
|
924
983
|
}
|
|
925
984
|
if (latest === null) {
|
|
926
985
|
if (await prMerged()) return mergedResult("AUTO-LANDED");
|
|
927
|
-
|
|
986
|
+
// A deferral to a live earlier run (remediate.md M0 liveness step) is not a failed attempt. The
|
|
987
|
+
// decision was made by the script at M0 time, so the record is not re-judged against the clock (a
|
|
988
|
+
// lease that expired since is the NEXT dispatch's business). Only a trusted, full-line record for THIS
|
|
989
|
+
// PR counts, and only while it is newer than every `FORGE:REMEDIATION` trail comment: a later run
|
|
990
|
+
// that proceeded posts one, which retires the deferral.
|
|
991
|
+
// forge#3807: a deferral older than this dispatch's marker is stale (a later dispatch proceeded and
|
|
992
|
+
// crashed before posting a trail), so it is a failed attempt. No marker (older runs) => dispatchAt -1.
|
|
993
|
+
let deferredAt = -1, trailAt = -1, dispatchAt = -1, deferred = null;
|
|
994
|
+
const dispatchSentinel = `${REMEDIATION_DISPATCH_MARKER}: pr=${state.pr}`;
|
|
995
|
+
scoped.forEach((b, i) => {
|
|
996
|
+
if (state.pr != null && hasSentinelLine(b, dispatchSentinel)) dispatchAt = i;
|
|
997
|
+
if (startsWithHeader(b, REMEDIATION_DEFERRED_MARKER) && hasSentinelLine(b, REMEDIATION_DEFERRED_MARKER)) { deferredAt = i; deferred = b; }
|
|
998
|
+
else if (startsWithHeader(b, PHASE_MARKERS.remediate.header)) trailAt = i;
|
|
999
|
+
});
|
|
1000
|
+
if (deferred !== null && deferredAt > trailAt && deferredAt > dispatchAt && (state.pr == null || /^pr=(\d+)\s*$/m.exec(deferred)?.[1] === String(state.pr))) {
|
|
1001
|
+
const kind = /^kind=(\w+)\s*$/m.exec(deferred)?.[1] ?? "lease";
|
|
1002
|
+
return { status: "blocked", reason: "deferred", code: OC.REMEDIATE_DEFERRED_LIVE_RUN, retryable: false, detail: `deferred-live-run: ${kind}` };
|
|
1003
|
+
}
|
|
1004
|
+
return { status: "failed", code: OC.REMEDIATE_NO_MARKER, detail: `no ${completion} marker` };
|
|
928
1005
|
}
|
|
929
1006
|
// Parse the **Re-gate outcome**: field remediate.md's Phase M8 posts
|
|
930
1007
|
// (e.g. "**Re-gate outcome**: AUTO-LANDED to staging" — value is the
|
|
@@ -952,8 +1029,11 @@ export const PHASES = [
|
|
|
952
1029
|
// policy judgment call respectively) — reuse the existing
|
|
953
1030
|
// "needs-human" terminal reason rather than inventing two more,
|
|
954
1031
|
// matching the #2352/#2353 precedent of reusing an existing
|
|
955
|
-
// TERMINAL_REASONS value where semantically equivalent.
|
|
956
|
-
|
|
1032
|
+
// TERMINAL_REASONS value where semantically equivalent. forge#3833:
|
|
1033
|
+
// the two stay distinguishable by outcome code.
|
|
1034
|
+
return { status: "committed", terminalReason: "needs-human",
|
|
1035
|
+
code: reGateOutcome === "UNFIXABLE" ? OC.REMEDIATE_UNFIXABLE : OC.REMEDIATE_RE_ESCALATED,
|
|
1036
|
+
outputs: { reGateOutcome } };
|
|
957
1037
|
default:
|
|
958
1038
|
return { status: "failed", detail: `FORGE:REMEDIATION:COMPLETE present but Re-gate outcome unrecognized/missing: ${reGateOutcome || "none"}` };
|
|
959
1039
|
}
|
package/bin/engine/projector.mjs
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
|
|
4
4
|
*/
|
|
5
5
|
import { parseState, upsertStateBlock } from "./state.mjs";
|
|
6
|
+
import { isTrustedActor } from "./phases.mjs";
|
|
7
|
+
|
|
8
|
+
// Defence in depth: a lease further out than 3x the engine's 10 min TTL (same
|
|
9
|
+
// bound as scripts/remediate-liveness.sh MAX_LEASE_TTL_MS) is never honoured.
|
|
10
|
+
export const MAX_LEASE_TTL_MS = 3 * 600_000;
|
|
11
|
+
|
|
12
|
+
// Body + provenance in ONE call. `gh issue view --json` cannot return the last
|
|
13
|
+
// editor or authorAssociation, so GraphQL is used. -F variables, never interpolation.
|
|
14
|
+
const PROVENANCE_QUERY = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){"
|
|
15
|
+
+ "issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}";
|
|
6
16
|
|
|
7
17
|
export function makeProjector(io) {
|
|
8
18
|
const gh = io.gh;
|
|
@@ -12,9 +22,46 @@ export function makeProjector(io) {
|
|
|
12
22
|
try { return JSON.parse(out).body ?? ""; } catch { return ""; }
|
|
13
23
|
}
|
|
14
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Body of the issue iff its LAST EDITOR (`editor ?? author`) is trusted, else
|
|
27
|
+
* null. The reporter can edit their own body, so creator-only trust is wrong
|
|
28
|
+
* and engine writes (bot editor) must stay readable. Any error, malformed
|
|
29
|
+
* JSON or missing provenance fails closed (null).
|
|
30
|
+
*/
|
|
31
|
+
async function getTrustedBody(issue) {
|
|
32
|
+
try {
|
|
33
|
+
const out = await gh(["api", "graphql", "-f", `query=${PROVENANCE_QUERY}`,
|
|
34
|
+
"-F", "owner={owner}", "-F", "name={repo}", "-F", `number=${Number(issue)}`]);
|
|
35
|
+
const i = JSON.parse(out)?.data?.repository?.issue;
|
|
36
|
+
if (!i || typeof i.body !== "string") return null;
|
|
37
|
+
const actor = i.editor ?? i.author;
|
|
38
|
+
if (!actor || typeof actor !== "object") return null;
|
|
39
|
+
// authorAssociation describes the CREATOR: valid for the actor only when
|
|
40
|
+
// the actor IS the creator. A distinct human editor's standing comes from
|
|
41
|
+
// the collaborator permission endpoint (fail closed to NONE).
|
|
42
|
+
let association = "NONE";
|
|
43
|
+
if (!i.editor || i.editor.login === i.author?.login) association = i.authorAssociation;
|
|
44
|
+
else if (actor.__typename !== "Bot" && typeof actor.login === "string" && /^[A-Za-z0-9-]+(\[bot\])?$/.test(actor.login)) {
|
|
45
|
+
try {
|
|
46
|
+
const perm = JSON.parse(await gh(["api", `repos/{owner}/{repo}/collaborators/${actor.login}/permission`, "--jq", "{permission}"])).permission;
|
|
47
|
+
association = perm === "admin" ? "OWNER" : (perm === "maintain" || perm === "write") ? "COLLABORATOR" : "NONE";
|
|
48
|
+
} catch { /* fail closed */ }
|
|
49
|
+
}
|
|
50
|
+
return isTrustedActor({ association, type: actor.__typename, login: actor.login }) ? i.body : null;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
return {
|
|
16
55
|
/** @returns {Promise<import("./phases.mjs").RunState|null>} */
|
|
17
|
-
async readState(issue) {
|
|
56
|
+
async readState(issue) {
|
|
57
|
+
const body = await getTrustedBody(issue);
|
|
58
|
+
if (body === null) return null;
|
|
59
|
+
const st = parseState(body);
|
|
60
|
+
if (st?.lease && !(Number.isFinite(st.lease.until) && st.lease.until <= Date.now() + MAX_LEASE_TTL_MS)) {
|
|
61
|
+
return { ...st, lease: null };
|
|
62
|
+
}
|
|
63
|
+
return st;
|
|
64
|
+
},
|
|
18
65
|
|
|
19
66
|
async writeState(issue, index) {
|
|
20
67
|
const body = upsertStateBlock(await getBody(issue), index);
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -12,7 +12,10 @@ export function appendEvent(dir, issue, event) {
|
|
|
12
12
|
mkdirSync(dir, { recursive: true });
|
|
13
13
|
const path = logPath(dir, issue);
|
|
14
14
|
const seq = readLog(dir, issue).length + 1;
|
|
15
|
-
|
|
15
|
+
// forge#3696: wall-clock ISO-8601 `ts` on every appended event. Placed before the
|
|
16
|
+
// spread so a caller-supplied `ts` wins. rewriteLog() deliberately does not stamp:
|
|
17
|
+
// a hydrated rewrite time would be a false timestamp. Old logs lack `ts` and still parse.
|
|
18
|
+
appendFileSync(path, JSON.stringify({ seq, ts: new Date().toISOString(), ...event }) + "\n");
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -58,7 +61,9 @@ export function deriveState(events) {
|
|
|
58
61
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
62
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
63
|
terminalReason: null, lease: null, lastRateLimit: null,
|
|
61
|
-
remediationKind: null };
|
|
64
|
+
remediationKind: null, outcomeCode: null };
|
|
65
|
+
// forge#3833: outcome code of the LAST PHASE_COMMIT only (replaced, like lastCommitReason).
|
|
66
|
+
let lastCommitCode = null;
|
|
62
67
|
// forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
|
|
63
68
|
let lastCommitReason = null;
|
|
64
69
|
// forge#3754: phase of the LAST PHASE_COMMIT, so a reopen can tell a remediate-terminal run.
|
|
@@ -80,6 +85,8 @@ export function deriveState(events) {
|
|
|
80
85
|
// remediate instead of defaulting to a "merged" close.
|
|
81
86
|
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
82
87
|
lastCommitReason = e.terminalReason ?? null;
|
|
88
|
+
lastCommitCode = e.code ?? null;
|
|
89
|
+
if (e.code) s.outcomeCode = e.code;
|
|
83
90
|
lastCommitPhase = e.phase ?? null;
|
|
84
91
|
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
85
92
|
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
@@ -89,6 +96,7 @@ export function deriveState(events) {
|
|
|
89
96
|
case "RUN_TERMINAL":
|
|
90
97
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
91
98
|
lastTerminalReason = s.terminalReason;
|
|
99
|
+
if (e.code) s.outcomeCode = e.code;
|
|
92
100
|
break;
|
|
93
101
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
94
102
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
@@ -97,7 +105,7 @@ export function deriveState(events) {
|
|
|
97
105
|
// (needs-human / decomposed); restore the last commit's reason so the
|
|
98
106
|
// retry re-enters remediate/decompose instead of close/build.
|
|
99
107
|
case "RUN_REOPEN":
|
|
100
|
-
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
108
|
+
s.terminal = false; s.terminalReason = lastCommitReason; s.outcomeCode = lastCommitCode; s.v = e.seq;
|
|
101
109
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
102
110
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
103
111
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
@@ -119,9 +127,9 @@ export function deriveState(events) {
|
|
|
119
127
|
// lastCommitReason is reset so a stale handoff reason cannot leak into
|
|
120
128
|
// the new cycle through a later RUN_REOPEN.
|
|
121
129
|
case "RUN_NEXT_CYCLE":
|
|
122
|
-
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
130
|
+
s.terminal = false; s.terminalReason = null; s.committed = []; s.outcomeCode = null;
|
|
123
131
|
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
124
|
-
lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
|
|
132
|
+
lastCommitReason = null; lastCommitPhase = null; lastCommitCode = null; lastTerminalReason = null; s.v = e.seq;
|
|
125
133
|
s.lastRateLimit = null; // forge#3791
|
|
126
134
|
break;
|
|
127
135
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
|
|
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, turns, duration_ms, cost_usd, *_tokens)
|
|
7
|
+
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
|
|
8
|
+
* Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
|
|
9
|
+
* are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { emit } from "./telemetry.mjs";
|
|
13
|
+
|
|
14
|
+
export function defaultResolveRepo() {
|
|
15
|
+
try {
|
|
16
|
+
const r = execFileSync("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
17
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).trim();
|
|
18
|
+
return /^[\w.-]+\/[\w.-]+$/.test(r) ? r : null;
|
|
19
|
+
} catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "string" && v !== "");
|
|
23
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
|
|
24
|
+
const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
|
|
25
|
+
|
|
26
|
+
/** Pure mapping, exported for tests: returns { event, payload, phase } or null when the record is not mapped. */
|
|
27
|
+
export function mapRecord(rec, wallMs) {
|
|
28
|
+
if (!rec || typeof rec !== "object") return null;
|
|
29
|
+
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
30
|
+
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
31
|
+
attempt: rec.attempt, model: rec.model, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
32
|
+
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
33
|
+
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
34
|
+
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
|
35
|
+
}) };
|
|
36
|
+
}
|
|
37
|
+
if (rec.event === "RUN_TERMINAL") {
|
|
38
|
+
return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{repo?: string|null, emitFn?: Function, now?: () => number, resolveRepo?: () => string|null, env?: object}} o
|
|
45
|
+
* @returns {(rec: object) => Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now, resolveRepo = defaultResolveRepo, env = process.env } = {}) {
|
|
48
|
+
const startedAt = now();
|
|
49
|
+
let resolved = repo || env.FORGE_REPO || null;
|
|
50
|
+
let tried = Boolean(resolved);
|
|
51
|
+
return async (rec) => {
|
|
52
|
+
try {
|
|
53
|
+
if ((env.FORGE_TELEMETRY || "on") === "off") return;
|
|
54
|
+
const mapped = mapRecord(rec, now() - startedAt);
|
|
55
|
+
if (!mapped || rec.issue == null) return;
|
|
56
|
+
if (!tried) { tried = true; resolved = resolveRepo() || null; }
|
|
57
|
+
if (!resolved) return; // no destination: the Node emitter would drop it anyway
|
|
58
|
+
// ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
|
|
59
|
+
emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined });
|
|
60
|
+
} catch { /* telemetry never fails a run */ }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { makeEmitTelemetry, mapRecord } from "./telemetry-seam.mjs";
|
|
6
|
+
|
|
7
|
+
const attempt = { event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 2, issue: 7, duration_ms: 1234, cost_usd: 0.123456789, model: "sonnet", turns: null,
|
|
8
|
+
usage: { input_tokens: 1, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4 } };
|
|
9
|
+
|
|
10
|
+
function harness(over = {}) {
|
|
11
|
+
const calls = [];
|
|
12
|
+
const emit = makeEmitTelemetry({ repo: "o/r", emitFn: (...a) => { calls.push(a); }, now: () => 5000, env: {}, ...over });
|
|
13
|
+
return { calls, emit };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("engine telemetry seam", () => {
|
|
17
|
+
it("flattens usage, omits nulls and passes repo/issue/run/phase as ctx (not payload)", async () => {
|
|
18
|
+
const { calls, emit } = harness();
|
|
19
|
+
await emit(attempt);
|
|
20
|
+
const [event, payload, ctx] = calls[0];
|
|
21
|
+
assert.equal(event, "phase_attempt");
|
|
22
|
+
assert.deepEqual(payload, { attempt: 2, model: "sonnet", duration_ms: 1234, cost_usd: 0.123457, input_tokens: 1, output_tokens: 2, cache_read_tokens: 3, cache_creation_tokens: 4 });
|
|
23
|
+
assert.equal(ctx.repo, "o/r");
|
|
24
|
+
assert.equal(ctx.issue, 7);
|
|
25
|
+
assert.equal(ctx.phase, "build");
|
|
26
|
+
assert.match(ctx.run, /^r_7_\d+$/);
|
|
27
|
+
for (const v of Object.values(payload)) assert.notEqual(typeof v, "object");
|
|
28
|
+
});
|
|
29
|
+
it("maps RUN_TERMINAL with reason, code and wall time; ignores other records", async () => {
|
|
30
|
+
let t = 1000;
|
|
31
|
+
const { calls, emit } = harness({ now: () => t });
|
|
32
|
+
t = 61000;
|
|
33
|
+
await emit({ event: "RUN_TERMINAL", reason: "needs-human", code: "review.escalated", issue: 7 });
|
|
34
|
+
await emit({ event: "PHASE_START", phase: "build", issue: 7 });
|
|
35
|
+
assert.equal(calls.length, 1);
|
|
36
|
+
assert.equal(calls[0][0], "run_terminal");
|
|
37
|
+
assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
|
|
38
|
+
assert.equal(calls[0][2].phase, undefined);
|
|
39
|
+
});
|
|
40
|
+
it("null usage yields a record with no token fields", () => {
|
|
41
|
+
assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
|
|
42
|
+
});
|
|
43
|
+
it("never throws, even when the emitter throws, and honours FORGE_TELEMETRY=off", async () => {
|
|
44
|
+
const boom = harness({ emitFn: () => { throw new Error("down"); } });
|
|
45
|
+
await boom.emit(attempt);
|
|
46
|
+
const off = harness({ env: { FORGE_TELEMETRY: "off" } });
|
|
47
|
+
await off.emit(attempt);
|
|
48
|
+
assert.equal(off.calls.length, 0);
|
|
49
|
+
});
|
|
50
|
+
it("drops records with no resolvable repo (the Node emitter would drop them) and resolves lazily once", async () => {
|
|
51
|
+
let resolves = 0;
|
|
52
|
+
const none = harness({ repo: null, resolveRepo: () => { resolves++; return null; } });
|
|
53
|
+
await none.emit(attempt); await none.emit(attempt);
|
|
54
|
+
assert.equal(none.calls.length, 0);
|
|
55
|
+
assert.equal(resolves, 1);
|
|
56
|
+
const some = harness({ repo: null, resolveRepo: () => "a/b" });
|
|
57
|
+
await some.emit(attempt);
|
|
58
|
+
assert.equal(some.calls[0][2].repo, "a/b");
|
|
59
|
+
});
|
|
60
|
+
});
|