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
@@ -1,9 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
- import { execFile, execFileSync } from "node:child_process";
4
+ import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -11,6 +11,9 @@ import { fileURLToPath } from "node:url";
11
11
  import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
12
12
  import { makeIo } from "../engine-cli.mjs";
13
13
  import { makeProjector } from "../engine/projector.mjs";
14
+ import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
15
+ import { makeDepCache } from "./depcache.mjs";
16
+ import { makeHeavyIo } from "./heavy.mjs";
14
17
 
15
18
  const pexec = promisify(execFile);
16
19
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -44,19 +47,45 @@ export function wrapLaneLimits(bin, args, limits) {
44
47
  return { bin: "systemd-run", args: ["--user", "--scope", "-q", ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
45
48
  }
46
49
 
50
+ /** #4211: a judgment call over data in the prompt only (the refactor:split grouping call): no tool can read a file. */
51
+ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
52
+ : ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
53
+
47
54
  export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
48
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null }) => {
55
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
49
56
  const bin = resolveClaudeCliBinary(cwd) || "claude";
50
57
  const caps = getClaudeCliCapabilities(cwd);
51
- const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
58
+ const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : [])];
52
59
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
53
60
  args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
54
61
  logger: { log() {} },
55
- idleMs, maxMs, stream: idleMs > 0, control, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
62
+ idleMs, maxMs, stream: idleMs > 0, control, onSpawn, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
56
63
  return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
57
64
  };
58
65
  }
59
66
 
67
+ /** Parent pid from /proc/<pid>/stat (the field after the parenthesised command name); 0 when unreadable. */
68
+ const ppidOf = (root, pid) => { try { const st = readFileSync(join(root, String(pid), "stat"), "utf8"); return Number(st.slice(st.lastIndexOf(")") + 2).split(" ")[1]) || 0; } catch { return 0; } };
69
+ /**
70
+ * #4162: processes whose cwd is `dir` or inside it, from /proc (Linux). Excludes this process and its ancestors.
71
+ * Returns [{pid, cmd}]; [] where /proc is unavailable (the caller then skips stopping them).
72
+ */
73
+ export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {}) {
74
+ if (!dir || !existsSync(procRoot)) return [];
75
+ try { dir = realpathSync(dir); } catch { /* not on disk: compare as given */ } // /proc cwd links are resolved paths
76
+ const skip = new Set();
77
+ for (let p = self, i = 0; p > 1 && i < 64 && !skip.has(p); i++) { skip.add(p); p = ppidOf(procRoot, p); }
78
+ const out = [];
79
+ for (const e of readdirSync(procRoot)) {
80
+ if (!/^\d+$/.test(e) || skip.has(Number(e))) continue;
81
+ let at; try { at = readlinkSync(join(procRoot, e, "cwd")); } catch { continue; }
82
+ if (at !== dir && !at.startsWith(`${dir}/`)) continue;
83
+ let cmd = ""; try { cmd = readFileSync(join(procRoot, e, "cmdline"), "utf8").split("\0").filter(Boolean).join(" ").slice(0, 200); } catch { /* exited */ }
84
+ out.push({ pid: Number(e), cmd });
85
+ }
86
+ return out;
87
+ }
88
+
60
89
  export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
61
90
  export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
62
91
  const OWNER = ".forge-run-owner";
