forgedock 1.27.0 → 1.29.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 +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- 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 +104 -9
- 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 +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- 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/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- 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 +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- 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 +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- 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 +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -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 +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -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 +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- 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/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -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/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- 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/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -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
|
+
}
|
|
@@ -47,6 +47,9 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
47
47
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
48
48
|
CLI_TIMEOUT: "cli.timeout",
|
|
49
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
|
|
50
53
|
LEASE_HELD: "lease.held",
|
|
51
54
|
DEPENDENCY_OPEN: "dependency.open",
|
|
52
55
|
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
@@ -77,8 +80,14 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
77
80
|
CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
|
|
78
81
|
CONFORMANCE_REGRESSION: "conformance.regression",
|
|
79
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
|
|
80
85
|
FOLLOWUP_UNAVAILABLE: "followup.unavailable",
|
|
81
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
|
|
82
91
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
|
83
92
|
COMPLETED: "completed",
|
|
84
93
|
AWAITING_MERGE: "awaiting_merge",
|
|
@@ -133,6 +142,7 @@ export function codeForRunnerError(err) {
|
|
|
133
142
|
return OUTCOME_CODES.CLI_SPAWN_FAILED;
|
|
134
143
|
case "NO_API_KEY":
|
|
135
144
|
case "NO_SDK":
|
|
145
|
+
case "CLI_BACKEND_UNAVAILABLE":
|
|
136
146
|
case "CLI_BACKEND_FAILED":
|
|
137
147
|
case "PHASE_ARGS_INVALID":
|
|
138
148
|
case "CLI_MAX_TURNS":
|
|
@@ -165,3 +175,32 @@ export function codeForTerminalReason(reason) {
|
|
|
165
175
|
default: return UNCLASSIFIED;
|
|
166
176
|
}
|
|
167
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
181
|
+
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
182
|
+
*/
|
|
183
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID]);
|
|
184
|
+
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
185
|
+
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
189
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
190
|
+
* edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
|
|
191
|
+
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyNeedsHuman(row) {
|
|
194
|
+
if (!row || typeof row !== "object") return "terminal";
|
|
195
|
+
const outcome = row.outcome ?? row.terminalReason;
|
|
196
|
+
if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
|
|
197
|
+
if (outcome !== "needs-human") return "terminal";
|
|
198
|
+
if (HUMAN_CODES.includes(row.code)) return "human";
|
|
199
|
+
if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
|
|
200
|
+
return RECOVERABLE_CODES.includes(row.code) ? "recoverable" : "terminal";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The code a recovery lane works on: a conflicting `ci-pending` is recovered as a base conflict. */
|
|
204
|
+
export function recoveryCodeOf(row) {
|
|
205
|
+
return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
|
|
206
|
+
}
|
|
@@ -3,9 +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)
|
|
8
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.
|
|
9
13
|
* Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
|
|
10
14
|
* are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
|
|
11
15
|
*/
|
|
@@ -24,24 +28,48 @@ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v ===
|
|
|
24
28
|
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => scalar(v)));
|
|
25
29
|
const tok = (u, k) => (u && typeof u === "object" && Number.isFinite(u[k]) ? u[k] : undefined);
|
|
26
30
|
|
|
27
|
-
|
|
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. */
|
|
28
50
|
export function mapRecord(rec, wallMs) {
|
|
29
51
|
if (!rec || typeof rec !== "object") return null;
|
|
30
52
|
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
31
53
|
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
32
|
-
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,
|
|
33
55
|
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
34
56
|
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
35
57
|
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
|
36
58
|
}) };
|
|
37
59
|
}
|
|
38
60
|
if (rec.event === "RUN_TERMINAL") {
|
|
39
|
-
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 }) };
|
|
40
67
|
}
|
|
41
68
|
if (rec.event === "STRUCTURE_DELTA") {
|
|
42
69
|
const bit = (v) => (typeof v === "boolean" ? Number(v) : v);
|
|
43
|
-
return { event: "structure_delta", phase: rec.phase ?? "implement", payload: compact({ edges_added: rec.edges_added, edges_removed: rec.edges_removed,
|
|
44
|
-
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)
|
|
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) }) };
|
|
45
73
|
}
|
|
46
74
|
if (rec.event === "CI_PENDING_WAIT") {
|
|
47
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 }) };
|
|
@@ -65,7 +93,8 @@ export function makeEmitTelemetry({ repo = null, emitFn = emit, now = Date.now,
|
|
|
65
93
|
if (!tried) { tried = true; resolved = resolveRepo() || null; }
|
|
66
94
|
if (!resolved) return; // no destination: the Node emitter would drop it anyway
|
|
67
95
|
// ctx carries repo/issue/run/phase: emit() drops those reserved keys from the payload.
|
|
68
|
-
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 } : {}) });
|
|
69
98
|
} catch { /* telemetry never fails a run */ }
|
|
70
99
|
};
|
|
71
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);
|
|
@@ -41,6 +47,31 @@ describe("engine telemetry seam", () => {
|
|
|
41
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);
|
|
42
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 } });
|
|
43
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
|
+
|
|
44
75
|
it("null usage yields a record with no token fields", () => {
|
|
45
76
|
assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
|
|
46
77
|
});
|