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.
Files changed (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -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) { return parseState(await getBody(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);
@@ -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
- appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
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
+ });
@@ -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 !== "PHASE_COMMIT" && e.event !== "PHASE_FAILED") continue;
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