@@ -66,6 +95,8 @@ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e)
66
95
  export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive = pidAlive, host = hostname() } = {}) {
67
96
  const inside = (d) => { const r = resolve(d); return r.startsWith(resolve(root) + sep) && !r.slice(resolve(root).length + 1).includes(sep); };
68
97
  return {
98
+ /** The dir make(issue, runId) creates, so a ledger can register it first (#4179). */
99
+ path: (runId) => join(root, String(runId)),
69
100
  async make(issue, runId) {
70
101
  const dir = join(root, String(runId));
71
102
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -100,6 +131,22 @@ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive
100
131
  };
101
132
  }
102
133
 
134
+ /**
135
+ * SIGTERM to a ledger-registered pid's process group (#4179). Only when the pid is alive and, where /proc exists, its
136
+ * command line still names `marker`, so a reused pid is never signalled. Returns true when the pid is gone or signalled.
137
+ */
138
+ export function killLedgerPid(pid, marker, { kill = process.kill.bind(process), procDir = "/proc" } = {}) {
139
+ const n = Number(pid);
140
+ if (!Number.isSafeInteger(n) || n <= 1) return false;
141
+ if (!pidAlive(n)) return true;
142
+ if (marker && existsSync(procDir)) {
143
+ let cmd = "";
144
+ try { cmd = readFileSync(join(procDir, String(n), "cmdline"), "utf8"); } catch { return true; } // exited meanwhile
145
+ if (!cmd.includes(String(marker))) return true; // pid reused by something else: not ours any more
146
+ }
147
+ try { kill(-n, "SIGTERM"); return true; } catch { try { kill(n, "SIGTERM"); return true; } catch (e) { return e?.code === "ESRCH"; } }
148
+ }
149
+
103
150
  /** Real host probes for admission (#4105). MemAvailable from /proc/meminfo (fallback os.freemem), statfs for tmp. */
104
151
  export function makeHostProbe() {
105
152
  return {
@@ -118,31 +165,96 @@ export function makeHostProbe() {
118
165
  },
119
166
  loadAvg1: () => loadavg()[0],
120
167
  cores: () => cpus().length || 1,
168
+ /** Live lanes on this host from the ledger (#4182), this lane included once it registered. */
169
+ activeLanes: () => countLiveLanes({ root: ledgerRoot(), host: hostname() }),
121
170
  tmpPaths: () => [...new Set([join(homedir(), ".forge", "tmp"), tmpdir(), "/tmp"].filter((x) => existsSync(x) || x === join(homedir(), ".forge", "tmp")))],
122
171
  };
123
172
  }
124
173
 
174
+ /**
175
+ * Required status checks for `lane` (#4158): the union of classic branch protection (contexts + checks) and the
176
+ * repository rulesets that apply to the branch (`rules/branches/{lane}`, type required_status_checks). Each source
177
+ * fails independently to [] (404, no protection, no rulesets, bad JSON), so one never hides the other.
178
+ */
179
+ export async function requiredChecksFor({ gh, repo, lane }) {
180
+ const classic = async () => {
181
+ const j = JSON.parse(String(await gh(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`])));
182
+ return [...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c?.context)];
183
+ };
184
+ const rulesets = async () => {
185
+ // --jq emits one context per line across all pages, so pagination needs no JSON stitching.
186
+ const out = String(await gh(["api", "--paginate", `repos/${repo}/rules/branches/${lane}`,
187
+ "--jq", '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]?.context']));
188
+ return out.split("\n").map((x) => x.trim());
189
+ };
190
+ const safe = async (f) => { try { const v = await f(); return Array.isArray(v) ? v : []; } catch { return []; } };
191
+ const all = [...(await safe(classic)), ...(await safe(rulesets))];
192
+ return [...new Set(all.filter((x) => typeof x === "string" && x))];
193
+ }
194
+
195
+ /** Combined stdout+stderr kept per declared check command (tail). */
196
+ const CHECK_OUTPUT_CAP = 64000;
197
+ /** #4240: run a forge.yaml declared check command locally (no gh, no Actions); {rc, output}, rc 124 on timeout. */
198
+ export function runCheckCommand({ command, cwd, timeoutMs = 300000 }) {
199
+ return new Promise((done) => {
200
+ let out = "", timedOut = false, child;
201
+ const add = (b) => { out += String(b); if (out.length > 2 * CHECK_OUTPUT_CAP) out = out.slice(-CHECK_OUTPUT_CAP); };
202
+ try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
203
+ catch (e) { done({ rc: 127, output: String(e?.message ?? e) }); return; }
204
+ const kill = () => { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
205
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
206
+ child.stdout.on("data", add); child.stderr.on("data", add);
207
+ child.on("error", (e) => { clearTimeout(timer); done({ rc: 127, output: `${out}${String(e?.message ?? e)}`.slice(-CHECK_OUTPUT_CAP) }); });
208
+ child.on("close", (code) => {
209
+ clearTimeout(timer);
210
+ const tail = out.slice(-CHECK_OUTPUT_CAP);
211
+ done(timedOut ? { rc: 124, output: `${tail}\n(timed out after ${Math.round(timeoutMs / 1000)}s)` } : { rc: Number.isInteger(code) ? code : 1, output: tail });
212
+ });
213
+ });
214
+ }
215
+
125
216
  export function makeLeanIo({ repo }) {
126
217
  const base = makeIo();
127
218
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
128
219
  return {
129
- gh: run("gh"), git: run("git"), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
130
- /** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
131
- async requiredChecks(lane) {
132
- try {
133
- const j = JSON.parse(await run("gh")(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`]));
134
- return [...new Set([...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c.context)].filter(Boolean))];
135
- } catch { return []; }
136
- },
137
- /** [{name, state: success|failure|pending}] for the PR's checks. */
220
+ gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
221
+ /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
222
+ deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
223
+ /** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
224
+ async requiredChecks(lane) { return requiredChecksFor({ gh: run("gh"), repo, lane }); },
225
+ /** [{name, state: success|failure|pending, url, head}] for the PR's checks; `head` is the PR head the rollup belongs to (#4240). */
138
226
  async checkStates(pr) {
139
- const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
227
+ const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup,headRefOid"]));
140
228
  return (j.statusCheckRollup ?? []).map((c) => {
141
229
  const v = String(c.conclusion || c.state || "").toUpperCase();
142
230
  const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(v) ? "failure" : "pending";
143
- return { name: c.name ?? c.context, state };
231
+ return { name: c.name ?? c.context, state, url: c.detailsUrl || c.targetUrl || null, head: j.headRefOid ?? null };
144
232
  });
145
233
  },
