forgedock 1.16.2 → 1.18.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 +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -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 +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -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 +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- 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/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- 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-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -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 +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- 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 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- 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/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
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,14 @@ 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;
|
|
109
|
+
// forge#3862: a `decomposed` terminal whose decomposition was revoked (trusted GitHub state,
|
|
110
|
+
// checked by the engine before it appends this event) reopens at build: un-commit the
|
|
111
|
+
// phases named in `uncommit` and drop the stale handoff reason so pickPhase re-selects build.
|
|
112
|
+
if (Array.isArray(e.uncommit) && e.uncommit.length) {
|
|
113
|
+
s.committed = s.committed.filter((p) => !e.uncommit.includes(p));
|
|
114
|
+
s.terminalReason = null; s.outcomeCode = null;
|
|
115
|
+
}
|
|
101
116
|
// forge#3754: a remediate that ended needs-human is re-run after a human clears the
|
|
102
117
|
// label. Un-commit it (new array, no in-place mutation) so the forge#3506 resume guard
|
|
103
118
|
// does not re-terminate and remediate's entryCondition re-selects it.
|
|
@@ -119,9 +134,9 @@ export function deriveState(events) {
|
|
|
119
134
|
// lastCommitReason is reset so a stale handoff reason cannot leak into
|
|
120
135
|
// the new cycle through a later RUN_REOPEN.
|
|
121
136
|
case "RUN_NEXT_CYCLE":
|
|
122
|
-
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
137
|
+
s.terminal = false; s.terminalReason = null; s.committed = []; s.outcomeCode = null;
|
|
123
138
|
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
124
|
-
lastCommitReason = null; lastCommitPhase = null; lastTerminalReason = null; s.v = e.seq;
|
|
139
|
+
lastCommitReason = null; lastCommitPhase = null; lastCommitCode = null; lastTerminalReason = null; s.v = e.seq;
|
|
125
140
|
s.lastRateLimit = null; // forge#3791
|
|
126
141
|
break;
|
|
127
142
|
// 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
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Node twin of scripts/lib/telemetry.sh: same record shape, same upsert into the trusted
|
|
5
|
+
* `<!-- FORGE:TELEMETRY v1 -->` comment, same rollover and local buffer. emit() NEVER throws and never
|
|
6
|
+
* rejects its caller; FORGE_TELEMETRY=off disables it. Records hold ids, counts and codes only.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = join(here, "..", "..");
|
|
17
|
+
const RESERVED = new Set(["v", "ts", "forge_version", "repo", "issue", "pr", "run", "phase", "event", "eid"]);
|
|
18
|
+
const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
|
|
19
|
+
const REVIEW_SIDE = new Set(["review_panel", "finding_disposition", "ci_gate"]);
|
|
20
|
+
|
|
21
|
+
const clean = (v) => String(v).replace(/[\n\r\t]/g, " ").replace(/[^A-Za-z0-9 _.:/#@+=,-]/g, "_").slice(0, 200);
|
|
22
|
+
const num = (s) => (/^[0-9]+$/.test(s ?? "") ? Number(s) : null);
|
|
23
|
+
const str = (s) => (s ? String(s) : null);
|
|
24
|
+
|
|
25
|
+
function forgeVersion() {
|
|
26
|
+
try { return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version || null; } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Same JSON line the shell emitter writes (key order included). `ctx` overrides the FORGE_* env. */
|
|
30
|
+
export function buildRecord(event, payload = {}, ctx = {}, now = new Date()) {
|
|
31
|
+
const env = process.env;
|
|
32
|
+
const c = {
|
|
33
|
+
repo: ctx.repo ?? env.FORGE_REPO, issue: ctx.issue ?? env.FORGE_ISSUE, pr: ctx.pr ?? env.FORGE_PR,
|
|
34
|
+
run: ctx.run ?? env.FORGE_RUN, phase: ctx.phase ?? env.FORGE_PHASE,
|
|
35
|
+
};
|
|
36
|
+
const extra = {};
|
|
37
|
+
for (const [k, raw] of Object.entries(payload)) {
|
|
38
|
+
if (!/^[A-Za-z0-9_]+$/.test(k) || RESERVED.has(k)) continue;
|
|
39
|
+
const v = clean(raw);
|
|
40
|
+
extra[k] = /^-?[0-9]+$/.test(v) ? Number(v) : v === "true" ? true : v === "false" ? false : v;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
v: 1, ts: now.toISOString().replace(/\.\d+Z$/, "Z"), forge_version: forgeVersion(), repo: str(c.repo),
|
|
44
|
+
issue: num(String(c.issue ?? "")), pr: num(String(c.pr ?? "")), run: str(c.run), phase: str(c.phase), event,
|
|
45
|
+
eid: `${Math.floor(now.getTime() / 1000)}-${process.pid}-${randomBytes(4).toString("hex")}`, ...extra,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function target(event, rec) {
|
|
50
|
+
const order = REVIEW_SIDE.has(event) ? [rec.pr, rec.issue] : [rec.issue, rec.pr];
|
|
51
|
+
const n = order.find((x) => x != null);
|
|
52
|
+
return n != null && rec.repo && rec.repo.includes("/") ? n : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gh(args) {
|
|
56
|
+
const bin = process.env.FORGE_TELEMETRY_GH || "gh";
|
|
57
|
+
const r = spawnSync(bin, args, { encoding: "utf8", timeout: 30000, maxBuffer: 64 * 1024 * 1024 });
|
|
58
|
+
if (r.error || r.status !== 0) throw new Error("gh failed");
|
|
59
|
+
return r.stdout;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function trusted(comment) {
|
|
63
|
+
const tc = join(ROOT, "scripts", "trusted-comments.sh");
|
|
64
|
+
const r = spawnSync("bash", [tc, "count", MARKER_RE.source], { input: JSON.stringify([comment]), encoding: "utf8" });
|
|
65
|
+
return r.status === 0 && r.stdout.trim() === "1";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function upsert(repo, n, lines) {
|
|
69
|
+
const max = Number(process.env.FORGE_TELEMETRY_MAX_CHARS) || 60000;
|
|
70
|
+
const raw = gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`]).trim();
|
|
71
|
+
const pages = raw ? JSON.parse(`[${raw.replace(/\]\s*\[/g, "],[")}]`) : [[]];
|
|
72
|
+
let best = null;
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const c of pages.flat()) {
|
|
75
|
+
const m = MARKER_RE.exec(c.body || "");
|
|
76
|
+
if (!m || !trusted(c)) continue;
|
|
77
|
+
const part = m[2] ? Number(m[2]) : 1;
|
|
78
|
+
parts.push({ body: c.body });
|
|
79
|
+
if (!best || part >= best.part) best = { id: c.id, part, body: c.body };
|
|
80
|
+
}
|
|
81
|
+
const all = parts.map((p) => p.body).join("\n");
|
|
82
|
+
const fresh = lines.filter((l) => !all.includes(l));
|
|
83
|
+
if (fresh.length === 0) return;
|
|
84
|
+
if (best) {
|
|
85
|
+
const cut = best.body.lastIndexOf("\n```");
|
|
86
|
+
const out = `${cut >= 0 ? best.body.slice(0, cut) : best.body}\n${fresh.join("\n")}\n\`\`\``;
|
|
87
|
+
if (out.length <= max) { gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${best.id}`, "-f", `body=${out}`]); return; }
|
|
88
|
+
}
|
|
89
|
+
const hdr = best ? `<!-- FORGE:TELEMETRY v1 part=${best.part + 1} -->` : "<!-- FORGE:TELEMETRY v1 -->";
|
|
90
|
+
gh(["api", "-X", "POST", `repos/${repo}/issues/${n}/comments`, "-f", `body=${hdr}\n\`\`\`jsonl\n${fresh.join("\n")}\n\`\`\``]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Emit one event. Returns the record when written or buffered, null when disabled/dropped. Never throws. */
|
|
94
|
+
export function emit(event, payload = {}, ctx = {}) {
|
|
95
|
+
try {
|
|
96
|
+
if ((process.env.FORGE_TELEMETRY || "on") === "off") return null;
|
|
97
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(event || "")) return null;
|
|
98
|
+
const rec = buildRecord(event, payload, ctx);
|
|
99
|
+
const n = target(event, rec);
|
|
100
|
+
if (n == null) return null;
|
|
101
|
+
const dir = process.env.FORGE_TELEMETRY_BUFFER_DIR || join(homedir(), ".forge", "telemetry-buffer");
|
|
102
|
+
const buf = join(dir, `${rec.repo.replace("/", "_")}__${n}.jsonl`);
|
|
103
|
+
const line = JSON.stringify(rec);
|
|
104
|
+
// Per-target lock (mkdir is atomic): serializes emitters on this host so the unversioned GitHub read-modify-write
|
|
105
|
+
// cannot lose a record or double-POST a part. Bounded wait (50 x 0.1s); a lock over 5 min old is stale and is
|
|
106
|
+
// reclaimed by an atomic rename (one winner); the holder writes an owner token and releases only its own lock.
|
|
107
|
+
// A non-writable buffer dir skips locking; on timeout the record is buffered.
|
|
108
|
+
let canLock = true;
|
|
109
|
+
try { mkdirSync(dir, { recursive: true }); accessSync(dir, constants.W_OK); } catch { canLock = false; }
|
|
110
|
+
const lock = join(dir, `.lock-${rec.repo.replace("/", "_")}__${n}`);
|
|
111
|
+
const token = `${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
112
|
+
let got = !canLock;
|
|
113
|
+
for (let i = 0; i < 50 && !got; i++) {
|
|
114
|
+
try { mkdirSync(lock); writeFileSync(join(lock, "owner"), token); got = true; } catch {
|
|
115
|
+
let stale = false;
|
|
116
|
+
try { stale = Date.now() - statSync(lock).mtimeMs > 300000; } catch { /* raced */ }
|
|
117
|
+
if (stale) { try { renameSync(lock, `${lock}.stale.${token}`); rmSync(`${lock}.stale.${token}`, { recursive: true, force: true }); } catch { /* lost the race */ } }
|
|
118
|
+
else spawnSync("sleep", ["0.1"]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!got) { appendFileSync(buf, `${line}\n`); return rec; }
|
|
122
|
+
const unlock = () => { if (!canLock) return; try { if (readFileSync(join(lock, "owner"), "utf8") === token) rmSync(lock, { recursive: true, force: true }); } catch { /* already gone */ } };
|
|
123
|
+
// Claim the buffer by renaming it: records appended meanwhile land in a fresh file and are never deleted. Holding
|
|
124
|
+
// the lock, any other "<buf>.flush.*" is an orphan of a killed emitter: fold it in.
|
|
125
|
+
const claimed = `${buf}.flush.${token}`;
|
|
126
|
+
let pending = [line];
|
|
127
|
+
try {
|
|
128
|
+
if (existsSync(buf)) renameSync(buf, claimed);
|
|
129
|
+
for (const f of readdirSync(dir)) {
|
|
130
|
+
const full = join(dir, f);
|
|
131
|
+
if (f.startsWith(`${basename(buf)}.flush.`) && full !== claimed) { appendFileSync(claimed, readFileSync(full, "utf8")); rmSync(full, { force: true }); }
|
|
132
|
+
}
|
|
133
|
+
if (existsSync(claimed)) pending = [...readFileSync(claimed, "utf8").split("\n").filter(Boolean), line];
|
|
134
|
+
} catch { /* nothing claimed */ }
|
|
135
|
+
try {
|
|
136
|
+
upsert(rec.repo, n, pending);
|
|
137
|
+
rmSync(claimed, { force: true });
|
|
138
|
+
} catch {
|
|
139
|
+
try {
|
|
140
|
+
if (existsSync(claimed)) { appendFileSync(buf, readFileSync(claimed, "utf8")); rmSync(claimed, { force: true }); }
|
|
141
|
+
appendFileSync(buf, `${line}\n`);
|
|
142
|
+
} catch { /* lost record costs completeness only */ }
|
|
143
|
+
} finally { unlock(); }
|
|
144
|
+
return rec;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { buildRecord, emit } from "./telemetry.mjs";
|
|
10
|
+
|
|
11
|
+
let td; let saved;
|
|
12
|
+
const KEYS = ["FORGE_TELEMETRY", "FORGE_TELEMETRY_GH", "FORGE_TELEMETRY_BUFFER_DIR", "FORGE_TELEMETRY_MAX_CHARS", "FORGE_REPO", "FORGE_ISSUE", "FORGE_PR", "FORGE_RUN", "FORGE_PHASE", "TD"];
|
|
13
|
+
|
|
14
|
+
// Stub gh: lists $TD/comments.json, records the last PATCH/POST body in $TD/patched|posted, logs calls.
|
|
15
|
+
function stubGh(fail = false) {
|
|
16
|
+
const p = join(td, "gh");
|
|
17
|
+
writeFileSync(p, `#!/usr/bin/env bash
|
|
18
|
+
echo "$*" >> "$TD/calls"
|
|
19
|
+
${fail ? "exit 1" : ""}
|
|
20
|
+
case "$*" in
|
|
21
|
+
"api --paginate "*) if [ -f "$TD/comments.json" ]; then cat "$TD/comments.json"; else echo '[]'; fi ;;
|
|
22
|
+
"api -X PATCH"*) b="\${@: -1}"; printf '%s' "\${b#body=}" > "$TD/patched" ;;
|
|
23
|
+
"api -X POST"*) b="\${@: -1}"; printf '%s' "\${b#body=}" > "$TD/posted" ;;
|
|
24
|
+
esac
|
|
25
|
+
`);
|
|
26
|
+
chmodSync(p, 0o755);
|
|
27
|
+
process.env.FORGE_TELEMETRY_GH = p;
|
|
28
|
+
}
|
|
29
|
+
const setComments = (bodies) => writeFileSync(join(td, "comments.json"),
|
|
30
|
+
JSON.stringify(bodies.map((b, i) => ({ id: 100 + i, body: b, user: { type: "Bot" }, author_association: "NONE" }))));
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
|
|
34
|
+
td = mkdtempSync(join(tmpdir(), "tel-"));
|
|
35
|
+
Object.assign(process.env, { TD: td, FORGE_TELEMETRY_BUFFER_DIR: join(td, "buf"), FORGE_REPO: "o/r", FORGE_ISSUE: "5" });
|
|
36
|
+
for (const k of ["FORGE_TELEMETRY", "FORGE_PR", "FORGE_RUN", "FORGE_PHASE", "FORGE_TELEMETRY_MAX_CHARS"]) delete process.env[k];
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
for (const k of KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
|
|
40
|
+
rmSync(td, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("telemetry emitter (node twin)", () => {
|
|
44
|
+
it("builds the shared record shape with null context and typed payload", () => {
|
|
45
|
+
const r = buildRecord("diff_size", { lines: "10", excluded: 0, threshold: 0, result: "OK", bad: "a`b\nc" });
|
|
46
|
+
assert.deepEqual(Object.keys(r).slice(0, 9), ["v", "ts", "forge_version", "repo", "issue", "pr", "run", "phase", "event"]);
|
|
47
|
+
assert.equal(r.v, 1); assert.equal(r.issue, 5); assert.equal(r.pr, null); assert.equal(r.run, null);
|
|
48
|
+
assert.equal(r.lines, 10); assert.equal(r.result, "OK"); assert.equal(r.bad, "a_b c");
|
|
49
|
+
assert.match(r.forge_version, /^\d+\.\d+\.\d+/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("drops reserved and malformed keys", () => {
|
|
53
|
+
const r = buildRecord("x", { repo: "evil", "a b": 1, ok: 1 });
|
|
54
|
+
assert.equal(r.repo, "o/r"); assert.equal(r.ok, 1); assert.equal("a b" in r, false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("creates then upserts one trusted comment", () => {
|
|
58
|
+
stubGh();
|
|
59
|
+
emit("ci_gate", { result: "PASS", rc: 0, checks: 3 });
|
|
60
|
+
const posted = readFileSync(join(td, "posted"), "utf8");
|
|
61
|
+
assert.match(posted, /^<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n\{.*"event":"ci_gate".*\}\n```$/);
|
|
62
|
+
setComments([posted]);
|
|
63
|
+
emit("liveness", { decision: "DEFER", why: "lease" });
|
|
64
|
+
const patched = readFileSync(join(td, "patched"), "utf8");
|
|
65
|
+
assert.equal(patched.split("\n").filter((l) => l.startsWith("{")).length, 2);
|
|
66
|
+
assert.deepEqual(existsSync(join(td, "buf")) ? readdirSync(join(td, "buf")) : [], []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ignores an untrusted telemetry comment", () => {
|
|
70
|
+
stubGh();
|
|
71
|
+
writeFileSync(join(td, "comments.json"), JSON.stringify([{ id: 1, body: "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n```", user: { type: "User" }, author_association: "NONE" }]));
|
|
72
|
+
emit("ci_gate", { result: "PASS" });
|
|
73
|
+
assert.ok(existsSync(join(td, "posted")));
|
|
74
|
+
assert.equal(existsSync(join(td, "patched")), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rolls over to part=N near the size limit", () => {
|
|
78
|
+
stubGh();
|
|
79
|
+
process.env.FORGE_TELEMETRY_MAX_CHARS = "300";
|
|
80
|
+
setComments(["<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n" + '{"a":"' + "x".repeat(250) + '"}\n```']);
|
|
81
|
+
emit("ci_gate", { result: "PASS" });
|
|
82
|
+
assert.match(readFileSync(join(td, "posted"), "utf8"), /^<!-- FORGE:TELEMETRY v1 part=2 -->/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("buffers on a failed write and flushes on the next emit", () => {
|
|
86
|
+
stubGh(true);
|
|
87
|
+
assert.doesNotThrow(() => emit("diff_size", { lines: 1 }));
|
|
88
|
+
const files = readdirSync(join(td, "buf"));
|
|
89
|
+
assert.deepEqual(files, ["o_r__5.jsonl"]);
|
|
90
|
+
stubGh(false);
|
|
91
|
+
emit("diff_size", { lines: 2 });
|
|
92
|
+
const posted = readFileSync(join(td, "posted"), "utf8");
|
|
93
|
+
assert.equal(posted.split("\n").filter((l) => l.startsWith("{")).length, 2);
|
|
94
|
+
assert.equal(existsSync(join(td, "buf", "o_r__5.jsonl")), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("never throws with an unwritable buffer dir and no gh", () => {
|
|
98
|
+
process.env.FORGE_TELEMETRY_GH = "/nonexistent/gh";
|
|
99
|
+
writeFileSync(join(td, "afile"), "x");
|
|
100
|
+
process.env.FORGE_TELEMETRY_BUFFER_DIR = join(td, "afile", "buf"); // parent is a file: mkdir fails
|
|
101
|
+
assert.doesNotThrow(() => emit("diff_size", { lines: 1 }));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("honours FORGE_TELEMETRY=off and drops records with no target", () => {
|
|
105
|
+
stubGh();
|
|
106
|
+
process.env.FORGE_TELEMETRY = "off";
|
|
107
|
+
assert.equal(emit("ci_gate", { result: "PASS" }), null);
|
|
108
|
+
assert.equal(existsSync(join(td, "calls")), false);
|
|
109
|
+
delete process.env.FORGE_TELEMETRY; delete process.env.FORGE_ISSUE;
|
|
110
|
+
assert.equal(emit("ci_gate", { result: "PASS" }), null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("shell and node emitters produce the same keys", () => {
|
|
114
|
+
process.env.FORGE_PR = "9"; process.env.FORGE_RUN = "r1"; process.env.FORGE_PHASE = "build";
|
|
115
|
+
const sh = spawnSync("bash", ["-c", `. "${join(import.meta.dirname, "..", "..", "scripts", "lib", "telemetry.sh")}"; _forge_tel_record diff_size lines=10 result=OK`], { encoding: "utf8", env: process.env });
|
|
116
|
+
const a = JSON.parse(sh.stdout); const b = buildRecord("diff_size", { lines: 10, result: "OK" });
|
|
117
|
+
assert.deepEqual(Object.keys(a), Object.keys(b));
|
|
118
|
+
for (const k of Object.keys(a)) if (k !== "ts" && k !== "eid") assert.deepEqual(a[k], b[k], k);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs"
|
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
15
|
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
16
|
+
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
@@ -77,8 +78,16 @@ export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
|
77
78
|
export function aggregateUsage(events) {
|
|
78
79
|
let found = false;
|
|
79
80
|
const total = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
|
|
81
|
+
// forge#3696: PHASE_COMMIT.usage is cumulative over all attempts, so summing it with
|
|
82
|
+
// PHASE_FAILED would double count. For a phase that has PHASE_ATTEMPT_USAGE events,
|
|
83
|
+
// sum ONLY those; otherwise (old logs) fall back to the legacy COMMIT + FAILED sum.
|
|
84
|
+
const attemptPhases = new Set(events.filter((e) => e.event === "PHASE_ATTEMPT_USAGE").map((e) => e.phase));
|
|
80
85
|
for (const e of events) {
|
|
81
|
-
if (e.event
|
|
86
|
+
if (e.event === "PHASE_ATTEMPT_USAGE") {
|
|
87
|
+
// counted as-is (usage null => nothing to add)
|
|
88
|
+
} else if (e.event === "PHASE_COMMIT" || e.event === "PHASE_FAILED") {
|
|
89
|
+
if (attemptPhases.has(e.phase)) continue;
|
|
90
|
+
} else continue;
|
|
82
91
|
if (!e.usage || typeof e.usage !== "object") continue;
|
|
83
92
|
found = true;
|
|
84
93
|
total.input_tokens += e.usage.input_tokens ?? 0;
|
|
@@ -393,6 +402,8 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
393
402
|
...(repo ? { repo } : {}),
|
|
394
403
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
395
404
|
onProgress,
|
|
405
|
+
// forge#3835: phase_attempt / run_terminal records reach GitHub so `forgedock stats` works from any machine.
|
|
406
|
+
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo }),
|
|
396
407
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
397
408
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
398
409
|
...(backend ? { backend } : {}),
|
|
@@ -409,6 +420,11 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
409
420
|
console.log(res.detail ?? "run is not retryable");
|
|
410
421
|
return res;
|
|
411
422
|
}
|
|
423
|
+
// forge#3932: refused before any state write (open or unreadable **Depends on**); nothing ran.
|
|
424
|
+
if (res.terminalReason === "dependency-open" || res.terminalReason === "dependency-unreadable") {
|
|
425
|
+
console.log(res.detail ?? "run refused: dependency check");
|
|
426
|
+
return res;
|
|
427
|
+
}
|
|
412
428
|
// forge#2175: a non-success termination previously printed nothing beyond
|
|
413
429
|
// the bare reason above — the actual failing phase/attempt/reason was only
|
|
414
430
|
// recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
|