forgedock 1.26.0 → 1.28.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/README.md
CHANGED
|
@@ -268,7 +268,9 @@ Then just open Claude Code and run `/work-on <issue>`.
|
|
|
268
268
|
|
|
269
269
|
Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock init` to generate `forge.yaml`.
|
|
270
270
|
|
|
271
|
-
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**.
|
|
271
|
+
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. It never falls back to the paid Anthropic API implicitly: without a CLI it fails with `CLI_BACKEND_UNAVAILABLE` unless the owner opted in (forge.yaml `execution.agent_backend: api` plus `FORGEDOCK_ALLOW_API_BACKEND=1`; see [CONFIG.md](docs/CONFIG.md#execution-optional)). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
|
|
272
|
+
|
|
273
|
+
**Brand-new project?** `/bootstrap "<idea>"` (or `npx forgedock bootstrap "<idea>" --dry-run` to preview) turns an idea into a validated, milestone-ordered GitHub backlog that `/orchestrate` can build. See [Start a New Project with ForgeDock](docs/site/start-a-new-project.md).
|
|
272
274
|
|
|
273
275
|
**Explicit install command:**
|
|
274
276
|
|
|
@@ -363,6 +365,7 @@ Using ForgeDock in your pipeline? Add the badge — each one is a backlink and a
|
|
|
363
365
|
|
|
364
366
|
- [GitHub Is Already Your Agents' Memory](docs/site/github-is-the-memory.md) — the canonical argument: why GitHub is the right place for agent memory, how FORGE annotations make it machine-readable, and how to adopt the protocol without ForgeDock
|
|
365
367
|
- [Getting Started in 5 Minutes](docs/site/getting-started.md)
|
|
368
|
+
- [Start a New Project with ForgeDock](docs/site/start-a-new-project.md) — idea → `/bootstrap` → backlog → `/orchestrate`, with a worked example
|
|
366
369
|
- [How the Knowledge Graph Works](docs/site/how-it-works.md)
|
|
367
370
|
- [What Are Those FORGE Comments?](docs/site/annotations-explained.md) — 2-minute explainer for annotations you meet in the wild
|
|
368
371
|
- [FORGE Annotation Protocol](docs/spec/forge-protocol-v1.md) — the open standard for AI context passing (CC-BY-4.0)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* `forgedock bootstrap` (#4177, part of #4155): the headless `/bootstrap`.
|
|
5
|
+
* - Plan mode, `--plan <plan.json> [--dry-run] [--repo] [--config] [--base] [--json] [--cost-per-issue <usd>]`:
|
|
6
|
+
* scripts/bootstrap-create.mjs validates and previews (dry run, no GitHub access) or creates the backlog. No LLM
|
|
7
|
+
* call. Re-running the same command resumes from GitHub state: existing spike, milestones, branches, issues and
|
|
8
|
+
* marker comments are reused, so no local state is needed.
|
|
9
|
+
* - Idea mode, `<free text | docs path> [--dry-run] [--spike <path>] [--model <id>] [--backend <cli|api|auto>]`:
|
|
10
|
+
* runs commands/bootstrap.md headlessly through runner.mjs runCommand. `--dry-run` and `--spike` are spec
|
|
11
|
+
* arguments; `--dry-run` is never the runner's own prompt-preview dryRun.
|
|
12
|
+
* Exit: 0 ok; 1 failed run (or invalid plan); 2 usage; 3 GitHub error (plan mode, re-run to resume).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const USAGE = "usage: forgedock bootstrap <idea | docs path> [--dry-run] [--spike <path>] [--model <id>] [--backend <cli|api|auto>]\n" +
|
|
16
|
+
" forgedock bootstrap --plan <plan.json> [--dry-run] [--repo OWNER/NAME] [--config forge.yaml] [--base <branch>] [--json] [--cost-per-issue <usd>]\n";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string[]} argv arguments after `bootstrap`
|
|
20
|
+
* @param {{runCommand?: Function, createMain?: Function, commandsDir?: string, cwd?: string, out?: {write}, err?: {write}}} deps
|
|
21
|
+
* @returns {Promise<number>} exit code
|
|
22
|
+
*/
|
|
23
|
+
export async function runBootstrap(argv = [], deps = {}) {
|
|
24
|
+
const { commandsDir, cwd = process.cwd(), out = process.stdout, err = process.stderr } = deps;
|
|
25
|
+
const needValue = (i) => i + 1 < argv.length && !argv[i + 1].startsWith("--");
|
|
26
|
+
const planIdx = argv.indexOf("--plan");
|
|
27
|
+
if (planIdx !== -1) {
|
|
28
|
+
if (!needValue(planIdx)) { err.write(`forgedock bootstrap: --plan requires a value\n${USAGE}`); return 2; }
|
|
29
|
+
const createMain = deps.createMain ?? (await import("../scripts/bootstrap-create.mjs")).main;
|
|
30
|
+
const rest = [...argv.slice(0, planIdx), ...argv.slice(planIdx + 2)];
|
|
31
|
+
return createMain([argv[planIdx + 1], ...rest], { out, err });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const args = [];
|
|
35
|
+
let model, backend;
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
if (a === "--dry-run") args.push(a);
|
|
39
|
+
else if (a === "--spike" || a === "--model" || a === "--backend") {
|
|
40
|
+
if (!needValue(i)) { err.write(`forgedock bootstrap: ${a} requires a value\n${USAGE}`); return 2; }
|
|
41
|
+
const v = argv[++i];
|
|
42
|
+
if (a === "--spike") args.push(a, v);
|
|
43
|
+
else if (a === "--model") model = v;
|
|
44
|
+
else backend = v;
|
|
45
|
+
} else if (a.startsWith("--")) { err.write(`forgedock bootstrap: unknown argument ${a}\n${USAGE}`); return 2; }
|
|
46
|
+
else args.push(a);
|
|
47
|
+
}
|
|
48
|
+
if (!args.some((a, i) => !a.startsWith("--") && args[i - 1] !== "--spike")) { err.write(USAGE); return 2; }
|
|
49
|
+
const runCommand = deps.runCommand ?? (await import("./runner.mjs")).runCommand;
|
|
50
|
+
const result = await runCommand({
|
|
51
|
+
commandsDir, commandName: "bootstrap", args, cwd, dryRun: false,
|
|
52
|
+
...(model ? { model } : {}), ...(backend ? { backend } : {}),
|
|
53
|
+
});
|
|
54
|
+
return result && (result.status === "max-iterations" || result.status === "incomplete") ? 1 : 0;
|
|
55
|
+
}
|
|
@@ -36,15 +36,20 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
36
36
|
BASE_CONFLICT: "base.conflict",
|
|
37
37
|
RATE_LIMITED: "rate_limited",
|
|
38
38
|
HOST_BUSY: "host.busy",
|
|
39
|
+
GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
|
|
39
40
|
MERGE_TRANSIENT: "merge.transient",
|
|
40
41
|
PUSH_TRANSIENT: "push.transient",
|
|
41
42
|
CLI_IDLE: "cli.idle",
|
|
43
|
+
RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
|
|
42
44
|
PATH_SWITCH_CONFLICT: "path.switch_conflict",
|
|
43
45
|
ENGINE_ERROR: "engine.error",
|
|
44
46
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
45
47
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
46
48
|
CLI_TIMEOUT: "cli.timeout",
|
|
47
49
|
CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
|
|
50
|
+
ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
|
|
51
|
+
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
|
|
52
|
+
SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
|
|
48
53
|
LEASE_HELD: "lease.held",
|
|
49
54
|
DEPENDENCY_OPEN: "dependency.open",
|
|
50
55
|
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
@@ -74,8 +79,15 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
74
79
|
CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
|
|
75
80
|
CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
|
|
76
81
|
CONFORMANCE_REGRESSION: "conformance.regression",
|
|
82
|
+
STRUCTURE_UNDECLARED_EDGE: "structure.undeclared_edge", // forge#4159: a workspace package.json edge the contract does not name
|
|
83
|
+
DOCS_STALE: "docs.stale", // the target repo's devdocs freshness rule still fails after one builder resume
|
|
84
|
+
MAINTENANCE_HOTSPOT_GROWTH: "maintenance.hotspot_growth", // forge#4195: an over-threshold file grew past growth_lines without hotspot-exempt
|
|
77
85
|
FOLLOWUP_UNAVAILABLE: "followup.unavailable",
|
|
78
86
|
MERGE_HEAD_MISMATCH: "merge.head_mismatch",
|
|
87
|
+
// refactor:split lane (forge#4211): deterministic refusals and the move proof; every one leaves no edit behind.
|
|
88
|
+
SPLIT_UNSUPPORTED: "split.unsupported", // no codemod or index tool for the target language, or libcst or ts-morph missing; refused before any edit
|
|
89
|
+
SPLIT_PLAN_INVALID: "split.plan_invalid", // missing target/test command, unparseable grouping reply, or a plan symbol-move refuses
|
|
90
|
+
SPLIT_VERIFY_FAILED: "split.verify_failed", // refactor-verify found a logic diff, missing symbol, changed counts or new cycle; branch reset
|
|
79
91
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
80
92
|
COMPLETED: "completed",
|
|
81
93
|
AWAITING_MERGE: "awaiting_merge",
|
|
@@ -130,6 +142,7 @@ export function codeForRunnerError(err) {
|
|
|
130
142
|
return OUTCOME_CODES.CLI_SPAWN_FAILED;
|
|
131
143
|
case "NO_API_KEY":
|
|
132
144
|
case "NO_SDK":
|
|
145
|
+
case "CLI_BACKEND_UNAVAILABLE":
|
|
133
146
|
case "CLI_BACKEND_FAILED":
|
|
134
147
|
case "PHASE_ARGS_INVALID":
|
|
135
148
|
case "CLI_MAX_TURNS":
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -349,12 +349,48 @@ export async function openDependency(issue, io) {
|
|
|
349
349
|
return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
|
|
350
350
|
}
|
|
351
351
|
const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
|
|
352
|
-
if (st === "open")
|
|
352
|
+
if (st === "open") {
|
|
353
|
+
// #4153: an open decomposed parent is a tracker; the edge resolves to its sub-issues (satisfied when all close).
|
|
354
|
+
const subs = isDecomposedRecord(d) ? await decomposedSubissues(dep, io) : null;
|
|
355
|
+
if (!subs) { open.push(dep); continue; }
|
|
356
|
+
for (const s of subs) {
|
|
357
|
+
let sr;
|
|
358
|
+
try { sr = await readIssueRecord(s, io); } catch (e) {
|
|
359
|
+
return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} could not be read: ${e?.message || e}` };
|
|
360
|
+
}
|
|
361
|
+
const ss = typeof sr?.state === "string" ? sr.state.toLowerCase() : "";
|
|
362
|
+
if (ss === "open") open.push(s);
|
|
363
|
+
else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
353
366
|
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
354
367
|
}
|
|
355
|
-
return open.length ? { open } : null;
|
|
368
|
+
return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
|
|
356
369
|
}
|
|
357
370
|
|
|
371
|
+
/**
|
|
372
|
+
* #4153: sub-issues of a decomposed parent, from the `- #N` lines of its newest trusted `<!-- FORGE:DECOMPOSED -->`
|
|
373
|
+
* comment (lean `- #N title` and decompose.md `- #N: title`). `null` when there is none, it lists no issue, it was
|
|
374
|
+
* revoked, or the trail is unreadable: the caller then keeps the parent as the dependency (fail closed).
|
|
375
|
+
*/
|
|
376
|
+
export async function decomposedSubissues(issue, io) {
|
|
377
|
+
let comments;
|
|
378
|
+
try { comments = (await issueMarkers(issue, io)).comments; } catch { return null; }
|
|
379
|
+
const head = /^\s*<!--\s*FORGE:DECOMPOSED\s*-->/;
|
|
380
|
+
const revoked = /^\s*<!--\s*FORGE:DECOMPOSED:REVOKED(?![A-Za-z0-9_])/;
|
|
381
|
+
let rec = null;
|
|
382
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
383
|
+
if (revoked.test(comments[i])) return null;
|
|
384
|
+
if (head.test(comments[i])) { rec = comments[i]; break; }
|
|
385
|
+
}
|
|
386
|
+
if (!rec) return null;
|
|
387
|
+
const subs = [...new Set([...rec.matchAll(/^[ \t]*[-*][ \t]+#(\d+)\b/gm)].map((m) => Number(m[1])))].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
|
|
388
|
+
return subs.length ? subs : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** True when a REST/`gh --json` issue record carries the `workflow:decomposed` label. */
|
|
392
|
+
export const isDecomposedRecord = (rec) => (rec?.labels ?? []).some((l) => (l?.name ?? l) === "workflow:decomposed");
|
|
393
|
+
|
|
358
394
|
/**
|
|
359
395
|
* Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
|
|
360
396
|
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
|
|
5
5
|
* seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
|
|
6
|
-
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, turns, duration_ms, cost_usd, *_tokens)
|
|
7
|
-
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
|
|
6
|
+
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, *_tokens)
|
|
7
|
+
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would and the pr)
|
|
8
|
+
* SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
|
|
9
|
+
* STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
|
|
10
|
+
* lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
|
|
11
|
+
* new_edges / removed_edges as "from:to" comma-joined, top_growth "file+N", decisions "K1:lint",
|
|
12
|
+
* plus decisions_doc/lint/precedent/spec counts. Values are capped at 200 chars by the emitter.
|
|
8
13
|
* Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
|
|
9
14
|
* are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
|
|
10
15
|
*/
|
|
@@ -23,19 +28,48 @@ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v ===
|
|
|
23
28
|
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
|
|
24
29
|
const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
const SOURCES = ["doc", "lint", "precedent", "spec"];
|
|
32
|
+
const prOf = (rec) => (Number.isSafeInteger(rec.pr) && rec.pr > 0 ? { pr: rec.pr } : {});
|
|
33
|
+
/** "a:b,c:d" from [{from,to}] (forge#4165); undefined for a non-array so compact() drops it. An empty list is "". */
|
|
34
|
+
const pairs = (v) => (Array.isArray(v) ? v.map((e) => `${e?.from}:${e?.to}`).join(",") : undefined);
|
|
35
|
+
const nonEmpty = (v) => (v === "" ? "none" : v);
|
|
36
|
+
/** Lean's forge#4165 extras of a STRUCTURE_DELTA record; {} for the implement-time counts-only shape. */
|
|
37
|
+
function structureLists(rec) {
|
|
38
|
+
if (!Array.isArray(rec.decisions) && !Array.isArray(rec.new_edges) && !Array.isArray(rec.deps_added)) return {};
|
|
39
|
+
const dec = Array.isArray(rec.decisions) ? rec.decisions : [];
|
|
40
|
+
const out = {
|
|
41
|
+
milestone: rec.milestone, deps_added: nonEmpty(pairs(rec.deps_added)), new_edges: nonEmpty(pairs(rec.new_edges)), removed_edges: nonEmpty(pairs(rec.removed_edges)),
|
|
42
|
+
top_growth: Array.isArray(rec.top_growth) ? nonEmpty(rec.top_growth.map((g) => `${g?.file}+${g?.lines}`).join(",")) : undefined,
|
|
43
|
+
decisions: nonEmpty(dec.map((k) => `${k?.id}:${k?.source ?? "none"}`).join(",")),
|
|
44
|
+
};
|
|
45
|
+
for (const src of SOURCES) out[`decisions_${src}`] = dec.filter((k) => k?.source === src).length;
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pure mapping, exported for tests: returns { event, payload, phase, pr? } or null when the record is not mapped. */
|
|
27
50
|
export function mapRecord(rec, wallMs) {
|
|
28
51
|
if (!rec || typeof rec !== "object") return null;
|
|
29
52
|
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
30
53
|
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
31
|
-
attempt: rec.attempt, model: rec.model, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
54
|
+
attempt: rec.attempt, model: rec.model, backend: rec.backend ?? undefined, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
32
55
|
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
33
56
|
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
34
57
|
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
|
35
58
|
}) };
|
|
36
59
|
}
|
|
37
60
|
if (rec.event === "RUN_TERMINAL") {
|
|
38
|
-
return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs
|
|
61
|
+
return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
|
|
62
|
+
milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would }) };
|
|
63
|
+
}
|
|
64
|
+
if (rec.event === "SANDBOX_USAGE") {
|
|
65
|
+
return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,
|
|
66
|
+
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined, batch: rec.batch }) };
|
|
67
|
+
}
|
|
68
|
+
if (rec.event === "STRUCTURE_DELTA") {
|
|
69
|
+
const bit = (v) => (typeof v === "boolean" ? Number(v) : v);
|
|
70
|
+
return { event: "structure_delta", phase: rec.phase ?? "implement", ...prOf(rec), payload: compact({ edges_added: rec.edges_added, edges_removed: rec.edges_removed,
|
|
71
|
+
import_files_added: rec.import_files_added, import_files_removed: rec.import_files_removed, boundary_changed: bit(rec.boundary_changed), boundary_loosened: bit(rec.boundary_loosened),
|
|
72
|
+
...structureLists(rec) }) };
|
|
39
73
|
}
|
|
40
74
|
if (rec.event === "CI_PENDING_WAIT") {
|
|
41
75
|
return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
|
|
@@ -59,7 +93,8 @@ export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now,
|
|
|
59
93
|
if (!tried) { tried = true; resolved = resolveRepo() || null; }
|
|
60
94
|
if (!resolved) return; // no destination: the Node emitter would drop it anyway
|
|
61
95
|
// ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
|
|
62
|
-
emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined
|
|
96
|
+
emitFn(mapped.event, mapped.payload, { repo: resolved, issue: rec.issue, run: `r_${rec.issue}_${Math.floor(startedAt / 1000)}`, phase: mapped.phase ?? undefined,
|
|
97
|
+
...(Number.isSafeInteger(mapped.pr) ? { pr: mapped.pr } : {}) });
|
|
63
98
|
} catch { /* telemetry never fails a run */ }
|
|
64
99
|
};
|
|
65
100
|
}
|
|
@@ -14,6 +14,12 @@ function harness(over = {}) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
describe("engine telemetry seam", () => {
|
|
17
|
+
it("forge#4257: phase_attempt carries the backend, omitted when unreported", () => {
|
|
18
|
+
assert.equal(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: "api" }, 0).payload.backend, "api");
|
|
19
|
+
assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, backend: null }, 0).payload, false);
|
|
20
|
+
assert.equal("backend" in mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1 }, 0).payload, false);
|
|
21
|
+
});
|
|
22
|
+
|
|
17
23
|
it("flattens usage, omits nulls and passes repo/issue/run/phase as ctx (not payload)", async () => {
|
|
18
24
|
const { calls, emit } = harness();
|
|
19
25
|
await emit(attempt);
|
|
@@ -37,6 +43,35 @@ describe("engine telemetry seam", () => {
|
|
|
37
43
|
assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
|
|
38
44
|
assert.equal(calls[0][2].phase, undefined);
|
|
39
45
|
});
|
|
46
|
+
it("maps STRUCTURE_DELTA (forge#4159) to structure_delta with scalar counts and 0/1 flags", () => {
|
|
47
|
+
const m = mapRecord({ event: "STRUCTURE_DELTA", issue: 7, edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: true, boundary_loosened: false }, 10);
|
|
48
|
+
assert.deepEqual(m, { event: "structure_delta", phase: "implement", payload: { edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: 1, boundary_loosened: 0 } });
|
|
49
|
+
});
|
|
50
|
+
it("forge#4165: the merge-time STRUCTURE_DELTA and RUN_TERMINAL carry scalar lists, milestone and the pr in ctx", async () => {
|
|
51
|
+
const { calls, emit } = harness();
|
|
52
|
+
await emit({ event: "STRUCTURE_DELTA", issue: 7, pr: 41, phase: "merge", milestone: "M", edges_added: 1, edges_removed: 0, import_files_added: 2, import_files_removed: 0,
|
|
53
|
+
boundary_changed: false, boundary_loosened: false, deps_added: [{ from: "@s/a", to: "@s/b" }], deps_removed: [], new_edges: [{ from: "@s/a", to: "@s/c", file: "x.ts" }, { from: "@s/d", to: "@s/c" }],
|
|
54
|
+
removed_edges: [], top_growth: [{ file: "x.ts", lines: 9 }], decisions: [{ id: "K1", source: "lint" }, { id: "K2", source: null }] });
|
|
55
|
+
await emit({ event: "RUN_TERMINAL", issue: 7, pr: 41, reason: "merged", code: null, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
|
|
56
|
+
assert.equal(calls.length, 2);
|
|
57
|
+
const [[e1, p1, c1], [e2, p2, c2]] = calls;
|
|
58
|
+
assert.deepEqual([e1, c1.pr, c1.phase, e2, c2.pr], ["structure_delta", 41, "merge", "run_terminal", 41]);
|
|
59
|
+
assert.deepEqual([p1.deps_added, p1.new_edges, p1.removed_edges, p1.top_growth, p1.decisions, p1.decisions_lint, p1.decisions_doc, p1.milestone],
|
|
60
|
+
["@s/a:@s/b", "@s/a:@s/c,@s/d:@s/c", "none", "x.ts+9", "K1:lint,K2:none", 1, 0, "M"]);
|
|
61
|
+
for (const v of [...Object.values(p1), ...Object.values(p2)]) assert.notEqual(typeof v, "object");
|
|
62
|
+
assert.deepEqual(p2, { reason: "merged", wall_ms: 0, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
|
|
63
|
+
assert.equal(mapRecord({ event: "RUN_TERMINAL", reason: "merged", pr: 0 }, 1).pr, undefined, "no pr 0 in ctx");
|
|
64
|
+
});
|
|
65
|
+
it("forge#4255: SANDBOX_USAGE maps to sandbox_usage with provider, minutes and cost", async () => {
|
|
66
|
+
const { calls, emit } = harness();
|
|
67
|
+
await emit({ event: "SANDBOX_USAGE", issue: 7, provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.1234567891, batch: "b1" });
|
|
68
|
+
const [event, payload, ctx] = calls[0];
|
|
69
|
+
assert.equal(event, "sandbox_usage");
|
|
70
|
+
assert.deepEqual(payload, { provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.123457, batch: "b1" });
|
|
71
|
+
assert.equal(ctx.issue, 7);
|
|
72
|
+
assert.equal(ctx.phase, undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
40
75
|
it("null usage yields a record with no token fields", () => {
|
|
41
76
|
assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
|
|
42
77
|
});
|