forgedock 1.26.0 → 1.27.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/bin/engine/outcome-codes.mjs +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- 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/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
package/bin/lean/conformance.mjs
CHANGED
|
@@ -59,6 +59,33 @@ export function authorizationOf(contract, f) {
|
|
|
59
59
|
}
|
|
60
60
|
const blank = (s) => !String(s ?? "").trim() || /^none$/i.test(String(s).trim());
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* #4160: an evidence path the reviewer gave as a bare or partial name (`route.ts`, `pdf/route.ts`) resolves to the one
|
|
64
|
+
* changed file it names. Returns the repo path, or null when it is not a unique match.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveEvidencePath(p, changedFiles = []) {
|
|
67
|
+
const s = String(p ?? "").replace(/^\.\//, "");
|
|
68
|
+
if (!s) return null;
|
|
69
|
+
const hits = changedFiles.filter((f) => f === s || f.endsWith(`/${s}`));
|
|
70
|
+
return hits.length === 1 ? hits[0] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* #4160: the rows the engine requires, pre-filled with every id so the reviewer fills statuses instead of composing rows.
|
|
75
|
+
* Scope rows are listed only for changed files the contract does not already authorize (deliverable, test or
|
|
76
|
+
* incidental); issue_coverage starts from the contract's verbatim quotes, and the reviewer adds any the contract missed.
|
|
77
|
+
*/
|
|
78
|
+
export function reviewSkeleton({ contract, changedFiles = [], ignore = [] }) {
|
|
79
|
+
const reqs = contract?.requirements ?? [];
|
|
80
|
+
return {
|
|
81
|
+
issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
|
|
82
|
+
requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
|
|
83
|
+
scope: changedFiles.filter((f) => !ignore.some((pre) => f.startsWith(pre)) && !authorizationOf(contract, f)).map((f) => ({ file: f, authorized_by: "", note: "" })),
|
|
84
|
+
preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "", evidence: "" })),
|
|
85
|
+
regressions: [], verdict: "", summary: "",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
/** Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. */
|
|
63
90
|
export function validateContract(c, issueText) {
|
|
64
91
|
const problems = [];
|
|
@@ -97,7 +124,11 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
97
124
|
const tag = JSON.stringify(a ?? null).slice(0, 100);
|
|
98
125
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
99
126
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
100
|
-
|
|
127
|
+
const have = c.deliverables.find((d) => d.path === a.path);
|
|
128
|
+
// An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
|
|
129
|
+
// package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
|
|
130
|
+
if (have) { have.change = have.change ? `${have.change}; ${a.why}` : a.why; have.amended = true; }
|
|
131
|
+
else c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
|
|
101
132
|
} else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
|
|
102
133
|
if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
|
|
103
134
|
if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
|
|
@@ -118,25 +149,31 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
118
149
|
|
|
119
150
|
/**
|
|
120
151
|
* Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
|
|
121
|
-
* requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}
|
|
152
|
+
* requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}; each failure carries a
|
|
153
|
+
* `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral) so callers can
|
|
154
|
+
* tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
|
|
155
|
+
* engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails.
|
|
122
156
|
*/
|
|
123
|
-
export function precheck({ contract, result, changedFiles, ignore = [] }) {
|
|
157
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false }) {
|
|
124
158
|
const failures = [];
|
|
125
159
|
const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
|
|
126
|
-
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
160
|
+
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
127
161
|
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
128
162
|
for (const v of contract.validate ?? []) {
|
|
129
163
|
const r = runs.find((x) => x?.id === v.id);
|
|
130
|
-
|
|
131
|
-
|
|
164
|
+
// #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
|
|
165
|
+
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
166
|
+
if (ciVerified && leftToCi) continue;
|
|
167
|
+
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
168
|
+
else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
|
|
132
169
|
}
|
|
133
170
|
const st = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
134
171
|
for (const r of contract.requirements ?? []) {
|
|
135
172
|
const s = st.find((x) => x?.id === r.id)?.status;
|
|
136
173
|
if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
|
|
137
174
|
if (r.disposition === "deliver") {
|
|
138
|
-
if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
|
|
139
|
-
else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} reported "${s ?? "no status"}"` });
|
|
175
|
+
if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, kind: "untracked_deferral", detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
|
|
176
|
+
else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
|
|
140
177
|
}
|
|
141
178
|
}
|
|
142
179
|
return { ok: failures.length === 0, failures };
|
|
@@ -175,13 +212,15 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
175
212
|
if (row.status === "implemented") {
|
|
176
213
|
const m = EVIDENCE_RE.exec(String(row.evidence ?? ""));
|
|
177
214
|
if (!m) p.push(`${r.id}: implemented needs path:line evidence`);
|
|
178
|
-
else if (!changedFiles.includes(m[1]) && !pathExists(m[1])) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
|
|
215
|
+
else if (!changedFiles.includes(m[1]) && !pathExists(m[1]) && !resolveEvidencePath(m[1], changedFiles)) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
|
|
179
216
|
if (blank(row.validated_by)) p.push(`${r.id}: implemented needs validated_by (a validate id or test)`);
|
|
180
217
|
}
|
|
181
218
|
}
|
|
182
219
|
const scope = arr("scope");
|
|
183
220
|
if (!scope) p.push("scope rows are missing");
|
|
184
|
-
|
|
221
|
+
// #4160: a file the contract authorizes (deliverable, test, incidental) is already settled by the deterministic precheck;
|
|
222
|
+
// a row is required only for the files it could not authorize. A row the reviewer does give is still judged in decide().
|
|
223
|
+
for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)) && !authorizationOf(contract, x))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
|
|
185
224
|
const pres = arr("preserve") ?? [];
|
|
186
225
|
for (const c of contract.preserve ?? []) {
|
|
187
226
|
const row = pres.find((x) => x?.id === c.id);
|
|
@@ -236,3 +275,30 @@ export function followupItems(reqs) {
|
|
|
236
275
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
237
276
|
return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400) }));
|
|
238
277
|
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* forge#4159: package.json edges between workspace packages (scripts/structure-delta.sh `package_edges_added`) that
|
|
281
|
+
* the contract does not name. Named = a deliverable covering the edge's manifest whose `change` names the target
|
|
282
|
+
* package (an add-deliverable amendment counts: its change is the why). A null or `{}` delta names nothing to check.
|
|
283
|
+
*/
|
|
284
|
+
export function unnamedEdges(contract, delta) {
|
|
285
|
+
const edges = Array.isArray(delta?.package_edges_added) ? delta.package_edges_added : [];
|
|
286
|
+
const dels = contract?.deliverables ?? [];
|
|
287
|
+
return edges.filter((e) => e?.manifest && e?.to && !dels.some((d) => pathCovers(d.path, e.manifest) && String(d.change ?? "").includes(e.to)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* forge#4159: the one report-only line for the existing reviewer prompt, or "" when there is no import-pair growth
|
|
292
|
+
* and no boundary-config loosening. Never blocks; adds no reviewer run.
|
|
293
|
+
*/
|
|
294
|
+
export function structureNote(delta) {
|
|
295
|
+
const grow = (Array.isArray(delta?.import_pairs) ? delta.import_pairs : []).filter((p) => p?.files_added > 0);
|
|
296
|
+
const b = delta?.boundary_config_changed;
|
|
297
|
+
const loosened = Boolean(b?.loosens);
|
|
298
|
+
if (!grow.length && !loosened) return "";
|
|
299
|
+
const pairs = grow.map((p) => `${p.files_added} value import${p.files_added === 1 ? "" : "s"} ${p.from}→${p.to}`).join(", ");
|
|
300
|
+
const files = (Array.isArray(b?.files) ? b.files : []).join(", ");
|
|
301
|
+
const config = loosened ? `the repo's boundary config was loosened in this PR (${files})` : "the repo's boundary config allows it";
|
|
302
|
+
const s = grow.length ? `this PR adds ${pairs} (files); ${config}` : `this PR loosens the repo's boundary config (${files})`;
|
|
303
|
+
return `Structure (report only): ${s}; flag only if the contract or the architecture doc says otherwise.`.replace(/[\r\n]+/g, " ");
|
|
304
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { validateContract, applyAmendments, precheck, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath } from "./conformance.mjs";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, reviewSkeleton, resolveEvidencePath } from "./conformance.mjs";
|
|
6
6
|
|
|
7
7
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
8
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -95,11 +95,40 @@ describe("conformance review: the engine computes the verdict from complete rows
|
|
|
95
95
|
assert.ok(d.failures.every((f) => f.code === "review.incomplete"));
|
|
96
96
|
});
|
|
97
97
|
it("no JSON is unparseable", () => { assert.equal(parseReview("LGTM"), null); assert.equal(decide(null, ctx).state, "review.unparseable"); });
|
|
98
|
-
it("incomplete: missing requirement row, missing scope row for
|
|
98
|
+
it("incomplete: missing requirement row, missing scope row for an unauthorized changed file, missing preserve row", () => {
|
|
99
99
|
assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
|
|
100
|
-
|
|
100
|
+
const extra = { ...ctx, changedFiles: [...files, "src/billing.js"] };
|
|
101
|
+
assert.equal(checkCompleteness(good, extra).problems.includes("changed file src/billing.js has no scope row"), true);
|
|
101
102
|
assert.equal(decide({ ...good, preserve: [] }, ctx).state, "review.incomplete");
|
|
102
103
|
});
|
|
104
|
+
it("#4160: a changed file the contract already authorizes (deliverable, test, incidental) needs no scope row", () => {
|
|
105
|
+
assert.equal(decide({ ...good, scope: [] }, ctx).state, "conforms");
|
|
106
|
+
assert.equal(checkCompleteness({ ...good, scope: [] }, { ...ctx, changedFiles: [...files, "CHANGELOG.md"] }).complete, true);
|
|
107
|
+
});
|
|
108
|
+
it("#4160: a scope row the reviewer gives for an authorized file is still judged (none on a deliverable is creep)", () => {
|
|
109
|
+
const s = good.scope.map((x) => (x.file === "src/scrape.js" ? { ...x, authorized_by: "none", note: "unrelated rewrite" } : x));
|
|
110
|
+
assert.equal(decide({ ...good, scope: s }, ctx).state, "nonconforming");
|
|
111
|
+
});
|
|
112
|
+
it("#4160: evidence given as a bare or partial file name resolves to the one changed file it names", () => {
|
|
113
|
+
assert.equal(resolveEvidencePath("scrape.js", files), "src/scrape.js");
|
|
114
|
+
assert.equal(resolveEvidencePath("./src/scrape.js", files), "src/scrape.js");
|
|
115
|
+
assert.equal(resolveEvidencePath("leak.test.js", files), "test/leak.test.js");
|
|
116
|
+
assert.equal(resolveEvidencePath("x.js", ["a/x.js", "b/x.js"]), null, "ambiguous");
|
|
117
|
+
assert.equal(resolveEvidencePath("nope.js", files), null);
|
|
118
|
+
assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
|
|
119
|
+
});
|
|
120
|
+
it("#4160: reviewSkeleton lists every required row, pre-filled, with scope rows only for unauthorized files", () => {
|
|
121
|
+
const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
|
|
122
|
+
const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
|
|
123
|
+
assert.deepEqual(sk.requirements.map((r) => r.id), k.requirements.map((r) => r.id));
|
|
124
|
+
assert.equal(sk.requirements.find((r) => r.id === "R9").deferral_ok, null);
|
|
125
|
+
assert.ok(!("deferral_ok" in sk.requirements[0]));
|
|
126
|
+
assert.deepEqual(sk.preserve.map((p) => p.id), (k.preserve ?? []).map((p) => p.id));
|
|
127
|
+
assert.deepEqual(sk.scope.map((s) => s.file), ["src/billing.js"]);
|
|
128
|
+
assert.deepEqual(sk.issue_coverage.map((c) => c.quote), k.requirements.map((r) => r.quote));
|
|
129
|
+
assert.ok(sk.issue_coverage.every((c) => inIssue(c.quote, ISSUE)), "seeded quotes pass the verbatim check");
|
|
130
|
+
assert.ok([...sk.requirements, ...sk.preserve, ...sk.issue_coverage].every((r) => r.status === ""), "no status is pre-judged");
|
|
131
|
+
});
|
|
103
132
|
it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
|
|
104
133
|
assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
|
|
105
134
|
assert.equal(decide(row("R1", { evidence: "src/nope.js:3" }), ctx).state, "review.incomplete");
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile, execFileSync } 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,7 @@ 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 } from "./ledger.mjs";
|
|
14
15
|
|
|
15
16
|
const pexec = promisify(execFile);
|
|
16
17
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
@@ -45,18 +46,40 @@ export function wrapLaneLimits(bin, args, limits) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
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 }) => {
|
|
49
|
+
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
|
|
49
50
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
50
51
|
const caps = getClaudeCliCapabilities(cwd);
|
|
51
52
|
const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
|
|
52
53
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
53
54
|
args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
54
55
|
logger: { log() {} },
|
|
55
|
-
idleMs, maxMs, stream: idleMs > 0, control, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
56
|
+
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
56
57
|
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Parent pid from /proc/<pid>/stat (the field after the parenthesised command name); 0 when unreadable. */
|
|
62
|
+
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; } };
|
|
63
|
+
/**
|
|
64
|
+
* #4162: processes whose cwd is `dir` or inside it, from /proc (Linux). Excludes this process and its ancestors.
|
|
65
|
+
* Returns [{pid, cmd}]; [] where /proc is unavailable (the caller then skips stopping them).
|
|
66
|
+
*/
|
|
67
|
+
export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {}) {
|
|
68
|
+
if (!dir || !existsSync(procRoot)) return [];
|
|
69
|
+
try { dir = realpathSync(dir); } catch { /* not on disk: compare as given */ } // /proc cwd links are resolved paths
|
|
70
|
+
const skip = new Set();
|
|
71
|
+
for (let p = self, i = 0; p > 1 && i < 64 && !skip.has(p); i++) { skip.add(p); p = ppidOf(procRoot, p); }
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const e of readdirSync(procRoot)) {
|
|
74
|
+
if (!/^\d+$/.test(e) || skip.has(Number(e))) continue;
|
|
75
|
+
let at; try { at = readlinkSync(join(procRoot, e, "cwd")); } catch { continue; }
|
|
76
|
+
if (at !== dir && !at.startsWith(`${dir}/`)) continue;
|
|
77
|
+
let cmd = ""; try { cmd = readFileSync(join(procRoot, e, "cmdline"), "utf8").split("\0").filter(Boolean).join(" ").slice(0, 200); } catch { /* exited */ }
|
|
78
|
+
out.push({ pid: Number(e), cmd });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
|
|
61
84
|
export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
|
|
62
85
|
const OWNER = ".forge-run-owner";
|
|
@@ -66,6 +89,8 @@ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e)
|
|
|
66
89
|
export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive = pidAlive, host = hostname() } = {}) {
|
|
67
90
|
const inside = (d) => { const r = resolve(d); return r.startsWith(resolve(root) + sep) && !r.slice(resolve(root).length + 1).includes(sep); };
|
|
68
91
|
return {
|
|
92
|
+
/** The dir make(issue, runId) creates, so a ledger can register it first (#4179). */
|
|
93
|
+
path: (runId) => join(root, String(runId)),
|
|
69
94
|
async make(issue, runId) {
|
|
70
95
|
const dir = join(root, String(runId));
|
|
71
96
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -100,6 +125,22 @@ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive
|
|
|
100
125
|
};
|
|
101
126
|
}
|
|
102
127
|
|
|
128
|
+
/**
|
|
129
|
+
* SIGTERM to a ledger-registered pid's process group (#4179). Only when the pid is alive and, where /proc exists, its
|
|
130
|
+
* command line still names `marker`, so a reused pid is never signalled. Returns true when the pid is gone or signalled.
|
|
131
|
+
*/
|
|
132
|
+
export function killLedgerPid(pid, marker, { kill = process.kill.bind(process), procDir = "/proc" } = {}) {
|
|
133
|
+
const n = Number(pid);
|
|
134
|
+
if (!Number.isSafeInteger(n) || n <= 1) return false;
|
|
135
|
+
if (!pidAlive(n)) return true;
|
|
136
|
+
if (marker && existsSync(procDir)) {
|
|
137
|
+
let cmd = "";
|
|
138
|
+
try { cmd = readFileSync(join(procDir, String(n), "cmdline"), "utf8"); } catch { return true; } // exited meanwhile
|
|
139
|
+
if (!cmd.includes(String(marker))) return true; // pid reused by something else: not ours any more
|
|
140
|
+
}
|
|
141
|
+
try { kill(-n, "SIGTERM"); return true; } catch { try { kill(n, "SIGTERM"); return true; } catch (e) { return e?.code === "ESRCH"; } }
|
|
142
|
+
}
|
|
143
|
+
|
|
103
144
|
/** Real host probes for admission (#4105). MemAvailable from /proc/meminfo (fallback os.freemem), statfs for tmp. */
|
|
104
145
|
export function makeHostProbe() {
|
|
105
146
|
return {
|
|
@@ -122,18 +163,34 @@ export function makeHostProbe() {
|
|
|
122
163
|
};
|
|
123
164
|
}
|
|
124
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Required status checks for `lane` (#4158): the union of classic branch protection (contexts + checks) and the
|
|
168
|
+
* repository rulesets that apply to the branch (`rules/branches/{lane}`, type required_status_checks). Each source
|
|
169
|
+
* fails independently to [] (404, no protection, no rulesets, bad JSON), so one never hides the other.
|
|
170
|
+
*/
|
|
171
|
+
export async function requiredChecksFor({ gh, repo, lane }) {
|
|
172
|
+
const classic = async () => {
|
|
173
|
+
const j = JSON.parse(String(await gh(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`])));
|
|
174
|
+
return [...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c?.context)];
|
|
175
|
+
};
|
|
176
|
+
const rulesets = async () => {
|
|
177
|
+
// --jq emits one context per line across all pages, so pagination needs no JSON stitching.
|
|
178
|
+
const out = String(await gh(["api", "--paginate", `repos/${repo}/rules/branches/${lane}`,
|
|
179
|
+
"--jq", '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]?.context']));
|
|
180
|
+
return out.split("\n").map((x) => x.trim());
|
|
181
|
+
};
|
|
182
|
+
const safe = async (f) => { try { const v = await f(); return Array.isArray(v) ? v : []; } catch { return []; } };
|
|
183
|
+
const all = [...(await safe(classic)), ...(await safe(rulesets))];
|
|
184
|
+
return [...new Set(all.filter((x) => typeof x === "string" && x))];
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
export function makeLeanIo({ repo }) {
|
|
126
188
|
const base = makeIo();
|
|
127
189
|
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
128
190
|
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
|
|
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
|
-
},
|
|
191
|
+
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
192
|
+
/** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
|
|
193
|
+
async requiredChecks(lane) { return requiredChecksFor({ gh: run("gh"), repo, lane }); },
|
|
137
194
|
/** [{name, state: success|failure|pending}] for the PR's checks. */
|
|
138
195
|
async checkStates(pr) {
|
|
139
196
|
const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
|
|
@@ -143,6 +200,28 @@ export function makeLeanIo({ repo }) {
|
|
|
143
200
|
return { name: c.name ?? c.context, state };
|
|
144
201
|
});
|
|
145
202
|
},
|
|
203
|
+
/** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
|
|
204
|
+
async dispatchWorkflow({ workflow, ref, inputs = {} }) {
|
|
205
|
+
await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
|
|
206
|
+
},
|
|
207
|
+
/** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name}]. */
|
|
208
|
+
async workflowRuns({ workflow, branch }) {
|
|
209
|
+
return JSON.parse(await run("gh")(["run", "list", "-R", repo, "--workflow", String(workflow), "--branch", String(branch), "--event", "workflow_dispatch",
|
|
210
|
+
"--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name"]) || "[]");
|
|
211
|
+
},
|
|
212
|
+
/** Check runs on a commit (push-triggered CI): [{name, state: success|failure|pending, url}], same mapping as checkStates. */
|
|
213
|
+
async commitChecks(sha) {
|
|
214
|
+
const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {name, status, conclusion, url: .html_url} | @json"]));
|
|
215
|
+
return out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)).map((c) => {
|
|
216
|
+
const v = String(c.status === "completed" ? c.conclusion ?? "" : "").toUpperCase();
|
|
217
|
+
const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(v) ? "failure" : "pending";
|
|
218
|
+
return { name: c.name, state, url: c.url ?? null };
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
/** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
|
|
222
|
+
async failedLogs(runId) {
|
|
223
|
+
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 ""; }
|
|
224
|
+
},
|
|
146
225
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
147
226
|
/**
|
|
148
227
|
* File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
|
|
@@ -155,6 +234,12 @@ export function makeLeanIo({ repo }) {
|
|
|
155
234
|
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
235
|
return map;
|
|
157
236
|
},
|
|
237
|
+
/** forge#4159: scripts/structure-delta.sh over base...HEAD in cwd; null on any failure (never fails a run). */
|
|
238
|
+
async structureDelta({ cwd, base }) {
|
|
239
|
+
try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
|
|
240
|
+
},
|
|
241
|
+
/** #4162: processes left running inside a lane worktree (Linux /proc). */
|
|
242
|
+
proc: { inDir: async (dir) => procsInDir(dir), alive: pidAlive, kill: (pid, sig) => { try { process.kill(pid, sig); } catch { /* already gone */ } } },
|
|
158
243
|
fs: {
|
|
159
244
|
read: async (p) => readFileSync(p, "utf8"),
|
|
160
245
|
exists: async (p) => existsSync(p),
|