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.
Files changed (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,431 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Lane execution targets (#4253): forge.yaml `execution: {mode, max_remote, provider}` and the sandbox-provider seam
5
+ * behind `orchestrate`'s `runOne`/`adopt`.
6
+ *
7
+ * A provider is `{name, idFor(issue), start({issue, args, env, secrets}) -> handle, status(handle) -> running|exited|gone,
8
+ * fetchResult(handle) -> result|null, teardown(handle) -> boolean}`. A handle is JSON (persisted in the batch state so a
9
+ * resumed dispatcher re-attaches) with `{provider, id, issue}`; `id` is `idFor(issue)`, known before the sandbox exists.
10
+ * `teardown` returns true only when the sandbox is gone. Providers here: `local` (today's detached child, batch.mjs) and
11
+ * the reference `container` (an ephemeral local container with its own clone of the checkout and a read-only shared
12
+ * cache mount). The lane inside still runs `run-issue`, so results, leases and FORGE annotations flow through GitHub; the
13
+ * dispatcher only reads the result file.
14
+ *
15
+ * `providerLanes` registers each sandbox in the lane ledger (kind `sandbox`) BEFORE `start` and releases it only after
16
+ * `teardown` returned true, on every exit path (result, failure, no result, error, adopt after a restart). A dispatcher
17
+ * killed mid-lane leaves the entry under its dead pid: a `--resume` adopts the sandbox, `forgedock gc --apply` reaps it.
18
+ * Secrets reach a sandbox only by NAME from `execution.secrets` (plus FORGEDOCK_BATCH_ID and GH_REPO, the target repo);
19
+ * values never appear in argv, the handle, the ledger or the batch state.
20
+ *
21
+ * `remote` (#4254, sandbox-remote.mjs) reaches a runner pool or remote session through a transport (`execution.endpoint`,
22
+ * credential `execution.token_env` from the env or the keychain). It is never a default, and a GitHub Actions endpoint
23
+ * needs `allow_actions: true`. A sandbox that fails to start is a typed outcome, not an engine-error: `needs-human`
24
+ * `sandbox.start_failed`, or `deferred` `sandbox.capacity` when the provider marks the error retryable. A provider that
25
+ * sets `handle.minutes` (billable sandbox minutes) has them used as the lane's sandbox minutes (#4255 below).
26
+ *
27
+ * Sandbox cost and budget (#4255): a provider may implement `usage(handle, {startedAt, endedAt}) -> {minutes, cost_usd}`;
28
+ * otherwise a lane's usage is its wall-clock minutes (from the handle's persisted `startedAt`) times
29
+ * `execution.cost_per_minute`. `providerLanes` attaches it to every result as `sandbox: {provider, id, minutes, cost_usd}`.
30
+ * `openExecutionBudget` enforces the hard caps (`budget_minutes`/`budget_usd` per batch, `day_budget_*` per host UTC day,
31
+ * from the host-day store `execution-budget.json` in the sched dir): pure arithmetic, no LLM.
32
+ *
33
+ * Hybrid (#4256): `placeLane` places each lane from facts (lane kind, validation weight, host headroom, verify.mode);
34
+ * orchestrate runs a remote-placed lane locally when `max_remote` or the execution budget is exhausted.
35
+ */
36
+ import { execFile } from "node:child_process";
37
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
38
+ import { readFile } from "node:fs/promises";
39
+ import { cpus, homedir, loadavg } from "node:os";
40
+ import { dirname, join } from "node:path";
41
+ import { promisify } from "node:util";
42
+ import { isLaneAlive, launchLane, writeAtomic } from "./batch.mjs";
43
+ import { schedDir } from "./sched.mjs";
44
+ import { dockerLabels, safeSegment } from "./ledger.mjs";
45
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
46
+ import { resolveCredential } from "./credentials.mjs";
47
+ import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remote.mjs";
48
+ import { ADMISSION_DEFAULTS } from "./admission.mjs";
49
+ import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
50
+ import { detectPins } from "./toolchain.mjs";
51
+
52
+ export const EXECUTION_MODES = Object.freeze(["local", "remote", "hybrid"]);
53
+ export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote"]);
54
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
55
+ const ENV_RE = /^[A-Z_][A-Z0-9_]*$/;
56
+ const IMAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/:@-]*$/;
57
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
58
+ const errText = (e) => String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 300);
59
+
60
+ /**
61
+ * forge.yaml `execution:` section (block style; parseForgeYaml gives strings) → {mode, maxRemote, provider, image,
62
+ * secrets, budget}, plus the remote provider settings `endpoint`, `tokenEnv`, `cacheStore`, `allowActions` (#4254) when set
63
+ * (absent means null/false). Invalid values fall back to the defaults; mode defaults to local (today's behaviour). The
64
+ * provider default is never `remote`. `budget` is the flat-key `execution.budget` (#4255): a missing, non-numeric or
65
+ * negative cap is null (no cap).
66
+ */
67
+ export function parseExecution(sec = {}) {
68
+ const s = sec && typeof sec === "object" ? sec : {};
69
+ const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
70
+ const mode = EXECUTION_MODES.includes(str(s.mode)) ? str(s.mode) : "local";
71
+ const n = Number(str(s.max_remote));
72
+ const maxRemote = str(s.max_remote) !== "" && Number.isInteger(n) && n >= 0 ? n : 0;
73
+ const provider = NAME_RE.test(str(s.provider)) ? str(s.provider) : mode === "local" ? "local" : "container";
74
+ const image = IMAGE_RE.test(str(s.image)) ? str(s.image) : null;
75
+ const raw = Array.isArray(s.secrets) ? s.secrets : str(s.secrets) ? str(s.secrets).split(",") : [];
76
+ const secrets = [...new Set(raw.map((x) => String(x).trim()).filter((x) => ENV_RE.test(x)))];
77
+ let endpoint = null;
78
+ try { const u = new URL(str(s.endpoint)); if (u.protocol === "https:" || u.protocol === "http:") endpoint = str(s.endpoint); } catch { endpoint = null; }
79
+ const tokenEnv = ENV_RE.test(str(s.token_env)) ? str(s.token_env) : null;
80
+ const cacheStore = str(s.cache_store) || null;
81
+ const allowActions = str(s.allow_actions).toLowerCase() === "true";
82
+ const cap = (v) => { const t = str(v), x = Number(t); return t !== "" && Number.isFinite(x) && x >= 0 ? x : null; };
83
+ const cpm = cap(s.cost_per_minute);
84
+ const budget = { batchMinutes: cap(s.budget_minutes), batchUsd: cap(s.budget_usd), dayMinutes: cap(s.day_budget_minutes), dayUsd: cap(s.day_budget_usd),
85
+ onBudget: str(s.on_budget) === "local" ? "local" : "stop", costPerMinute: cpm ?? 0 };
86
+ return { mode, maxRemote, provider, image, secrets, budget, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}) };
87
+ }
88
+
89
+ const round1 = (x) => Math.round(x * 10) / 10;
90
+ const round6 = (x) => Math.round(x * 1e6) / 1e6;
91
+ const nonNeg = (x) => (Number.isFinite(x) && x >= 0 ? x : null);
92
+
93
+ /** The host-day budget store: `FORGEDOCK_EXECUTION_BUDGET_FILE`, else `execution-budget.json` in the sched dir. */
94
+ export function executionBudgetFile(env = process.env) {
95
+ return env.FORGEDOCK_EXECUTION_BUDGET_FILE || join(schedDir(env), "execution-budget.json");
96
+ }
97
+
98
+ const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
99
+ const KEEP_DAYS = 7;
100
+
101
+ /**
102
+ * The execution budget (#4255). `record(key, {minutes, cost_usd})` adds one lane attempt to today's host total; the key
103
+ * (`<batch>#<sandbox id>#<startedAt>`) makes a repeated record a no-op. `admit({minutes, cost_usd, running})` gets the
104
+ * batch's settled + running usage (`running` the running part alone) and refuses once a batch cap, or a day cap with
105
+ * today's host total plus this batch's running usage, is reached. A corrupt or unreadable day file refuses while a day
106
+ * cap is set (a hard cap fails closed) and is ignored otherwise; it is never overwritten.
107
+ */
108
+ export function openExecutionBudget({ budget = {}, file = executionBudgetFile(), now = () => Date.now() } = {}) {
109
+ const b = budget ?? {};
110
+ const dayCap = b.dayMinutes != null || b.dayUsd != null;
111
+ const read = () => {
112
+ let raw;
113
+ try { raw = readFileSync(file, "utf8"); } catch (e) { return e?.code === "ENOENT" ? { ok: true, days: {} } : { ok: false }; }
114
+ try {
115
+ const o = JSON.parse(raw);
116
+ if (!o || typeof o !== "object" || (o.days !== undefined && (typeof o.days !== "object" || o.days === null || Array.isArray(o.days)))) return { ok: false };
117
+ return { ok: true, days: o.days ?? {} };
118
+ } catch { return { ok: false }; }
119
+ };
120
+ const totals = (day) => {
121
+ let minutes = 0, cost_usd = 0;
122
+ for (const u of Object.values(day ?? {})) { minutes += nonNeg(u?.minutes) ?? 0; cost_usd += nonNeg(u?.cost_usd) ?? 0; }
123
+ return { minutes: round1(minutes), cost_usd: round6(cost_usd) };
124
+ };
125
+ const dayTotals = () => { const r = read(); return r.ok ? { ok: true, day: utcDay(now()), ...totals(r.days[utcDay(now())]) } : { ok: false, day: utcDay(now()), minutes: null, cost_usd: null }; };
126
+ return {
127
+ file, budget: b,
128
+ record: (key, u) => {
129
+ const r = read();
130
+ if (!r.ok) return false;
131
+ const d = utcDay(now()), today = { ...(r.days[d] ?? {}) };
132
+ if (today[key]) return false;
133
+ today[key] = { minutes: nonNeg(u?.minutes) ?? 0, cost_usd: nonNeg(u?.cost_usd) ?? 0, at: now() };
134
+ const days = { [d]: today };
135
+ for (const [k, v] of Object.entries(r.days)) if (k !== d && Date.parse(k) > now() - KEEP_DAYS * 86400000) days[k] = v;
136
+ writeAtomic(file, JSON.stringify({ v: 1, days }, null, 2) + "\n");
137
+ return true;
138
+ },
139
+ dayTotals,
140
+ admit: (u = {}) => {
141
+ const m = nonNeg(u.minutes) ?? 0, c = nonNeg(u.cost_usd) ?? 0;
142
+ if (b.batchMinutes != null && m >= b.batchMinutes) return { ok: false, reason: `batch sandbox minutes ${round1(m)} >= budget_minutes ${b.batchMinutes}` };
143
+ if (b.batchUsd != null && c >= b.batchUsd) return { ok: false, reason: `batch sandbox cost $${c.toFixed(2)} >= budget_usd ${b.batchUsd}` };
144
+ if (!dayCap) return { ok: true };
145
+ const t = dayTotals();
146
+ if (!t.ok) return { ok: false, reason: `host-day budget file ${file} is unreadable; fix or remove it` };
147
+ const dm = t.minutes + (nonNeg(u.running?.minutes) ?? 0), dc = t.cost_usd + (nonNeg(u.running?.cost_usd) ?? 0);
148
+ if (b.dayMinutes != null && dm >= b.dayMinutes) return { ok: false, reason: `host sandbox minutes today ${round1(dm)} >= day_budget_minutes ${b.dayMinutes}` };
149
+ if (b.dayUsd != null && dc >= b.dayUsd) return { ok: false, reason: `host sandbox cost today $${dc.toFixed(2)} >= day_budget_usd ${b.dayUsd}` };
150
+ return { ok: true };
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Where a `mode: remote` lane runs: remote only with `max_remote >= 1`. Hybrid is placed per lane by `placeLane` (#4256). */
156
+ export function placement(exec) {
157
+ return exec?.mode === "remote" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1 ? "remote" : "local";
158
+ }
159
+
160
+ const DOCS_LABELS = new Set(["docs", "documentation", "type:docs"]);
161
+
162
+ /**
163
+ * Hybrid placement (#4256), pure: facts in, `{where: "local"|"remote", reason}` out. `exec` is parseExecution output,
164
+ * `verifyMode` forge.yaml `verify.mode`, `labels` the issue's label names (the lane kind), `weight` from
165
+ * `validationWeight`, `headroom` from `hostHeadroom`. In order: a non-hybrid mode keeps `placement()`; `max_remote` 0 is
166
+ * local; `verify.mode: ci` is local (heavy validation already runs, and is billed, on CI); a docs-only lane is local; a
167
+ * heavy-validation lane is remote; a host without headroom sends the lane remote; anything else stays on the host.
168
+ * Capacity (`max_remote` reached, the execution budget) is applied at admission by orchestrate, which falls back to local.
169
+ */
170
+ export function placeLane({ exec = null, verifyMode = "local", labels = [], weight = null, headroom = null } = {}) {
171
+ const mode = exec?.mode;
172
+ if (mode !== "hybrid") { const where = placement(exec); return { where, reason: `mode ${EXECUTION_MODES.includes(mode) ? mode : "local"}` }; }
173
+ if (!(Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1)) return { where: "local", reason: "max_remote 0" };
174
+ if (verifyMode === "ci") return { where: "local", reason: "verify.mode ci: validation runs on CI, not billed twice" };
175
+ const names = (Array.isArray(labels) ? labels : []).map((l) => String(l ?? "").trim().toLowerCase());
176
+ if (names.some((l) => DOCS_LABELS.has(l))) return { where: "local", reason: "docs-only lane" };
177
+ if (weight?.heavy === true) {
178
+ const sig = (Array.isArray(weight.signals) ? weight.signals : []).map(String).filter(Boolean);
179
+ return { where: "remote", reason: `heavy validation${sig.length ? `: ${sig.join(", ")}` : ""}` };
180
+ }
181
+ if (headroom && headroom.ok === false) return { where: "remote", reason: `host without headroom${headroom.detail ? `: ${headroom.detail}` : ""}` };
182
+ return { where: "local", reason: "light lane, host has headroom" };
183
+ }
184
+
185
+ /** Repo files that mean a heavy validation toolchain (type checks, linters, test suites, compiles). */
186
+ const HEAVY_MARKERS = Object.freeze([
187
+ ["tsconfig.json", "tsc"], ["Cargo.toml", "cargo"], ["go.mod", "go"], ["pyproject.toml", "python"], ["setup.cfg", "python"], ["pytest.ini", "pytest"],
188
+ ["pom.xml", "maven"], ["build.gradle", "gradle"], ["build.gradle.kts", "gradle"],
189
+ ["eslint.config.js", "eslint"], ["eslint.config.mjs", "eslint"], ["eslint.config.cjs", "eslint"], [".eslintrc", "eslint"], [".eslintrc.js", "eslint"], [".eslintrc.cjs", "eslint"], [".eslintrc.json", "eslint"], [".eslintrc.yml", "eslint"],
190
+ ]);
191
+ const HEAVY_SCRIPTS = Object.freeze(["test", "lint", "typecheck", "type-check", "build"]);
192
+
193
+ /**
194
+ * The repo's expected validation weight (#4256): heavy when a declared check command (forge.yaml `learned.test_commands`)
195
+ * classifies heavy (heavy.mjs `classifyStep`), or a heavy toolchain marker or package.json test/lint/typecheck/build script
196
+ * exists. The toolchain pin (toolchain.mjs `detectPins`) names the package manager among the signals. Zero LLM.
197
+ */
198
+ export async function validationWeight({ cwd = process.cwd(), precheckCommands = [], read = (p) => readFile(p, "utf8") } = {}) {
199
+ const signals = [];
200
+ const add = (s) => { if (!signals.includes(s)) signals.push(s); };
201
+ for (const cmd of Array.isArray(precheckCommands) ? precheckCommands : []) {
202
+ const [name, ...args] = String(cmd ?? "").trim().split(/\s+/).filter(Boolean);
203
+ if (name && classifyStep(name, args) === "heavy") add(name.split("/").pop());
204
+ }
205
+ const exists = async (f) => { try { await read(join(cwd, f)); return true; } catch { return false; } };
206
+ for (const [f, sig] of HEAVY_MARKERS) if (await exists(f)) add(sig);
207
+ let pkg = null;
208
+ try { pkg = JSON.parse(String(await read(join(cwd, "package.json")))); } catch { pkg = null; }
209
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
210
+ for (const k of HEAVY_SCRIPTS) if (typeof scripts[k] === "string" && scripts[k].trim()) add(`${k} script`);
211
+ if (signals.length) {
212
+ let pins = null;
213
+ try { pins = await detectPins(cwd, { read: (p) => read(p) }); } catch { pins = null; }
214
+ if (pins?.pm?.name) add(pins.pm.name);
215
+ }
216
+ return { heavy: signals.length > 0, signals };
217
+ }
218
+
219
+ /**
220
+ * Host headroom snapshot for placement (#4256), synchronous: not ok when available memory is below the admission floor
221
+ * (`min_free_mem_mb`) plus one heavy step (`step_mem_mb`), or the 1-min load per core exceeds `max_load_per_core` when
222
+ * that is configured. Probes are injected; an unreadable probe does not count against the host.
223
+ */
224
+ export function hostHeadroom({ cfg = {}, heavyCfg = {}, host = { memAvailableMb, loadAvg1: () => loadavg()[0], cores: () => cpus().length || 1 } } = {}) {
225
+ const c = { ...ADMISSION_DEFAULTS, ...Object.fromEntries(Object.entries(cfg ?? {}).filter(([, v]) => v !== undefined && v !== null)) };
226
+ const h = heavyConfig(heavyCfg ?? {});
227
+ const probe = (fn) => { try { const v = fn?.(); return Number.isFinite(v) ? v : null; } catch { return null; } };
228
+ if (c.enabled === false) return { ok: true, detail: "" };
229
+ const reasons = [];
230
+ const need = c.min_free_mem_mb + h.step_mem_mb, mem = probe(host?.memAvailableMb);
231
+ if (mem !== null && mem < need) reasons.push(`mem ${mem} MB < ${need} MB`);
232
+ const maxLoad = typeof c.max_load_per_core === "number" && Number.isFinite(c.max_load_per_core) ? c.max_load_per_core : null;
233
+ if (maxLoad !== null) {
234
+ const load = probe(host?.loadAvg1), cores = probe(host?.cores);
235
+ if (load !== null && cores && load / cores > maxLoad) reasons.push(`load ${(load / cores).toFixed(1)}/core > ${maxLoad}`);
236
+ }
237
+ return { ok: reasons.length === 0, detail: reasons.join(", ") };
238
+ }
239
+
240
+ /** The env a sandbox gets: FORGEDOCK_BATCH_ID, GH_REPO and the allowlisted secret names present in `env`. Nothing else. */
241
+ export function scopedEnv({ env = {}, secrets = [], batchId, repo }) {
242
+ const out = {};
243
+ if (batchId) out.FORGEDOCK_BATCH_ID = String(batchId);
244
+ if (repo) out.GH_REPO = String(repo);
245
+ for (const k of secrets) if (ENV_RE.test(k) && env[k] !== undefined && env[k] !== "") out[k] = String(env[k]);
246
+ return out;
247
+ }
248
+
249
+ /** Today's detached child as a provider: the handle carries the pid; nothing to tear down (the lane is its own process). */
250
+ export function localProvider({ batch, script, cwd, bin, spawnFn, alive = isLaneAlive }) {
251
+ return {
252
+ name: "local",
253
+ idFor: (issue) => `local-${safeSegment(batch.id)}-${issue}`,
254
+ async start({ issue, args = [], env = process.env }) {
255
+ const { pid } = launchLane({ batch, issue, args, script, cwd, env, ...(bin ? { bin } : {}), ...(spawnFn ? { spawnFn } : {}) });
256
+ return { provider: "local", id: `local-${safeSegment(batch.id)}-${issue}`, issue, pid };
257
+ },
258
+ async status(h) { return alive(h?.pid, batch.resultFile(h?.issue)) ? "running" : "exited"; },
259
+ async fetchResult(h) { return readJson(batch.resultFile(h?.issue)); },
260
+ async teardown() { return true; },
261
+ };
262
+ }
263
+
264
+ const pexec = promisify(execFile);
265
+ /** `docker <args>` with exactly `env` added to a minimal base (PATH, HOME, DOCKER_*), so `-e NAME` reads only those. */
266
+ export const runDocker = async (args, { env = {} } = {}) => {
267
+ const base = Object.fromEntries(Object.entries(process.env).filter(([k]) => k === "PATH" || k === "HOME" || k.startsWith("DOCKER_")));
268
+ return (await pexec("docker", args, { env: { ...base, ...env }, maxBuffer: 16 * 1024 * 1024, timeout: 120000 })).stdout;
269
+ };
270
+
271
+ /**
272
+ * Reference sandbox: `docker run -d --rm --pull never` of `image`, labelled like every ForgeDock container (#4181) plus
273
+ * `forgedock.sandbox=1` (`labels` entries override the lane's own of the same key). The image must provide git and
274
+ * `forgedock`. Mounts: the checkout's git dir read-only at /src (cloned into the container's own /work), the
275
+ * shared cache dir read-only at /cache (when it exists), and the batch `lanes/` dir at /result (result + log).
276
+ * `command(issue, args, resultPath, logPath)` overrides the in-container argv (tests run a stub lane).
277
+ */
278
+ export function containerProvider({ batch, repo, image, gitDir, cacheDir = `${homedir()}/.forge/verify-cache`, docker = runDocker, labels = [],
279
+ mounts = [], command = null, user = typeof process.getuid === "function" ? `${process.getuid()}:${process.getgid()}` : null, now = () => Date.now(), ttlMs = 6 * 3600 * 1000 }) {
280
+ const idFor = (issue) => `fdsbx-${safeSegment(batch.id)}-${issue}`;
281
+ const defaultCommand = (issue, args, rf, log) => ["sh", "-c",
282
+ 'exec >>"$LANE_LOG" 2>&1; git -c safe.directory="*" clone -q /src /work && cd /work && exec forgedock "$@"', "sandbox", "run-issue", String(issue), ...args, "--result-file", rf];
283
+ return {
284
+ name: "container",
285
+ idFor,
286
+ async start({ issue, args = [], env = {}, secrets = [] }) {
287
+ if (!image) throw new Error("execution.image is required for the container provider");
288
+ if (!gitDir) throw new Error("container provider needs the checkout's git dir");
289
+ const rf = batch.resultFile(issue);
290
+ rmSync(rf, { force: true });
291
+ const lanesDir = dirname(rf);
292
+ mkdirSync(lanesDir, { recursive: true }); // docker would create a missing bind source as root
293
+ const scoped = { ...scopedEnv({ env, secrets, batchId: batch.id, repo }), HOME: "/tmp", LANE_LOG: `/result/${issue}.log` };
294
+ const id = idFor(issue);
295
+ const own = dockerLabels({ batch: batch.id, lane: `sandbox-${issue}`, issue, expires: now() + ttlMs });
296
+ const argv = ["run", "-d", "--rm", "--pull", "never", "--name", id,
297
+ ...[...own.filter((o) => !labels.some((l) => String(l).split("=")[0] === o.split("=")[0])), ...labels, "forgedock.sandbox=1"].flatMap((l) => ["--label", l]),
298
+ ...(user ? ["--user", user] : []),
299
+ "-v", `${gitDir}:/src:ro`, ...(cacheDir && existsSync(cacheDir) ? ["-v", `${cacheDir}:/cache:ro`] : []), "-v", `${lanesDir}:/result`,
300
+ ...mounts.flatMap((m) => ["-v", m]),
301
+ ...Object.keys(scoped).flatMap((k) => ["-e", k]), // names only: docker reads each value from its own env
302
+ image, ...(command ?? defaultCommand)(issue, args, `/result/${issue}.json`, `/result/${issue}.log`)];
303
+ await docker(argv, { env: scoped });
304
+ return { provider: "container", id, issue };
305
+ },
306
+ async status(h) {
307
+ let s;
308
+ try { s = String(await docker(["inspect", "-f", "{{.State.Status}}", h.id])).trim(); } catch { return "gone"; }
309
+ return ["running", "created", "restarting"].includes(s) ? "running" : "exited";
310
+ },
311
+ async fetchResult(h) { return readJson(batch.resultFile(h?.issue)); },
312
+ async teardown(h) {
313
+ try { await docker(["rm", "-f", h.id]); return true; } catch (e) {
314
+ if (/no such container/i.test(errText(e))) return true;
315
+ // `--rm` may be removing it already ("removal ... already in progress"): torn down once inspect finds nothing.
316
+ try { await docker(["inspect", "-f", "{{.Id}}", h.id]); return false; } catch { return true; }
317
+ }
318
+ },
319
+ };
320
+ }
321
+
322
+ const typedError = (code, message) => Object.assign(new Error(message), { code });
323
+
324
+ /**
325
+ * The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
326
+ * `exec.endpoint` with the credential `exec.tokenEnv` (env, then keychain). Throws `execution.provider.misconfigured`
327
+ * without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
328
+ */
329
+ export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = (n) => resolveCredential(n, { env }) } = {}) {
330
+ if (transport) return transport;
331
+ if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
332
+ if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
333
+ throw typedError("execution.provider.actions_refused", "execution.endpoint is a GitHub Actions API; set execution.allow_actions: true to spend Actions minutes");
334
+ }
335
+ return httpTransport({ endpoint: exec.endpoint, token: exec.tokenEnv ? credentials(exec.tokenEnv) : null });
336
+ }
337
+
338
+ /** A provider by name; throws a typed error for an unknown one. `remote` reads `opts.exec` (parseExecution output). */
339
+ export function providerByName(name, opts) {
340
+ if (name === "local") return localProvider(opts);
341
+ if (name === "container") return containerProvider(opts);
342
+ if (name === "remote") {
343
+ const exec = opts?.exec ?? {};
344
+ const env = opts?.env ?? process.env;
345
+ return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: exec, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
346
+ }
347
+ const e = new Error(`unknown execution.provider ${JSON.stringify(name)} (known: ${SANDBOX_PROVIDERS.join(", ")})`);
348
+ e.code = "execution.provider.unknown";
349
+ throw e;
350
+ }
351
+
352
+ /**
353
+ * Lane seams for `orchestrate()` over a provider, the same contract as batch.mjs `detachedLanes`. `ledgerFor(issue)`
354
+ * returns that sandbox's ledger (ledger.mjs `makeLedger`).
355
+ */
356
+ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
357
+ costPerMinute = 0, now = () => Date.now() }) {
358
+ const cpm = nonNeg(Number(costPerMinute)) ?? 0;
359
+ // #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
360
+ // clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
361
+ const withUsage = async (r, h) => {
362
+ if (!r || typeof r !== "object") return r;
363
+ const endedAt = now(), startedAt = Number.isFinite(h?.startedAt) ? h.startedAt : endedAt;
364
+ let u = null;
365
+ try { u = typeof provider.usage === "function" ? await provider.usage(h, { startedAt, endedAt }) : null; } catch { u = null; }
366
+ const minutes = nonNeg(u?.minutes) ?? nonNeg(h?.minutes) ?? round1(Math.max(0, endedAt - startedAt) / 60000);
367
+ const cost = nonNeg(u?.cost_usd) ?? minutes * cpm;
368
+ return { ...r, sandbox: { provider: provider.name, id: h.id, minutes: round1(minutes), cost_usd: round6(cost) } };
369
+ };
370
+ const tear = async (led, h) => {
371
+ let ok = false;
372
+ try { ok = (await provider.teardown(h)) === true; } catch { ok = false; }
373
+ if (ok) { try { led?.release("sandbox", h.id); led?.remove(); } catch { /* the reaper retries */ } }
374
+ return ok;
375
+ };
376
+ const wait = async (h) => {
377
+ for (;;) {
378
+ const r = await provider.fetchResult(h);
379
+ if (r) return r;
380
+ const st = await provider.status(h);
381
+ if (st !== "running") {
382
+ const last = await provider.fetchResult(h); // it may have written the result just before exiting
383
+ return last ?? { terminalReason: "engine-error", detail: `sandbox ${h.id} ${st} without a result` };
384
+ }
385
+ await sleep(pollMs);
386
+ }
387
+ };
388
+ // A start failure repeats on a relaunch: typed, never engine-error (#4254). Retryable (capacity) defers with backoff.
389
+ const startFailed = (id, e) => (e?.retryable === true
390
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
391
+ : { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
392
+ return {
393
+ runOne: async (n, lane, ctx = {}) => {
394
+ const led = ledgerFor(n);
395
+ const base = { provider: provider.name, id: provider.idFor(n), issue: n, startedAt: now() };
396
+ // A sandbox an earlier attempt left behind (same id) goes first, or the new one could not start.
397
+ if ((led?.entries("sandbox") ?? []).some((e) => e.value === base.id) && !(await tear(led, base))) {
398
+ return { terminalReason: "engine-error", detail: `sandbox ${base.id} from an earlier attempt could not be torn down` };
399
+ }
400
+ led?.register("sandbox", base.id, { provider: provider.name, handle: base }); // before it exists
401
+ let h = base, r, started = false;
402
+ try {
403
+ h = { ...base, ...(await provider.start({ issue: n, args: laneArgs(n, lane), env, secrets })) };
404
+ if (!Number.isFinite(h.startedAt)) h.startedAt = base.startedAt;
405
+ started = true;
406
+ ctx.onStart?.({ ...(Number.isInteger(h.pid) ? { pid: h.pid } : {}), handle: h, remote: true });
407
+ r = await wait(h);
408
+ } catch (e) {
409
+ r = started ? { terminalReason: "engine-error", detail: `sandbox ${base.id}: ${errText(e)}` } : startFailed(base.id, e);
410
+ }
411
+ await tear(led, h); // every path, after the result is in (tear never throws)
412
+ return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
413
+ },
414
+ adopt: async (n, rec) => {
415
+ const saved = rec?.handle;
416
+ if (!saved?.id || saved.provider !== provider.name) return null;
417
+ // Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
418
+ const h = { ...saved, ...(Number.isFinite(saved.startedAt) ? {} : Number.isFinite(rec?.startedAt) ? { startedAt: rec.startedAt } : {}) };
419
+ const led = ledgerFor(n);
420
+ led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
421
+ let r;
422
+ try {
423
+ r = !(await provider.fetchResult(h)) && (await provider.status(h)) !== "running" ? null : await wait(h); // null: relaunched
424
+ } catch (e) {
425
+ r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
426
+ }
427
+ await tear(led, h);
428
+ return withUsage(r, h);
429
+ },
430
+ };
431
+ }