forgedock 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/bin/lean/effects.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
131
|
-
async
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
});
|