forgedock 1.48.1 → 1.49.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-cli.mjs +18 -10
- package/bin/lean/batch.test.mjs +106 -2
- package/bin/lean/conformance.mjs +7 -5
- package/bin/lean/conformance.test.mjs +8 -0
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +57 -20
- package/bin/lean/execution.test.mjs +68 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +20 -3
- package/bin/lean/lean.test.mjs +20 -0
- package/bin/lean/orchestrate.mjs +66 -15
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/schedd.mjs +11 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/lean/lanecheck.mjs
CHANGED
|
@@ -31,20 +31,49 @@ const C = PREFLIGHT_CODES;
|
|
|
31
31
|
/** A remote or hybrid execution is configured (the only case the pool checks apply to). */
|
|
32
32
|
export const usesPool = (exec) => Boolean(exec) && (exec.mode === "remote" || exec.mode === "hybrid") && ["pool", "remote"].includes(exec.provider ?? "pool");
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/** #5131: `a` and `b` name the same `scheme://host:port` (default ports filled, path and trailing slash ignored). */
|
|
35
|
+
const sameEndpoint = (a, b) => {
|
|
36
|
+
const key = (u) => { try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`; } catch { return null; } };
|
|
37
|
+
return key(a) !== null && key(a) === key(b);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The injectable stand-in for execution.mjs `poolCredential` when no `poolToken` is given (tests, older callers): `credential`
|
|
42
|
+
* on `token_env` (default COMPUTESHARE_TOKEN), then the router token, for `remote` only when its endpoint is unset or the router's.
|
|
43
|
+
*/
|
|
44
|
+
export const legacyPoolToken = (credential = () => null) => (exec, router) => {
|
|
45
|
+
const name = exec?.tokenEnv ?? "COMPUTESHARE_TOKEN";
|
|
46
|
+
const v = credential(name);
|
|
47
|
+
if (v) return { token: v, source: name };
|
|
48
|
+
const ok = Boolean(router?.token) && ((exec?.provider ?? "pool") === "pool" || !exec?.endpoint || sameEndpoint(exec.endpoint, router.endpoint));
|
|
49
|
+
return ok ? { token: router.token, source: "forgedock_token_file" } : { token: null, source: null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The sources a resolver tries for `exec` (names only, never values), for a FAIL detail. */
|
|
53
|
+
const sourcesTried = (exec, router) => [
|
|
54
|
+
`env/keychain ${exec.tokenEnv ?? (exec.provider === "remote" ? "(no token_env)" : "COMPUTESHARE_TOKEN")}`,
|
|
55
|
+
...(exec.tokenFile ? [`token_file ${exec.tokenFile}`] : []),
|
|
56
|
+
!router ? "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"
|
|
57
|
+
: (exec.provider ?? "pool") === "remote" && exec.endpoint && !sameEndpoint(exec.endpoint, router.endpoint) ? `the router's forgedock_token_file (not used: endpoint ${exec.endpoint} is not the local router ${router.endpoint})`
|
|
58
|
+
: "the router's forgedock_token_file",
|
|
59
|
+
].join(", ");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The pool token resolves through `poolToken(exec, router) -> {token, source}` (#5131: hostLaneIo wires execution.mjs
|
|
63
|
+
* poolCredential with the dispatch env, so preflight and lane dispatch resolve the same token), else `credential`/`router`
|
|
64
|
+
* (legacyPoolToken), and the router does not answer 401/403 to an authenticated ping. Details name sources, never values.
|
|
65
|
+
*/
|
|
66
|
+
export async function checkPoolAuth({ exec, router = null, credential = () => null, ping = null, poolToken = null }) {
|
|
36
67
|
const code = C.PREFLIGHT_POOL_AUTH;
|
|
37
68
|
if (!usesPool(exec)) return pass(code, "skipped: no remote or hybrid execution", "remote");
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const token = fromEnv || router?.token || null;
|
|
41
|
-
if (!token) return fail(code, `pool token does not resolve: tried env/keychain ${name}, ${router ? "the router's forgedock_token_file" : "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"}`, "remote");
|
|
69
|
+
const { token, source } = (poolToken ?? legacyPoolToken(credential))(exec, router) ?? {};
|
|
70
|
+
if (!token) return fail(code, `pool token does not resolve: tried ${sourcesTried(exec, router)}`, "remote");
|
|
42
71
|
const endpoint = exec.endpoint ?? router?.endpoint ?? null;
|
|
43
|
-
if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${
|
|
72
|
+
if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${source}`, "remote");
|
|
44
73
|
let r = null;
|
|
45
74
|
try { r = await ping(endpoint, token); } catch { r = null; }
|
|
46
|
-
if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${
|
|
47
|
-
return pass(code, `token resolved from ${
|
|
75
|
+
if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${source}`, "remote");
|
|
76
|
+
return pass(code, `token resolved from ${source}${r ? "" : "; router unreachable (not an auth failure)"}`, "remote");
|
|
48
77
|
}
|
|
49
78
|
|
|
50
79
|
/** Every online pool agent runs the CLI's version. `agents` is the router's `GET /agents` list (each spreads versionOf: version, image). */
|
|
@@ -99,9 +128,11 @@ export async function runLaneChecks({ exec = null, env = process.env, cliVersion
|
|
|
99
128
|
let router = null;
|
|
100
129
|
try { router = io.router?.() ?? null; } catch { router = null; }
|
|
101
130
|
const ping = io.ping ?? null;
|
|
102
|
-
|
|
131
|
+
// #5131: one resolver for the auth check and the agents read: io.poolToken (poolCredential), else the injected credential/router.
|
|
132
|
+
const poolToken = io.poolToken ?? legacyPoolToken(io.credential ?? (() => null));
|
|
133
|
+
const agentsOf = async () => { try { return (await io.agents?.(exec?.endpoint ?? router?.endpoint, poolToken(exec, router)?.token ?? null)) ?? null; } catch { return null; } };
|
|
103
134
|
return Promise.all([
|
|
104
|
-
safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router,
|
|
135
|
+
safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router, poolToken, ping })),
|
|
105
136
|
safe(C.PREFLIGHT_POOL_VERSION, "remote", async () => checkPoolVersion({ exec, agents: usesPool(exec) ? await agentsOf() : null, cliVersion: cliV })),
|
|
106
137
|
safe(C.PREFLIGHT_PATH_LOOP, "all", () => checkPathLoop({ env: { ...env, PATH: env.PATH ?? "" }, ...(io.loops ? { loops: io.loops } : {}) })),
|
|
107
138
|
safe(C.PREFLIGHT_GH_FIELDS, "all", () => (io.ghExec ? checkGhFields({ exec: io.ghExec }) : pass(C.PREFLIGHT_GH_FIELDS, "skipped: no gh", "all"))),
|
|
@@ -113,7 +144,7 @@ export const formatLaneChecks = (checks) => checks.map((c) => `${c.ok ? "PASS" :
|
|
|
113
144
|
|
|
114
145
|
/** Real-host io for the daemon, the batch and `doctor --lanes`. gh output is cached per process (it cannot change under a running engine). */
|
|
115
146
|
export async function hostLaneIo({ env = process.env } = {}) {
|
|
116
|
-
const { poolRouterConfig } = await import("./execution.mjs");
|
|
147
|
+
const { poolCredential, poolRouterConfig } = await import("./execution.mjs");
|
|
117
148
|
const { resolveCredential } = await import("./credentials.mjs");
|
|
118
149
|
const { execFile } = await import("node:child_process");
|
|
119
150
|
const call = (url, token, timeoutMs = 5000) => fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -122,6 +153,7 @@ export async function hostLaneIo({ env = process.env } = {}) {
|
|
|
122
153
|
return {
|
|
123
154
|
router: () => poolRouterConfig({ env }),
|
|
124
155
|
credential: (n) => resolveCredential(n, { env }),
|
|
156
|
+
poolToken: (exec, router) => poolCredential(exec, { env, router }), // #5131: the dispatch resolver on the dispatch env
|
|
125
157
|
ping: async (endpoint, token) => ({ status: (await call(`${endpoint}/status`, token)).status }),
|
|
126
158
|
agents: async (endpoint, token) => { const r = await call(`${endpoint}/agents`, token); return r.ok ? (await r.json())?.agents ?? null : null; },
|
|
127
159
|
ghExec: (args) => { const k = args.join(" "); if (!memo.has(k)) memo.set(k, run(args)); return memo.get(k); },
|
|
@@ -31,6 +31,29 @@ describe("pool_auth", () => {
|
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
describe("pool_auth shared resolver (#5131)", () => {
|
|
35
|
+
const remote = { mode: "remote", provider: "remote", endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" };
|
|
36
|
+
const router = { endpoint: "http://127.0.0.1:7710", token: "router-tok" };
|
|
37
|
+
it("5131: FAILs remote on a non-matching endpoint although a router token exists, naming sources never the value", async () => {
|
|
38
|
+
const a = await checkPoolAuth({ exec: remote, router, credential: () => null });
|
|
39
|
+
assert.equal(a.ok, false); assert.equal(a.code, "preflight.pool_auth"); assert.equal(a.scope, "remote");
|
|
40
|
+
assert.match(a.detail, /COMPUTESHARE_TOKEN/); assert.match(a.detail, /not the local router/); assert.doesNotMatch(a.detail, /router-tok/);
|
|
41
|
+
const viaIo = await checkPoolAuth({ exec: remote, router, poolToken: () => ({ token: null, source: null }) });
|
|
42
|
+
assert.equal(viaIo.ok, false);
|
|
43
|
+
assert.equal((await checkPoolAuth({ exec: { ...remote, endpoint: "http://127.0.0.1:7710/" }, router, credential: () => null })).ok, true, "matching endpoint: router token applies");
|
|
44
|
+
});
|
|
45
|
+
it("5131: runLaneChecks uses io.poolToken for the auth check and the agents read, and FAILs a rejected token", async () => {
|
|
46
|
+
const calls = [];
|
|
47
|
+
const io = { router: () => router, poolToken: (exec, r) => { calls.push([exec.provider, r?.endpoint]); return { token: "tok", source: "token_file" }; },
|
|
48
|
+
ping: async (_e, t) => ({ status: t === "tok" ? 401 : 200 }), agents: async (_e, t) => { calls.push(["agents", t]); return []; } };
|
|
49
|
+
const checks = await runLaneChecks({ exec: remote, env: { PATH: "" }, io: { ...io, loops: () => [] } });
|
|
50
|
+
const auth = checks.find((c) => c.code === "preflight.pool_auth");
|
|
51
|
+
assert.equal(auth.ok, false); assert.match(auth.detail, /HTTP 401/); assert.match(auth.detail, /token_file/); assert.doesNotMatch(auth.detail, /\btok\b/);
|
|
52
|
+
assert.ok(calls.some((c) => c[0] === "agents" && c[1] === "tok"));
|
|
53
|
+
assert.ok(calls.some((c) => c[0] === "remote" && c[1] === router.endpoint));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
34
57
|
describe("pool_version", () => {
|
|
35
58
|
it("reports the agent, image version, CLI version and upgrade command", () => {
|
|
36
59
|
const c = checkPoolVersion({ exec: pool, cliVersion: "1.39.0", agents: [{ name: "a1", ok: true, version: "1.37.0" }, { name: "a2", ok: true, version: "1.39.0" }] });
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, self
|
|
|
32
32
|
import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE, GH_AUTH_RETRY_SECONDS, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
33
33
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, parseAmendmentsComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
34
34
|
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
35
|
-
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
35
|
+
import { computeTrigger, riskPathHits, TRIGGER_MODES, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
|
|
36
36
|
import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
|
|
37
37
|
|
|
38
38
|
const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
|
|
@@ -205,7 +205,8 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
205
205
|
* @param {"off"|"report"} [o.conformanceMutation] forge.yaml engine.conformance_mutation (#4913): rerun each evidence test with the change reverted; a survivor is conformance.mutation_survived (shadow unless conformance_kinds enforces it)
|
|
206
206
|
* @param {Record<string,"enforce"|"shadow">} [o.conformanceKinds] forge.yaml engine.conformance_kinds: a failure code's own mode (enforce blocks, shadow is recorded only); others follow `conformance`
|
|
207
207
|
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
208
|
-
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs
|
|
208
|
+
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs,
|
|
209
|
+
* enforceDomains: review.enforce_domains (#5099), a touched listed domain makes the lane enforce}
|
|
209
210
|
* @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
|
|
210
211
|
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
211
212
|
* @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
|
|
@@ -239,6 +240,7 @@ export async function runCore(o) {
|
|
|
239
240
|
const remoteVerify = vcfg.mode !== "local";
|
|
240
241
|
const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
|
|
241
242
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
243
|
+
const enforceDomains = parseEnforceDomains(o.reviewTrigger?.enforceDomains ?? []); // #5099
|
|
242
244
|
const verifierCfg = parseVerifierConfig(o.verifier ?? {});
|
|
243
245
|
const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
|
|
244
246
|
// #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
|
|
@@ -336,6 +338,7 @@ export async function runCore(o) {
|
|
|
336
338
|
let guardAddedMs = 0; // #4705 (R12): wall time the guard gate added, summed over the lane, carried on RUN_TERMINAL
|
|
337
339
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
338
340
|
const kinds = normalizeKindModes(conformanceKinds); // engine.conformance_kinds: a failure code's own mode
|
|
341
|
+
let domains = [], domainsLogged = null; // #5099: review domains the diff touches (review-roster.sh path triggers)
|
|
339
342
|
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
340
343
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
341
344
|
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (the same structure/hotspot gates as the full lane, #4502); summed session time
|
|
@@ -2211,7 +2214,7 @@ export async function runCore(o) {
|
|
|
2211
2214
|
await renew();
|
|
2212
2215
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
2213
2216
|
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
2214
|
-
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2217
|
+
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2215
2218
|
await renew();
|
|
2216
2219
|
await attempt("review", ++reviewN, rr, null, "review");
|
|
2217
2220
|
const parsed = parseReview(rr?.text);
|
|
@@ -2342,6 +2345,20 @@ export async function runCore(o) {
|
|
|
2342
2345
|
ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
|
|
2343
2346
|
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
2344
2347
|
}
|
|
2348
|
+
// #5099: the domains the diff touches get domain checks in the review; a review.enforce_domains hit enforces (sticky)
|
|
2349
|
+
const pd = pathDomains(files);
|
|
2350
|
+
const dErr = "Review domains unavailable: scripts/review-roster.sh failed; reviewed without domain checks";
|
|
2351
|
+
if (pd === null && !T.anomalies.includes(dErr)) T.anomalies.push(dErr);
|
|
2352
|
+
domains = pd ?? [];
|
|
2353
|
+
const dHits = shadow ? domains.filter((d) => enforceDomains.includes(d)) : [];
|
|
2354
|
+
if (dHits.length) {
|
|
2355
|
+
shadow = false; riskEnforced = dHits.map((d) => `domain:${d}`);
|
|
2356
|
+
T.decisions.push(`Conformance enforced (review.enforce_domains): ${dHits.join(", ")}`);
|
|
2357
|
+
}
|
|
2358
|
+
if (domainsLogged !== domains.join(",")) {
|
|
2359
|
+
domainsLogged = domains.join(",");
|
|
2360
|
+
ev({ event: "REVIEW_DOMAINS", domains, enforced: dHits.length ? dHits : undefined, ...(pd === null ? { error: "roster.failed" } : {}) });
|
|
2361
|
+
}
|
|
2345
2362
|
if (!amendFixed && sessionId && amendmentKindProblems(result?.amendments).length) { // #5089: one in-lane correction, outside the conformance resume budget
|
|
2346
2363
|
amendFixed = true;
|
|
2347
2364
|
const bad = amendmentKindProblems(result.amendments);
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -1659,6 +1659,24 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1659
1659
|
assert.ok(traj(w)[0].includes("| Close | issue closed (partial) |"));
|
|
1660
1660
|
});
|
|
1661
1661
|
|
|
1662
|
+
it("#5099 a web/ diff gets frontend domain checks in the reviewer prompt; review.enforce_domains makes the lane enforce", async () => {
|
|
1663
|
+
const k = { ...CONTRACT, preserve: [] }, changed = ["web/app/page.tsx", "test/foo.test.js"];
|
|
1664
|
+
const seenPrompts = (w) => { const out = [], r0 = w.io.runner; w.io.runner = async (c) => { if (c.role === "reviewer") out.push(c.prompt); return r0(c); }; return out; };
|
|
1665
|
+
const dir = tmpDir("dom-"), s = world({ contract: k, changed, reviews: [omission()] }), ps = seenPrompts(s);
|
|
1666
|
+
const rs = await runShadow(s, { dir });
|
|
1667
|
+
assert.equal(rs.terminalReason, "merged", "without enforce_domains the lane stays shadow");
|
|
1668
|
+
assert.match(ps[0], /<domain_checks>[\s\S]*regressions row[\s\S]*- frontend: [\s\S]*not-found\/error boundary/);
|
|
1669
|
+
assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "REVIEW_DOMAINS").map((e) => e.domains), [["frontend"]]);
|
|
1670
|
+
const plain = world({ contract: k, reviews: [ok({}, k)] }), pp = seenPrompts(plain);
|
|
1671
|
+
await runShadow(plain);
|
|
1672
|
+
assert.ok(!pp[0].includes("<domain_checks>"), "no domain touched: no domain block");
|
|
1673
|
+
const e = world({ contract: k, changed, reviews: [omission(), omission()] });
|
|
1674
|
+
const re = await runShadow(e, { reviewTrigger: { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] } });
|
|
1675
|
+
assert.equal(re.terminalReason, "needs-human"); assert.equal(e.merges.length, 0);
|
|
1676
|
+
assert.deepEqual(re.conformance, { mode: "enforce", would: null, risk: true });
|
|
1677
|
+
assert.ok(traj(e)[0].includes("Conformance enforced (review.enforce_domains): frontend"));
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1662
1680
|
it("#4398 an investigation whose answer the review marks deferred is tracked before the PR and closes partial, even though shadow merges it", async () => {
|
|
1663
1681
|
const k = { ...CONTRACT, preserve: [] };
|
|
1664
1682
|
const w = world({ title: "investigate: foo", contract: k, reviews: [conf({ requirements: [{ id: "R1", status: "deferred", evidence: "", validated_by: "", note: "question open" }] }, k)] });
|
|
@@ -5930,6 +5948,8 @@ describe("review trigger (#4273): shadow logs and always reviews, enforce skips
|
|
|
5930
5948
|
assert.deepEqual(at('review:\n trigger: enforce\n risk_paths: "db/**, *.sql"\n'), { mode: "enforce", riskPaths: ["db/**", "*.sql"] });
|
|
5931
5949
|
assert.deepEqual(at("review:\n tech_stack: x\n risk_paths:\n - \"infra/**\"\n - lib/acct/*.ts # c\n trigger: bogus\n"), { mode: "shadow", riskPaths: ["infra/**", "lib/acct/*.ts"] });
|
|
5932
5950
|
assert.equal(at("engine:\n path: core\n"), undefined);
|
|
5951
|
+
assert.deepEqual(at('review:\n enforce_domains: "Frontend, bogus"\n'), { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] }, "#5099");
|
|
5952
|
+
assert.deepEqual(at("review:\n enforce_domains:\n - auth\n risk_paths:\n - db/**\n"), { mode: "shadow", riskPaths: ["db/**"], enforceDomains: ["auth"] }, "#5099 block lists");
|
|
5933
5953
|
});
|
|
5934
5954
|
});
|
|
5935
5955
|
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
|
|
5
5
|
* among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
|
|
6
6
|
* A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
|
|
7
|
-
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124)
|
|
7
|
+
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124); an issue read later (`--add`,
|
|
8
|
+
* `--requeue`, a resume, a body re-read) resolves a decomposed dependency to its sub-issues too (#5112). A dependency that is parked
|
|
8
9
|
* (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
|
|
9
10
|
* the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
|
|
10
11
|
* A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
22
23
|
* limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
|
|
23
24
|
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
|
|
25
|
+
* #5112: with `remote.offlineFallback` (`execution.fallback_local`), an issue deferred `remote.capacityFallbackAfter` times (default
|
|
26
|
+
* `CAPACITY_FALLBACK_AFTER`) for `sandbox.capacity` runs on the host when `remote.headroom()` (host-busy guard) allows it.
|
|
24
27
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
28
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
29
|
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset.
|
|
@@ -136,6 +139,8 @@ export const HOST_BUSY_RETRY_S = 60;
|
|
|
136
139
|
// #4788: an unreachable pool is a wait too (the lane already retried for unreachable_window_min); a stall is reported by poolStall.
|
|
137
140
|
const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity", "sandbox.unreachable", "lane.unresolved"]);
|
|
138
141
|
export const POOL_OFFLINE_ALERT_MIN = 10;
|
|
142
|
+
// #5112: `sandbox.capacity` deferrals of one issue after which `execution.fallback_local` runs it on the host (headroom permitting).
|
|
143
|
+
export const CAPACITY_FALLBACK_AFTER = 3;
|
|
139
144
|
export const RATE_LIMIT_FALLBACK_S = 15 * 60;
|
|
140
145
|
export const DEFER_BACKOFF_S = 120;
|
|
141
146
|
export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
@@ -274,6 +279,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
274
279
|
const notBefore = toMap(saved?.notBefore);
|
|
275
280
|
const requeues = toMap(saved?.requeues);
|
|
276
281
|
const deferrals = toMap(saved?.deferrals);
|
|
282
|
+
const capDefers = toMap(saved?.capDeferrals); // #5112: issue -> `sandbox.capacity` deferrals (uncapped, so not in `deferrals`)
|
|
283
|
+
const capFellBack = new Set(); // #5112: issues already reported as a capacity fallback (one decision each)
|
|
277
284
|
const engineErrors = toMap(saved?.engineErrors);
|
|
278
285
|
const checked = toMap(saved?.prefilter); // issue -> pre-filter result
|
|
279
286
|
const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
|
|
@@ -358,7 +365,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
358
365
|
const save = () => {
|
|
359
366
|
if (!store || halted) return;
|
|
360
367
|
store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
361
|
-
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}),
|
|
368
|
+
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}), ...(capDefers.size ? { capDeferrals: toObj(capDefers) } : {}),
|
|
362
369
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
363
370
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
364
371
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
|
|
@@ -391,6 +398,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
391
398
|
if (typeof trusted === "function") { let ok = false; try { ok = (await trusted(n)) === true; } catch { ok = false; } if (!ok) return []; }
|
|
392
399
|
return parseDeps(body, n);
|
|
393
400
|
};
|
|
401
|
+
// #5112: a dependency that ended `decomposed` (sub-issues joined, mode not off) is its sub-issues, to a fixpoint; used on every edge write.
|
|
402
|
+
const throughDecomposed = (ds) => {
|
|
403
|
+
if (!joinsDecomposed(autoChildren?.mode ?? "decomposed")) return [...new Set(ds)];
|
|
404
|
+
const out = [], seen = new Set(), stack = [...ds].reverse();
|
|
405
|
+
while (stack.length) {
|
|
406
|
+
const d = stack.pop();
|
|
407
|
+
if (seen.has(d)) continue;
|
|
408
|
+
seen.add(d);
|
|
409
|
+
const r = results.get(d), subs = r?.outcome === "decomposed" && Array.isArray(r.subissues) ? r.subissues.filter((x) => Number.isSafeInteger(x) && x > 0) : [];
|
|
410
|
+
if (subs.length) stack.push(...[...subs].reverse()); else out.push(d);
|
|
411
|
+
}
|
|
412
|
+
return [...new Set(out)];
|
|
413
|
+
};
|
|
414
|
+
// #5112: the issue's declared edges re-read from its body (null when unreadable: the caller keeps what it has).
|
|
415
|
+
const reread = async (n) => {
|
|
416
|
+
let body;
|
|
417
|
+
try { body = await readBody(n); } catch { return null; }
|
|
418
|
+
if (drift?.holds) bodies.set(n, body);
|
|
419
|
+
return declared(n, body);
|
|
420
|
+
};
|
|
394
421
|
const readDeps = async (n) => {
|
|
395
422
|
let body = "";
|
|
396
423
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
@@ -401,7 +428,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
401
428
|
await readPriority(n);
|
|
402
429
|
if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; } lanes.set(n, l); }
|
|
403
430
|
// deps marks the issue read (a resume skips it): set it last, so a concurrent setup save never persists a half-read issue (#4963)
|
|
404
|
-
deps.set(n, d);
|
|
431
|
+
deps.set(n, throughDecomposed(d));
|
|
405
432
|
};
|
|
406
433
|
const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
|
|
407
434
|
// A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
|
|
@@ -516,6 +543,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
516
543
|
}
|
|
517
544
|
return "busy";
|
|
518
545
|
};
|
|
546
|
+
// #5112: opt-in (`execution.fallback_local`): an issue the pool refused `capacityFallbackAfter` times (default CAPACITY_FALLBACK_AFTER)
|
|
547
|
+
// for `sandbox.capacity` runs on the host when the host-busy guard (`remote.headroom()`, absent = allowed) passes; not ok or a throw waits.
|
|
548
|
+
const capacityFallback = (n) => {
|
|
549
|
+
if (!remote?.offlineFallback) return false;
|
|
550
|
+
const after = Number.isInteger(remote.capacityFallbackAfter) && remote.capacityFallbackAfter > 0 ? remote.capacityFallbackAfter : CAPACITY_FALLBACK_AFTER;
|
|
551
|
+
if ((capDefers.get(n) ?? 0) < after) return false;
|
|
552
|
+
if (typeof remote.headroom !== "function") return true;
|
|
553
|
+
try { return remote.headroom()?.ok !== false; } catch { return false; }
|
|
554
|
+
};
|
|
519
555
|
// A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
|
|
520
556
|
// true: start it; false: no capacity now; "busy" (#4481): another batch runs `n`, try the next issue.
|
|
521
557
|
const grant = (n, wanted) => {
|
|
@@ -555,6 +591,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
555
591
|
// #4547: opt-in (`execution.fallback_local`): every pool agent offline or asleep (sampled online 0) runs the lane on the host.
|
|
556
592
|
if (remote.offlineFallback && cap && cap.online === 0) {
|
|
557
593
|
p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
|
|
594
|
+
} else if (capacityFallback(n)) {
|
|
595
|
+
p = { where: "local", reason: `fallback to local: pool at capacity after ${capDefers.get(n)} deferrals (${p.reason})` };
|
|
596
|
+
if (!capFellBack.has(n)) {
|
|
597
|
+
capFellBack.add(n);
|
|
598
|
+
decided({ issue: n, what: `run #${n} on the host: the pool refused it ${capDefers.get(n)} times for capacity (execution.fallback_local)`, why: "sustained sandbox.capacity and the host has headroom",
|
|
599
|
+
alternative: "keep deferring until a pool slot frees" });
|
|
600
|
+
}
|
|
558
601
|
} else if (cap && Number.isInteger(cap.free)) {
|
|
559
602
|
if (cap.at !== capAt) { capAt = cap.at; capAdmits = 0; }
|
|
560
603
|
if (capWait.blocked(n, cap)) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
|
|
@@ -664,7 +707,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
664
707
|
if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
|
|
665
708
|
// A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
|
|
666
709
|
requeues.set(n, (requeues.get(n) ?? 0) + 1);
|
|
667
|
-
deps.set(n,
|
|
710
|
+
deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...depList]));
|
|
668
711
|
await addIssues(depList);
|
|
669
712
|
// Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
|
|
670
713
|
const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
|
|
@@ -686,6 +729,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
686
729
|
if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
|
|
687
730
|
if (r?.code === "sandbox.capacity") {
|
|
688
731
|
capWait.refused(n);
|
|
732
|
+
capDefers.set(n, (capDefers.get(n) ?? 0) + 1); // #5112: counted for the fallback_local capacity fallback
|
|
689
733
|
try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
|
|
690
734
|
}
|
|
691
735
|
if (!pol.capped) {
|
|
@@ -929,10 +973,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
929
973
|
if (!fresh.length) return kids;
|
|
930
974
|
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
931
975
|
for (const n of pending) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (drift?.holds) bodies.set(n, body);
|
|
935
|
-
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
976
|
+
const d = await reread(n);
|
|
977
|
+
if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
|
|
936
978
|
}
|
|
937
979
|
await addIssues(fresh);
|
|
938
980
|
save();
|
|
@@ -964,10 +1006,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
964
1006
|
.filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
|
|
965
1007
|
if (!fresh.length) return 0;
|
|
966
1008
|
for (const n of pending) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (drift?.holds) bodies.set(n, body);
|
|
970
|
-
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
1009
|
+
const d = await reread(n);
|
|
1010
|
+
if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
|
|
971
1011
|
}
|
|
972
1012
|
await addIssues(fresh);
|
|
973
1013
|
save();
|
|
@@ -980,7 +1020,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
980
1020
|
return fresh.length;
|
|
981
1021
|
};
|
|
982
1022
|
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
983
|
-
// return to pending, the prior rows kept in history;
|
|
1023
|
+
// return to pending, the prior rows kept in history; their `Depends on` edges are re-read from the body and replaced (#5112).
|
|
984
1024
|
const drainRequeues = async () => {
|
|
985
1025
|
if (!getRequeued) return 0;
|
|
986
1026
|
let got = [];
|
|
@@ -1000,14 +1040,25 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1000
1040
|
}
|
|
1001
1041
|
}
|
|
1002
1042
|
for (const m of moved) {
|
|
1003
|
-
for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
|
|
1043
|
+
for (const x of [requeues, deferrals, capDefers, engineErrors, recoveries, recovering]) x.delete(m);
|
|
1004
1044
|
relabelled.delete(m);
|
|
1005
1045
|
if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
|
|
1006
1046
|
if (!pending.includes(m)) requeue(m, 0);
|
|
1007
1047
|
}
|
|
1048
|
+
// #5112: the requeued issues' `Depends on` edges are re-read and replaced, so a body edit drops or repoints an edge
|
|
1049
|
+
// (a decomposed dependency resolves to its sub-issues); an unreadable body keeps the edges it had.
|
|
1050
|
+
const edited = [];
|
|
1051
|
+
for (const m of moved) {
|
|
1052
|
+
const d = await reread(m);
|
|
1053
|
+
if (!d) continue;
|
|
1054
|
+
const before = deps.get(m) ?? [], after = throughDecomposed(d);
|
|
1055
|
+
deps.set(m, after);
|
|
1056
|
+
if (before.length !== after.length || before.some((x) => !after.includes(x))) edited.push(`#${m} -> ${after.length ? after.map((x) => `#${x}`).join(", ") : "none"}`);
|
|
1057
|
+
}
|
|
1058
|
+
noteCrossLane();
|
|
1008
1059
|
save();
|
|
1009
1060
|
const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
|
|
1010
|
-
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
|
|
1061
|
+
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}${edited.length ? `; edges re-read: ${edited.join("; ")}` : ""}`,
|
|
1011
1062
|
why: e.reason || "operator decision", alternative: "start a second batch" });
|
|
1012
1063
|
update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
|
|
1013
1064
|
for (const m of moved) if (m !== e.issue) update({ issue: m, outcome: "requeued", detail: `dependency #${e.issue} requeued`, trigger: e.issue }); // #4687
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -463,12 +463,25 @@ export function decisionNote(comments = []) {
|
|
|
463
463
|
return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
|
|
466
|
+
/** #5099: one check line per review domain (domains from review-trigger.mjs pathDomains); mirrors the /review-pr personas. */
|
|
467
|
+
export const DOMAIN_CHECKS = Object.freeze({
|
|
468
|
+
frontend: "Dynamic API in a static/ISR tree: a headers(), cookies(), connection() or searchParams read in a layout, a not-found/error boundary or a module they import, when the route tree is static or ISR (revalidate, generateStaticParams, dynamic = 'force-static'), opts the route out of static rendering or throws at render and turns pages into HTTP 500.",
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
/** #5099: the <domain_checks> block for the domains the diff touches; "" when none. */
|
|
472
|
+
export function domainChecksBlock(domains = []) {
|
|
473
|
+
const ds = [...new Set((domains ?? []).map(String).filter(Boolean))].sort();
|
|
474
|
+
if (!ds.length) return "";
|
|
475
|
+
const lines = ds.map((d) => `- ${d}: check the diff for breakage in this domain.${DOMAIN_CHECKS[d] ? ` ${DOMAIN_CHECKS[d]}` : ""}`);
|
|
476
|
+
return `\n<domain_checks>\nThis diff touches these review domains. Report every finding below as a regressions row with path:line (it blocks the merge).\n${lines.join("\n")}\n</domain_checks>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null, domains = [] }) {
|
|
467
480
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
468
481
|
const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
|
|
469
482
|
notes.push(...decisions);
|
|
470
483
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
471
|
-
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
484
|
+
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}${domainChecksBlock(domains)}`;
|
|
472
485
|
}
|
|
473
486
|
|
|
474
487
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* log REVIEW_TRIGGER, always review), enforce (skip the reviewer when not triggered), off (today's behaviour).
|
|
8
8
|
* Run directly, it reports per repo how many would-skip lanes the reviewer flagged as nonconforming.
|
|
9
9
|
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
10
11
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
12
13
|
import { join } from "node:path";
|
|
@@ -43,6 +44,30 @@ export function parseRiskPaths(v) {
|
|
|
43
44
|
return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** #5099: the review domains a lane can touch, from scripts/review-roster.sh path triggers. */
|
|
48
|
+
export const DOMAINS = Object.freeze(["api", "auth", "database", "frontend", "infra"]);
|
|
49
|
+
const DOMAIN_OF_TRIGGER = Object.freeze({ "frontend-path": "frontend", "auth-path": "auth", "infra-path": "infra", "route-handler": "infra", "migration-path": "database", "sdk-path": "api" });
|
|
50
|
+
const ROSTER = fileURLToPath(new URL("../../scripts/review-roster.sh", import.meta.url));
|
|
51
|
+
|
|
52
|
+
/** forge.yaml review.enforce_domains (#5099): a comma-separated string or a list -> known domains, lowercased, unique. */
|
|
53
|
+
export function parseEnforceDomains(v) {
|
|
54
|
+
return [...new Set(parseRiskPaths(v).map((x) => x.toLowerCase()).filter((x) => DOMAINS.includes(x)))].sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* #5099: the domains the changed files touch, by the path triggers of the bundled scripts/review-roster.sh (the one
|
|
59
|
+
* source of the domain path rules). Sorted, unique; [] without files; null when the script fails (the caller logs it).
|
|
60
|
+
*/
|
|
61
|
+
export function pathDomains(files = [], { script = ROSTER } = {}) {
|
|
62
|
+
const list = [...new Set((files ?? []).map(String).filter(Boolean))];
|
|
63
|
+
if (!list.length) return [];
|
|
64
|
+
let out;
|
|
65
|
+
try { out = execFileSync("bash", [script, "--files-file", "-", "--changed-lines", "1"], { input: `${list.join("\n")}\n`, encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "ignore"] }); } catch { return null; }
|
|
66
|
+
const m = /^REASON=.*?triggers:([^\n]*)$/m.exec(String(out));
|
|
67
|
+
if (!/^SELECTED_AGENTS=/m.test(String(out))) return null;
|
|
68
|
+
return [...new Set((m ? m[1].trim().split(/\s+/) : []).map((t) => DOMAIN_OF_TRIGGER[t]).filter(Boolean))].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
/**
|
|
47
72
|
* #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
|
|
48
73
|
* count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
|
|
@@ -7,7 +7,8 @@ import { join } from "node:path";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
10
|
+
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
|
|
11
|
+
import { reviewerPrompt, domainChecksBlock } from "./prompts.mjs";
|
|
11
12
|
|
|
12
13
|
const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
|
|
13
14
|
const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
|
|
@@ -118,3 +119,29 @@ describe("triggerReport (#4273)", () => {
|
|
|
118
119
|
assert.deepEqual(JSON.parse(cli.stdout)["o/a"].would_skip_flagged, 1);
|
|
119
120
|
});
|
|
120
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("pathDomains / domain checks (#5099)", () => {
|
|
124
|
+
it("maps review-roster.sh path triggers to domains: web/ is frontend", () => {
|
|
125
|
+
assert.deepEqual(pathDomains(["web/src/app/(site)/not-found.tsx"]), ["frontend"]);
|
|
126
|
+
assert.deepEqual(pathDomains(["src/auth/x.js", "app/api/route.ts", "db/migrations/0001.sql", "sdk/client.py"]), ["api", "auth", "database", "frontend", "infra"]);
|
|
127
|
+
assert.deepEqual(pathDomains(["src/foo.js"]), []);
|
|
128
|
+
assert.deepEqual(pathDomains([]), []);
|
|
129
|
+
});
|
|
130
|
+
it("a failing roster script is null (never throws)", () => {
|
|
131
|
+
assert.equal(pathDomains(["web/a.tsx"], { script: "/nonexistent/review-roster.sh" }), null);
|
|
132
|
+
});
|
|
133
|
+
it("parseEnforceDomains keeps known domains only", () => {
|
|
134
|
+
assert.deepEqual(parseEnforceDomains("Frontend, bogus , auth"), ["auth", "frontend"]);
|
|
135
|
+
assert.deepEqual(parseEnforceDomains(["infra", "infra"]), ["infra"]);
|
|
136
|
+
assert.deepEqual(parseEnforceDomains(undefined), []);
|
|
137
|
+
});
|
|
138
|
+
it("the reviewer prompt gains a frontend check only when frontend is touched, and is unchanged without domains", () => {
|
|
139
|
+
const args = { issue: { number: 7, title: "t", body: "b" }, contract: {}, diff: "" };
|
|
140
|
+
assert.equal(reviewerPrompt(args), reviewerPrompt({ ...args, domains: [] }));
|
|
141
|
+
assert.equal(domainChecksBlock([]), "");
|
|
142
|
+
const p = reviewerPrompt({ ...args, domains: ["frontend"] });
|
|
143
|
+
assert.ok(p.endsWith("</domain_checks>"));
|
|
144
|
+
assert.match(p, /- frontend: .*headers\(\), cookies\(\).*ISR/);
|
|
145
|
+
assert.match(p, /regressions row with path:line/);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -87,10 +87,11 @@ export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots
|
|
|
87
87
|
/**
|
|
88
88
|
* The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
|
|
89
89
|
* the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
|
|
90
|
+
* An empty value is never pinned, passthrough keys included (#5131: an empty pin would mask the daemon's secret).
|
|
90
91
|
*/
|
|
91
92
|
export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
|
|
92
93
|
const extra = new Set(passthrough);
|
|
93
|
-
const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
|
|
94
|
+
const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && v !== "" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
|
|
94
95
|
if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
|
|
95
96
|
return out;
|
|
96
97
|
}
|
|
@@ -105,10 +106,17 @@ export function redactEnv(env) {
|
|
|
105
106
|
return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* `base` without its allowlisted non-secret keys, plus `kept`: an account selector the client did not set never leaks in from
|
|
111
|
+
* the daemon, and a pinned key wins. A secret-named key is only pinned when named (`env_passthrough`, `token_env`): a non-empty
|
|
112
|
+
* pin wins, as before #5131 (the operator chose that account); an empty secret pin never overrides the daemon's value (#5131).
|
|
113
|
+
*/
|
|
109
114
|
export function mergeEnv(base, kept) {
|
|
110
115
|
const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
|
|
111
|
-
|
|
116
|
+
for (const [k, v] of Object.entries(kept)) {
|
|
117
|
+
if (!SECRET_ENV.test(k) || (typeof v === "string" && v !== "")) out[k] = v;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
|