234
+ /** #4240: one declared check command (`bash -c`) in `cwd`; never throws. A timeout kills the process group and gives rc 124. */
235
+ runCommand: runCheckCommand,
236
+ /** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
237
+ async dispatchWorkflow({ workflow, ref, inputs = {} }) {
238
+ await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
239
+ },
240
+ /** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name}]. */
241
+ async workflowRuns({ workflow, branch }) {
242
+ return JSON.parse(await run("gh")(["run", "list", "-R", repo, "--workflow", String(workflow), "--branch", String(branch), "--event", "workflow_dispatch",
243
+ "--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name"]) || "[]");
244
+ },
245
+ /** Check runs on a commit (push-triggered CI): [{name, state: success|failure|pending, url}], same mapping as checkStates. */
246
+ async commitChecks(sha) {
247
+ const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {name, status, conclusion, url: .html_url} | @json"]));
248
+ return out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)).map((c) => {
249
+ const v = String(c.status === "completed" ? c.conclusion ?? "" : "").toUpperCase();
250
+ const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(v) ? "failure" : "pending";
251
+ return { name: c.name, state, url: c.url ?? null };
252
+ });
253
+ },
254
+ /** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
255
+ async failedLogs(runId) {
256
+ try { const out = String(await run("gh")(["run", "view", String(runId), "-R", repo, "--log-failed"])); return out.length > 64000 ? out.slice(-64000) : out; } catch { return ""; }
257
+ },
146
258
  async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
147
259
  /**
148
260
  * File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
@@ -155,6 +267,56 @@ export function makeLeanIo({ repo }) {
155
267
  for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
156
268
  return map;
157
269
  },
270
+ /**
271
+ * forge#4163: forge.yaml env.setup, once per fresh lane worktree: `bash -c <command>` in cwd with the lane env.
272
+ * Resolves {exit, stdout, stderr} (output tails); a non-zero exit, timeout or spawn error never throws.
273
+ */
274
+ runSetup({ cwd, command, env = null, timeoutMs = 15 * 60000 }) {
275
+ return new Promise((res) => {
276
+ execFile("bash", ["-c", command], { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
277
+ const tail = (x) => String(x ?? "").slice(-2000);
278
+ if (!err) return res({ exit: 0, stdout: tail(stdout), stderr: tail(stderr) });
279
+ const exit = Number.isInteger(err.code) ? err.code : 1;
280
+ const why = err.killed ? `timed out after ${Math.round(timeoutMs / 1000)}s` : typeof err.code === "string" ? err.code : "";
281
+ res({ exit: exit === 0 ? 1 : exit, stdout: tail(stdout), stderr: tail([stderr, why].filter(Boolean).join("\n")) });
282
+ });
283
+ });
284
+ },
285
+ /** forge#4159: scripts/structure-delta.sh over base...HEAD in cwd; null on any failure (never fails a run). */
286
+ async structureDelta({ cwd, base }) {
287
+ try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
288
+ },
289
+ /** forge#4195: scripts/hotspots.sh facts for `paths` in cwd (growth since base when given); null on any failure. */
290
+ async hotspots({ cwd, base = null, paths = [] }) {
291
+ const args = [script("hotspots.sh"), ...(base ? ["--base", base] : []), "--", ...paths];
292
+ try { return JSON.parse((await pexec("bash", args, { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
293
+ },
294
+ /** #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure. */
295
+ async redGreen({ cwd, base, command, timeoutSec = 300 }) {
296
+ const args = [script("acceptance-red-green.sh"), "--repo-path", cwd, "--base-ref", base, "--test-cmd", command, "--timeout", String(timeoutSec)];
297
+ try {
298
+ const m = /BASE=(\w+) HEAD=(\w+)/.exec(String((await pexec("bash", args, { cwd, timeout: (timeoutSec * 2 + 60) * 1000, maxBuffer: 4 * 1024 * 1024 })).stdout ?? ""));
299
+ return m ? { base: m[1], head: m[2] } : null;
300
+ } catch { return null; }
301
+ },
302
+ /**
303
+ * The target repo's own docs-move-with-code rule, when it has one (scripts/check-devdocs-freshness.sh, forge#4185),
304
+ * over base...HEAD in cwd. null when the repo has no such script or it cannot run (exit 2+, timeout): never blocks.
305
+ * Otherwise {ok, output} with the script's stdout+stderr (exit 0 = ok, exit 1 = stale docs).
306
+ */
307
+ async docsFreshness({ cwd, base }) {
308
+ const s = join(cwd, "scripts", "check-devdocs-freshness.sh");
309
+ if (!existsSync(s)) return null;
310
+ try {
311
+ const r = await pexec("bash", [s, base], { cwd, timeout: 60000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PR_BODY: "" } });
312
+ return { ok: true, output: String(r.stdout ?? "") };
313
+ } catch (e) {
314
+ if (e?.code !== 1) return null;
315
+ return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}`.slice(0, 6000) };
316
+ }
317
+ },
318
+ /** #4162: processes left running inside a lane worktree (Linux /proc). */
319
+ proc: { inDir: async (dir) => procsInDir(dir), alive: pidAlive, kill: (pid, sig) => { try { process.kill(pid, sig); } catch { /* already gone */ } } },
158
320
  fs: {
159
321
  read: async (p) => readFileSync(p, "utf8"),
160
322
  exists: async (p) => existsSync(p),
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Container sandbox end to end (#4253): a real `docker run` of a stub lane. Skipped unless docker answers and the image
4
+ // (FORGEDOCK_E2E_IMAGE, default node:22.23.0-bookworm-slim) is already local; it never pulls.
5
+ import { describe, it, after } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+ import { containerProvider, providerLanes, runDocker } from "./execution.mjs";
12
+ import { makeLedger } from "./ledger.mjs";
13
+ import { openBatch } from "./batch.mjs";
14
+ import { planGc, applyGc } from "../gc.mjs";
15
+
16
+ const IMAGE = process.env.FORGEDOCK_E2E_IMAGE || "node:22.23.0-bookworm-slim";
17
+ const has = (args) => { try { execFileSync("docker", args, { stdio: "ignore", timeout: 20000 }); return true; } catch { return false; } };
18
+ const skip = !has(["version"]) ? "docker unavailable" : !has(["image", "inspect", IMAGE]) ? `image ${IMAGE} not local (never pulled)` : false;
19
+ const LABELS = String(process.env.FORGE_DOCKER_LABELS ?? "").split(/\s+/).filter(Boolean);
20
+ const exists = (name) => has(["inspect", name]);
21
+ const names = new Set();
22
+ after(() => { for (const n of names) try { execFileSync("docker", ["rm", "-f", n], { stdio: "ignore" }); } catch { /* gone */ } });
23
+
24
+ describe("container provider e2e (#4253)", { skip }, () => {
25
+ const work = tmpDir("exec-e2e-");
26
+ const stubDir = join(work, "stub");
27
+ mkdirSync(stubDir);
28
+ // Stub lane: argv `<issue> --result-file <rf>`; issue 1 merges, 2 fails, 3 hangs (a lane whose dispatcher dies).
29
+ writeFileSync(join(stubDir, "lane.mjs"), `import { writeFileSync } from "node:fs";
30
+ const a = process.argv.slice(2); const n = Number(a[0]); const rf = a[a.indexOf("--result-file") + 1];
31
+ console.log("lane", n, "in", process.cwd(), "GH_REPO", process.env.GH_REPO, "leak", process.env.FD_E2E_LEAK ?? "none");
32
+ if (n === 3) setInterval(() => {}, 1000);
33
+ else writeFileSync(rf, JSON.stringify(n === 1 ? { terminalReason: "merged", pr: 41 } : { terminalReason: "needs-human", detail: "stub failure" }));
34
+ `);
35
+ const batch = openBatch(join(work, "batches"), `e2e-${process.pid}`);
36
+ const gitDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim();
37
+ const provider = containerProvider({ batch, repo: "o/r", image: IMAGE, gitDir, cacheDir: join(work, "cache"), labels: LABELS, mounts: [`${stubDir}:/stub:ro`],
38
+ command: (issue, _args, rf) => ["node", "/stub/lane.mjs", String(issue), "--result-file", rf] });
39
+ const root = join(work, "ledger");
40
+ const ledgerFor = (n, pid = process.pid) => makeLedger({ root, batch: batch.id, lane: `sandbox-${n}`, owner: "e2e", issue: n, pid });
41
+ const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor: (n) => ledgerFor(n), pollMs: 200, env: { FD_E2E_LEAK: "x" }, secrets: [] });
42
+
43
+ it("success: the lane runs in the container, its result reaches the batch, the sandbox is gone", async () => {
44
+ names.add(provider.idFor(1));
45
+ const r = await lanes.runOne(1, null);
46
+ const { sandbox, ...rest } = r; // #4255: every sandbox result carries its usage
47
+ assert.deepEqual(rest, { terminalReason: "merged", pr: 41 });
48
+ assert.equal(sandbox.provider, "container");
49
+ assert.equal(sandbox.id, provider.idFor(1));
50
+ assert.equal(exists(provider.idFor(1)), false);
51
+ assert.equal(ledgerFor(1).read(), null);
52
+ });
53
+
54
+ it("failure: the failure result reaches the batch and the sandbox is torn down", async () => {
55
+ names.add(provider.idFor(2));
56
+ const r = await lanes.runOne(2, null);
57
+ assert.equal(r.terminalReason, "needs-human");
58
+ assert.equal(exists(provider.idFor(2)), false);
59
+ assert.equal(ledgerFor(2).read(), null);
60
+ });
61
+
62
+ it("runner kill: a sandbox whose dispatcher died stays in the ledger and gc --apply removes it", async () => {
63
+ const id = provider.idFor(3);
64
+ names.add(id);
65
+ const dead = ledgerFor(3, 2 ** 22 + 12345); // a pid that is not running
66
+ dead.register("sandbox", id, { provider: "container", handle: { provider: "container", id, issue: 3 } });
67
+ await provider.start({ issue: 3, args: [], env: {}, secrets: [] });
68
+ assert.equal(await provider.status({ id }), "running");
69
+ const home = join(work, "home");
70
+ mkdirSync(join(home, "tmp"), { recursive: true });
71
+ const probes = { cwd: work, host: dead.read().host, ledgerRoot: root, forgeTmpRoot: join(home, "tmp"), worktreeRoot: join(work, "wt"),
72
+ alive: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } }, ppid: () => 0, procsInDir: () => [],
73
+ docker: (args) => runDocker(args), git: async () => "", gh: async () => "[]" };
74
+ const plan = await planGc({ probes, scopes: new Set(["sandboxes"]) });
75
+ assert.deepEqual(plan.items.map((i) => [i.type, i.id, i.action]), [["sandbox", id, "reclaim"]]);
76
+ const res = await applyGc(plan, probes);
77
+ assert.deepEqual(res.failed, []);
78
+ assert.equal(exists(id), false);
79
+ assert.equal(existsSync(dead.path), false);
80
+ });
81
+ });