forgedock 1.43.0 → 1.44.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 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +59 -10
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.mjs +3 -1
- package/bin/lean/batch.test.mjs +83 -0
- package/bin/lean/conformance.mjs +72 -13
- package/bin/lean/conformance.test.mjs +100 -9
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +65 -17
- package/bin/lean/lean.test.mjs +84 -2
- package/bin/lean/orchestrate-blocked.test.mjs +28 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +58 -11
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/schedd.mjs +38 -5
- package/bin/lean/schedd.test.mjs +109 -0
- package/bin/lean/selftest-live.mjs +57 -32
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +8 -4
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +14 -2
- package/bin/lean/signoff.test.mjs +45 -1
- package/bin/pool/agent.mjs +1 -1
- package/bin/pool/cli.mjs +29 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +9 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +3 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
|
@@ -45,6 +45,7 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
45
45
|
RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
|
|
46
46
|
PATH_SWITCH_CONFLICT: "path.switch_conflict",
|
|
47
47
|
ENGINE_ERROR: "engine.error",
|
|
48
|
+
ENGINE_DCO_REWRITE: "engine.dco_rewrite", // forge#4997: the DCO sign-off rewrite of unpushed commits failed (git filter-branch); mechanical engine-error (not needs-human), relaunched by the batch runner
|
|
48
49
|
ENGINE_VALIDATE_RESULT_LOST: "engine.validate_result_lost", // forge#4708: a validate result recorded before a CI fix round is missing after it and could not be carried forward
|
|
49
50
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
50
51
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
@@ -198,13 +199,13 @@ export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
|
|
|
198
199
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
199
200
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
200
201
|
*/
|
|
201
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
202
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED, OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_OMISSION, OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]);
|
|
202
203
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
203
204
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
204
205
|
|
|
205
206
|
/**
|
|
206
207
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
207
|
-
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
208
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `conformance.unimplemented` / `conformance.omission` / `conformance.unjustified_deferral` (#5035; regression, guard_broken, scope_creep and e2e_unverified stay terminal), `contract.invalid` after a mid-run issue
|
|
208
209
|
* edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
209
210
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
210
211
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
|
|
9
9
|
* builder_tier/class/model/escalated 0|1, forge#4394)
|
|
10
10
|
* SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
|
|
11
|
-
* DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766)
|
|
11
|
+
* DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766; emitted once the engine is wired)
|
|
12
12
|
* PRODWATCH_METRICS -> `prodwatch_metrics` (source, sample, invalid, fp_rate, fix_ineffective_rate, recurrence_rate, signal_to_issue/merge/verified_ms; forge#4837)
|
|
13
13
|
* STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
|
|
14
14
|
* lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
|
|
|
21
21
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
22
22
|
import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
|
|
23
23
|
import { readExperiment } from "./experiment.mjs";
|
|
24
|
-
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
24
|
+
import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
|
|
25
25
|
import { parseVerify } from "./lean/verify.mjs";
|
|
26
26
|
import { normalizeKindModes } from "./lean/conformance.mjs";
|
|
27
27
|
import { viewIssue } from "./lean/gh-assoc.mjs";
|
|
@@ -1283,6 +1283,11 @@ async function addedEdges({ added, records, io, repo }) {
|
|
|
1283
1283
|
* state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
|
|
1284
1284
|
* when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
|
|
1285
1285
|
*/
|
|
1286
|
+
/** #5000: what happened to the batch after an add/requeue; never implies a run when no daemon hosts it. */
|
|
1287
|
+
const hostedLine = (id, res) => res.reopened ? `batch ${id}: finished batch reopened and hosted by the schedd`
|
|
1288
|
+
: res.hosted ? `batch ${id}: hosted by the schedd; the runner picks it up on its next pass`
|
|
1289
|
+
: `batch ${id}: queued but NOT hosted (no schedd hosts it); run: forgedock orchestrate --resume ${id}`;
|
|
1290
|
+
|
|
1286
1291
|
async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1287
1292
|
const batchMod = await import("./lean/batch.mjs");
|
|
1288
1293
|
const b = batchMod.openBatch(root, id);
|
|
@@ -1308,11 +1313,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1308
1313
|
let res = { added: [], skipped: [], requeued: [] };
|
|
1309
1314
|
if (open.length) {
|
|
1310
1315
|
res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
|
|
1311
|
-
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
|
|
1316
|
+
if (res.reopened === undefined && (res.added?.length || res.requeued?.length) && b.readReport()) b.clearReport(); // #4687/#5000: no daemon: the next --resume reruns it
|
|
1312
1317
|
}
|
|
1313
1318
|
const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1319
|
+
if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
|
|
1314
1320
|
out.edges = await addedEdges({ added: out.added, records, io, repo }); // #4773
|
|
1315
1321
|
print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1322
|
+
if (out.added.length || out.requeued.length) print(hostedLine(id, res));
|
|
1316
1323
|
if (out.edges.length) print(`batch ${id}: edges ${out.edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ")}`);
|
|
1317
1324
|
return out;
|
|
1318
1325
|
}
|
|
@@ -1320,6 +1327,26 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1320
1327
|
/** #4592: a resume's release line, only when it freed something. */
|
|
1321
1328
|
const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
|
|
1322
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* #5031: the `baseWait` seam of `orchestrate` for issues admitted while a dependency's merge commit was not yet on their base:
|
|
1332
|
+
* a detail string until every awaited commit is an ancestor of origin/<base> (fetched at most every 30s per base), else null.
|
|
1333
|
+
*/
|
|
1334
|
+
function baseWaitOf(waits, { io, leanIo, cwd, now = () => Date.now() }) {
|
|
1335
|
+
const fetched = new Map();
|
|
1336
|
+
return async (n) => {
|
|
1337
|
+
const l = waits.get(n);
|
|
1338
|
+
if (!l) return null;
|
|
1339
|
+
const missing = [];
|
|
1340
|
+
for (const w of l) {
|
|
1341
|
+
if (now() - (fetched.get(w.base) ?? -Infinity) > 30000) { fetched.set(w.base, now()); try { await leanIo.git(["fetch", "origin", w.base], { cwd }); } catch { /* offline: the local ref */ } }
|
|
1342
|
+
let on = false;
|
|
1343
|
+
for (const ref of [`origin/${w.base}`, w.base]) if (!on) on = await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", w.sha, ref]).then(() => true, () => false);
|
|
1344
|
+
if (!on) missing.push(w);
|
|
1345
|
+
}
|
|
1346
|
+
return missing.length ? `waiting for ${missing.map((w) => `#${w.dep} (${w.sha.slice(0, 10)})`).join(", ")} on ${missing[0].base}` : null;
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1323
1350
|
/**
|
|
1324
1351
|
* `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
|
|
1325
1352
|
* when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
|
|
@@ -1371,7 +1398,9 @@ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, pr
|
|
|
1371
1398
|
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
|
|
1372
1399
|
}
|
|
1373
1400
|
const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
|
|
1401
|
+
if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
|
|
1374
1402
|
print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1403
|
+
if (out.requeued.length) print(hostedLine(id, res));
|
|
1375
1404
|
return out;
|
|
1376
1405
|
}
|
|
1377
1406
|
|
|
@@ -1560,12 +1589,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1560
1589
|
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
|
|
1561
1590
|
}
|
|
1562
1591
|
let batch, plan = null;
|
|
1592
|
+
const baseWaits = new Map(); // #5031: issue -> [{dep, sha, base}] merge commits not yet on its base at start
|
|
1563
1593
|
if (resumeId) {
|
|
1564
1594
|
batch = batchMod.openBatch(root, resumeId);
|
|
1565
1595
|
batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
|
|
1566
1596
|
plan = batch.readPlan();
|
|
1567
1597
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
1568
1598
|
argv = plan.argv;
|
|
1599
|
+
// #5031: a resume (operator or schedd restart) keeps the base waits recorded at start; a landed commit releases on the first check
|
|
1600
|
+
for (const [n, l] of Object.entries(plan.baseWaits ?? {})) if (Array.isArray(l) && l.length) baseWaits.set(Number(n), l.filter((w) => w && typeof w.sha === "string" && typeof w.base === "string"));
|
|
1569
1601
|
}
|
|
1570
1602
|
const issues = issuesOf(argv);
|
|
1571
1603
|
if (!issues.length) throw new Error(ORCH_USAGE);
|
|
@@ -1593,16 +1625,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1593
1625
|
const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
|
|
1594
1626
|
const gateBranches = readBranches(cwd);
|
|
1595
1627
|
const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1596
|
-
const
|
|
1628
|
+
const isAncestor = async (sha, b) => {
|
|
1629
|
+
for (const ref of [`origin/${b}`, b]) if (await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", sha, ref]).then(() => true, () => false)) return true;
|
|
1630
|
+
return false;
|
|
1631
|
+
};
|
|
1632
|
+
let unmet = await unmetDependencies({ issues, repo, gh: io.gh, isAncestor, baseOf: async (n) => {
|
|
1597
1633
|
const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
|
|
1598
1634
|
return r.ok ? r.lane : null;
|
|
1599
1635
|
} });
|
|
1636
|
+
// #5031: a dependency merged but not yet on the base is admitted and held until its commit lands; any other unmet edge still refuses
|
|
1637
|
+
for (const u of unmet.filter((x) => x.kind === "commit")) {
|
|
1638
|
+
const list = baseWaits.get(u.issue) ?? []; list.push({ dep: u.dep, sha: u.sha, base: u.base }); baseWaits.set(u.issue, list);
|
|
1639
|
+
}
|
|
1640
|
+
for (const [n, l] of baseWaits) sink.line(`held: #${n} waits for ${l.map((w) => `#${w.dep} (${w.sha.slice(0, 10)}) on ${w.base}`).join(", ")}; admitted once the commit lands`);
|
|
1641
|
+
unmet = unmet.filter((x) => x.kind !== "commit");
|
|
1600
1642
|
if (unmet.length) throw new Error(`parallel milestone start refused: ${unmet.map((u) => `#${u.issue} depends on #${u.dep} (${u.why}; base ${u.base})`).join("; ")}. Merge the dependencies first, or pass --force "<reason>".`);
|
|
1601
1643
|
}
|
|
1602
1644
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
1603
1645
|
batch = batchMod.openBatch(root, base);
|
|
1604
1646
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
1605
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1647
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(baseWaits.size ? { baseWaits: Object.fromEntries(baseWaits) } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1606
1648
|
batch.writePlan(plan);
|
|
1607
1649
|
batch.setParallel(parallel);
|
|
1608
1650
|
} else {
|
|
@@ -1635,8 +1677,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1635
1677
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
1636
1678
|
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
|
|
1637
1679
|
const fetched = new Set();
|
|
1680
|
+
const laneCache = makeLaneCache(), milestoneOf = new Map(); // #4963: shared default-branch / ls-remote reads; the milestone readBody already fetched
|
|
1638
1681
|
const laneOf = async (n) => {
|
|
1639
|
-
const
|
|
1682
|
+
const known = milestoneOf.get(n); milestoneOf.delete(n); // used once: a later retry re-reads the live milestone
|
|
1683
|
+
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride, cache: laneCache, milestone: known });
|
|
1640
1684
|
if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
1641
1685
|
return r;
|
|
1642
1686
|
};
|
|
@@ -1877,7 +1921,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1877
1921
|
trusted: (n) => trustedOf.get(n) === true,
|
|
1878
1922
|
autoChildren,
|
|
1879
1923
|
...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
|
|
1880
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
|
|
1924
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
|
|
1881
1925
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1882
1926
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1883
1927
|
...(remote ? { remote } : {}),
|
|
@@ -1919,8 +1963,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1919
1963
|
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1920
1964
|
},
|
|
1921
1965
|
readBody: maint || registry || hybrid
|
|
1922
|
-
? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author"); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
|
|
1923
|
-
: async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author"); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
|
|
1966
|
+
? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
|
|
1967
|
+
: async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
|
|
1924
1968
|
onUpdate: (r) => sink.row(r),
|
|
1925
1969
|
});
|
|
1926
1970
|
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
@@ -1962,7 +2006,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1962
2006
|
} catch { /* alerts never fail a batch */ }
|
|
1963
2007
|
for (const l of report) sink.line(l);
|
|
1964
2008
|
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1965
|
-
|
|
2009
|
+
// #4917: a batch with any pending issue is not finished: no report, so a resume or the daemon picks it up
|
|
2010
|
+
let unfinished = false;
|
|
2011
|
+
try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
|
|
2012
|
+
if (!unfinished) try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1966
2013
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1967
2014
|
}
|
|
1968
2015
|
|
|
@@ -2106,6 +2153,7 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2106
2153
|
const pend = b.pendingAdds?.length ? `, pending adds: ${b.pendingAdds.map((n) => `#${n}`).join(" ")}` : "";
|
|
2107
2154
|
console.log(` ${b.id}${b.running ? "" : " (not hosted)"}: ${age}${pend}`);
|
|
2108
2155
|
}
|
|
2156
|
+
for (const u of st.unhosted ?? []) console.log(`schedd: batch ${u.id} is not hosted but has ${u.pending.length} pending: ${u.pending.map((n) => `#${n}`).join(" ")} (forgedock orchestrate --resume ${u.id})`);
|
|
2109
2157
|
return { running: true, ...st };
|
|
2110
2158
|
}
|
|
2111
2159
|
if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
|
|
@@ -2288,11 +2336,12 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
2288
2336
|
const st = reg.list();
|
|
2289
2337
|
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
2290
2338
|
console.log(deps.tick ?? health.tickText(health.stallCheck({ dir: schedMod.schedDir() })));
|
|
2291
|
-
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
2339
|
+
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}, remote ${(st.remote ?? []).length}`);
|
|
2292
2340
|
const ws = Object.entries(st.weights ?? {});
|
|
2293
2341
|
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
2294
2342
|
for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
|
|
2295
2343
|
for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
|
|
2344
|
+
for (const l of schedMod.remoteLines(st)) console.log(l); // #4964
|
|
2296
2345
|
return st;
|
|
2297
2346
|
}
|
|
2298
2347
|
if (sub === "cap") {
|
package/bin/forgedock.mjs
CHANGED
|
@@ -221,6 +221,19 @@ function detectInstallPaths() {
|
|
|
221
221
|
|
|
222
222
|
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* True when a link at ~/.claude/<kind>/<rel> points at a ForgeDock-owned
|
|
226
|
+
* source: the package file, or its copy in the persisted ~/.forge home
|
|
227
|
+
* (persistHome(), #1943, makes npm installs link there). Path-only, so links
|
|
228
|
+
* left dangling after ~/.forge is deleted still count as ours (#4962).
|
|
229
|
+
*/
|
|
230
|
+
function isOwnedLinkTarget(dest, src, kind, rel) {
|
|
231
|
+
return (
|
|
232
|
+
dest === src ||
|
|
233
|
+
resolve(dest) === resolve(join(HOME, ".forge", kind, rel))
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
224
237
|
// PIPELINE_SCRIPTS (the allowlist of universal pipeline-agent scripts linked
|
|
225
238
|
// to ~/.claude/scripts/) is defined in journey.mjs — forge()'s
|
|
226
239
|
// linkPipelineScripts() step is what writes them there (forge#1885,
|
|
@@ -1183,7 +1196,7 @@ async function uninstall() {
|
|
|
1183
1196
|
const stats = await lstat(target);
|
|
1184
1197
|
if (stats.isSymbolicLink()) {
|
|
1185
1198
|
const current = await readlink(target);
|
|
1186
|
-
if (current
|
|
1199
|
+
if (isOwnedLinkTarget(current, file, "commands", rel)) {
|
|
1187
1200
|
await unlink(target);
|
|
1188
1201
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1189
1202
|
removed++;
|
|
@@ -1259,7 +1272,7 @@ async function uninstall() {
|
|
|
1259
1272
|
const stats = await lstat(target);
|
|
1260
1273
|
if (stats.isSymbolicLink()) {
|
|
1261
1274
|
const current = await readlink(target);
|
|
1262
|
-
if (current
|
|
1275
|
+
if (isOwnedLinkTarget(current, file, "scripts", rel)) {
|
|
1263
1276
|
await unlink(target);
|
|
1264
1277
|
console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
|
|
1265
1278
|
scriptsRemoved++;
|
|
@@ -2507,10 +2520,16 @@ async function doctor(fix = false) {
|
|
|
2507
2520
|
const lstats = await lstat(tgt);
|
|
2508
2521
|
if (lstats.isSymbolicLink()) {
|
|
2509
2522
|
const dest = await readlink(tgt);
|
|
2510
|
-
if (dest !== src) {
|
|
2523
|
+
if (dest !== src && !isOwnedLinkTarget(dest, src, "commands", rel)) {
|
|
2511
2524
|
broken++;
|
|
2512
2525
|
brokenLinks.push(rel);
|
|
2513
2526
|
symlinkOk = false;
|
|
2527
|
+
} else if (dest !== src && readFileSync(tgt, "utf-8") !== readFileSync(src, "utf-8")) {
|
|
2528
|
+
// Linked into the persisted ~/.forge home: valid only while
|
|
2529
|
+
// that copy matches the package source (#4962).
|
|
2530
|
+
broken++;
|
|
2531
|
+
brokenLinks.push(`${rel} (stale ~/.forge copy)`);
|
|
2532
|
+
symlinkOk = false;
|
|
2514
2533
|
} else {
|
|
2515
2534
|
try {
|
|
2516
2535
|
// readlink() only validates the stored target. Opening the
|
|
@@ -3111,34 +3130,41 @@ async function doctor(fix = false) {
|
|
|
3111
3130
|
// server presence is orthogonal to the core ForgeDock install — but missing
|
|
3112
3131
|
// it causes /qa-sweep to silently fail mid-sweep.
|
|
3113
3132
|
{
|
|
3133
|
+
// Claude Code registers servers in ~/.claude.json (user + per-project) and
|
|
3134
|
+
// project .mcp.json; the legacy mcp_servers.json is still honoured (#4962).
|
|
3114
3135
|
const mcpServersPath = join(GLOBAL_CLAUDE_DIR, "mcp_servers.json");
|
|
3115
3136
|
let playwrightFound = false;
|
|
3116
3137
|
let fileReadable = false;
|
|
3117
3138
|
let hasMcpServers = false;
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3139
|
+
const readJson = (p) => {
|
|
3140
|
+
try {
|
|
3141
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
3142
|
+
} catch {
|
|
3143
|
+
return null;
|
|
3144
|
+
}
|
|
3145
|
+
};
|
|
3146
|
+
const userCfg = readJson(join(HOME, ".claude.json"));
|
|
3147
|
+
const sources = [
|
|
3148
|
+
readJson(mcpServersPath),
|
|
3149
|
+
userCfg,
|
|
3150
|
+
userCfg?.projects?.[process.cwd()],
|
|
3151
|
+
readJson(join(process.cwd(), ".mcp.json")),
|
|
3152
|
+
];
|
|
3153
|
+
for (const cfg of sources) {
|
|
3154
|
+
if (!cfg || typeof cfg !== "object") continue;
|
|
3123
3155
|
fileReadable = true;
|
|
3124
|
-
const servers =
|
|
3125
|
-
if (servers
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
if (nameLower.includes("playwright") || cmdStr.includes("playwright")) {
|
|
3135
|
-
playwrightFound = true;
|
|
3136
|
-
break;
|
|
3137
|
-
}
|
|
3156
|
+
const servers = cfg.mcpServers;
|
|
3157
|
+
if (!servers || typeof servers !== "object") continue;
|
|
3158
|
+
hasMcpServers = true;
|
|
3159
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
3160
|
+
const cmdStr = [
|
|
3161
|
+
entry?.command ?? "",
|
|
3162
|
+
...(Array.isArray(entry?.args) ? entry.args : []),
|
|
3163
|
+
].join(" ").toLowerCase();
|
|
3164
|
+
if (name.toLowerCase().includes("playwright") || cmdStr.includes("playwright")) {
|
|
3165
|
+
playwrightFound = true;
|
|
3138
3166
|
}
|
|
3139
3167
|
}
|
|
3140
|
-
} catch {
|
|
3141
|
-
// File absent or unreadable — treat as no MCP servers configured
|
|
3142
3168
|
}
|
|
3143
3169
|
|
|
3144
3170
|
if (!fileReadable) {
|
package/bin/gc.mjs
CHANGED
|
@@ -600,7 +600,9 @@ export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygi
|
|
|
600
600
|
if (owned.length) parts.push(`${owned.length} ${t}${owned.length === 1 ? "" : "s"} ${fmtSize(owned.reduce((s, i) => s + (i.sizeBytes ?? 0), 0))}`);
|
|
601
601
|
}
|
|
602
602
|
const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
603
|
-
|
|
603
|
+
// Unrelated docker projects (compose etc.) are not ours to nag about (#4962): only ForgeDock-named docker resources count.
|
|
604
|
+
const forgeNamed = (i) => (i.type !== "container" && i.type !== "volume") || /^(forgedock|forge-|fdrsbx)/i.test(String(i.id));
|
|
605
|
+
const stale = items.filter((i) => !i.owned && forgeNamed(i) && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
|
|
604
606
|
const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
|
|
605
607
|
if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}${applied.deferred?.length ? ` (${applied.deferred.length} deferred)` : ""}`);
|
|
606
608
|
else bits.push(`${reclaimable} reclaimable`);
|
|
@@ -602,7 +602,7 @@ async function main() {
|
|
|
602
602
|
if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
|
|
603
603
|
|
|
604
604
|
// --- Rule 1: PR branch target validation ---
|
|
605
|
-
const prViolation = checkPrTarget(command);
|
|
605
|
+
const prViolation = await checkPrTarget(command);
|
|
606
606
|
if (prViolation) {
|
|
607
607
|
process.stderr.write(prViolation);
|
|
608
608
|
process.exit(2);
|
|
@@ -749,6 +749,51 @@ async function isForgeDockManagedCwd() {
|
|
|
749
749
|
// Rule 1: PR branch target validation
|
|
750
750
|
// ---------------------------------------------------------------------------
|
|
751
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Resolve the `owner/repo` a `gh pr create` command targets: `-R`/`--repo`,
|
|
754
|
+
* else the cwd's `origin` remote. Lower-cased; null when unresolvable.
|
|
755
|
+
*
|
|
756
|
+
* @param {string} command
|
|
757
|
+
* @returns {string|null}
|
|
758
|
+
*/
|
|
759
|
+
function resolvePrTargetRepo(command) {
|
|
760
|
+
const slug = (v) => {
|
|
761
|
+
const m = String(v || "").trim().match(/([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
762
|
+
return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
|
|
763
|
+
};
|
|
764
|
+
const flagged = extractFlag(command, "--repo") || extractFlag(command, "-R");
|
|
765
|
+
if (flagged) return slug(flagged);
|
|
766
|
+
try {
|
|
767
|
+
const { execFileSync: exec } = _require("child_process");
|
|
768
|
+
return slug(exec("git", ["remote", "get-url", "origin"], {
|
|
769
|
+
encoding: "utf-8",
|
|
770
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
771
|
+
timeout: 5000,
|
|
772
|
+
}));
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* `owner/repo` of the forge.yaml in cwd (project.owner / project.repo),
|
|
780
|
+
* lower-cased; null when absent or unreadable.
|
|
781
|
+
*
|
|
782
|
+
* @returns {Promise<string|null>}
|
|
783
|
+
*/
|
|
784
|
+
async function configuredRepoSlug() {
|
|
785
|
+
try {
|
|
786
|
+
const raw = _require("fs").readFileSync(join(process.cwd(), "forge.yaml"), "utf-8");
|
|
787
|
+
const { parseForgeYaml } = await import(
|
|
788
|
+
pathToFileURL(join(FORGE_HOME, "bin", "forge-utils.mjs")).href
|
|
789
|
+
);
|
|
790
|
+
const { owner, repo } = parseForgeYaml(raw)?.project ?? {};
|
|
791
|
+
return owner && repo ? `${owner}/${repo}`.toLowerCase() : null;
|
|
792
|
+
} catch {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
752
797
|
/**
|
|
753
798
|
* Check whether a gh pr create command targets a forbidden base branch.
|
|
754
799
|
*
|
|
@@ -757,9 +802,9 @@ async function isForgeDockManagedCwd() {
|
|
|
757
802
|
* main are still hard-blocked.
|
|
758
803
|
*
|
|
759
804
|
* @param {string} command
|
|
760
|
-
* @returns {string|null} Error message to show, or null if allowed.
|
|
805
|
+
* @returns {Promise<string|null>} Error message to show, or null if allowed.
|
|
761
806
|
*/
|
|
762
|
-
function checkPrTarget(command) {
|
|
807
|
+
async function checkPrTarget(command) {
|
|
763
808
|
// Only check gh pr create commands.
|
|
764
809
|
if (!/gh\s+pr\s+create/.test(command)) return null;
|
|
765
810
|
|
|
@@ -767,6 +812,12 @@ function checkPrTarget(command) {
|
|
|
767
812
|
if (!base) return null; // no --base flag — gh will use the default
|
|
768
813
|
|
|
769
814
|
if (FORBIDDEN_PR_BASES.includes(base.toLowerCase())) {
|
|
815
|
+
// The staging/milestone rule only governs the configured forge repo; a PR
|
|
816
|
+
// to a positively-identified other repo may legitimately target main.
|
|
817
|
+
const target = resolvePrTargetRepo(command);
|
|
818
|
+
const configured = await configuredRepoSlug();
|
|
819
|
+
if (target && configured && target !== configured) return null;
|
|
820
|
+
|
|
770
821
|
// Allow staging → main (the deploy path, not a pipeline violation).
|
|
771
822
|
const head = extractFlag(command, "--head") || extractFlag(command, "-H") || currentGitBranch();
|
|
772
823
|
if (head && /^staging$/.test(head)) {
|
package/bin/journey.mjs
CHANGED
|
@@ -642,9 +642,9 @@ export function openUrl(url) {
|
|
|
642
642
|
if (process.platform === "win32") {
|
|
643
643
|
spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
644
644
|
} else if (process.platform === "darwin") {
|
|
645
|
-
spawnSync("open", [url], { detached: true, stdio: "ignore" });
|
|
645
|
+
spawnSync("open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
646
646
|
} else {
|
|
647
|
-
spawnSync("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
647
|
+
spawnSync("xdg-open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
648
648
|
}
|
|
649
649
|
} catch {
|
|
650
650
|
// Best-effort — if the browser can't be opened, the journey continues.
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// Pure and deterministic: the child scan reads one open-issue list per pass; no LLM call.
|
|
5
5
|
import { parentOf } from "./context.mjs";
|
|
6
6
|
import { parseHelpFields } from "./selftest-static.mjs";
|
|
7
|
+
import { listAssociations } from "./gh-assoc.mjs";
|
|
8
|
+
import { isTrustedActor } from "../engine/phases.mjs";
|
|
7
9
|
|
|
8
10
|
export const AUTO_MODES = ["off", "decomposed", "epic", "all"];
|
|
9
11
|
export const DEFAULT_AUTO_MODE = "decomposed";
|
|
@@ -41,9 +43,11 @@ const labelsOf = (it) => nodesOf(it?.labels).map((l) => String(l?.name ?? l));
|
|
|
41
43
|
* @param {Iterable<number>} roots the batch issues plus the `--epic N` epics
|
|
42
44
|
* @param {Iterable<number>} skip issues the batch already holds
|
|
43
45
|
* @returns {number[]} open children of a root, ascending: `**Parent**: #P` line, `parent` field, or the root's `subIssues`;
|
|
44
|
-
* an issue labelled `needs-human` is left out.
|
|
46
|
+
* an issue labelled `needs-human` is left out. #4982: so is a child whose author is not trusted (the `Blocked by` edge
|
|
47
|
+
* rule, `isTrustedActor`; a missing association fails closed), reported through `onSkip({number, reason})`.
|
|
48
|
+
* @param {(s:{number:number, reason:string})=>void} [onSkip]
|
|
45
49
|
*/
|
|
46
|
-
export function selectChildren({ list, roots, skip = [] }) {
|
|
50
|
+
export function selectChildren({ list, roots, skip = [], onSkip }) {
|
|
47
51
|
const rootSet = new Set(roots), skipSet = new Set(skip), open = new Set(), out = new Set();
|
|
48
52
|
for (const it of list ?? []) if (Number.isSafeInteger(it?.number)) open.add(it.number);
|
|
49
53
|
for (const it of list ?? []) {
|
|
@@ -54,10 +58,12 @@ export function selectChildren({ list, roots, skip = [] }) {
|
|
|
54
58
|
if (p != null && rootSet.has(p)) out.add(n);
|
|
55
59
|
}
|
|
56
60
|
const byNum = new Map((list ?? []).map((it) => [it.number, it]));
|
|
57
|
-
|
|
61
|
+
const trusted = (it) => isTrustedActor({ association: it?.authorAssociation, type: it?.author?.is_bot ? "Bot" : "User", login: it?.author?.login });
|
|
62
|
+
return [...out].filter((n) => !skipSet.has(n) && !labelsOf(byNum.get(n)).includes("needs-human")).sort((a, b) => a - b)
|
|
63
|
+
.filter((n) => trusted(byNum.get(n)) || (onSkip?.({ number: n, reason: "untrusted author" }), false));
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
const REQUIRED_FIELDS = ["number", "body", "labels"], OPTIONAL_FIELDS = ["parent", "subIssues"];
|
|
66
|
+
const REQUIRED_FIELDS = ["number", "body", "labels", "author"], OPTIONAL_FIELDS = ["parent", "subIssues"];
|
|
61
67
|
|
|
62
68
|
/** The `--json` list this host's `gh issue list` accepts (read from its `--help`, K4): the required fields plus whichever sub-issue fields it advertises. */
|
|
63
69
|
export async function hostListFields(gh) {
|
|
@@ -74,7 +80,9 @@ export async function readChildList({ gh, repo, limit = 1000, fields = null }) {
|
|
|
74
80
|
const f = fields ?? (await hostListFields(gh));
|
|
75
81
|
const raw = await gh(["issue", "list", "-R", repo, "--state", "open", "--limit", String(limit), "--json", f.join(",")]);
|
|
76
82
|
const v = JSON.parse(raw);
|
|
77
|
-
|
|
83
|
+
if (!Array.isArray(v)) return [];
|
|
84
|
+
const assoc = await listAssociations(gh, repo); // #4982: not a `gh issue list` field (#4859); unreadable = every author untrusted
|
|
85
|
+
return v.map((it) => ({ ...it, authorAssociation: assoc.get(it?.number) }));
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
/**
|
|
@@ -26,12 +26,12 @@ describe("#4965 modes", () => {
|
|
|
26
26
|
describe("#4965 selectChildren", () => {
|
|
27
27
|
const list = [
|
|
28
28
|
{ number: 1, body: "epic", labels: [], subIssues: { nodes: [{ number: 5 }, { number: 9 }] } },
|
|
29
|
-
{ number: 2, body: "x\n\n**Parent**: #1", labels: [] },
|
|
30
|
-
{ number: 3, body: "", labels: [], parent: { number: 1 } },
|
|
31
|
-
{ number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] },
|
|
32
|
-
{ number: 5, body: "", labels: [] },
|
|
33
|
-
{ number: 6, body: "**Parent**: #77", labels: [] },
|
|
34
|
-
{ number: 7, body: "**Parent**: #1", labels: [] },
|
|
29
|
+
{ number: 2, body: "x\n\n**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
30
|
+
{ number: 3, body: "", labels: [], parent: { number: 1 }, authorAssociation: "MEMBER" },
|
|
31
|
+
{ number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" },
|
|
32
|
+
{ number: 5, body: "", labels: [], authorAssociation: "MEMBER" },
|
|
33
|
+
{ number: 6, body: "**Parent**: #77", labels: [], authorAssociation: "MEMBER" },
|
|
34
|
+
{ number: 7, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
35
35
|
];
|
|
36
36
|
it("finds children by Parent line, parent field and sub-issues; skips needs-human, closed sub-issues and unrelated", () => {
|
|
37
37
|
assert.deepEqual(selectChildren({ list, roots: [1], skip: [1] }), [2, 3, 5, 7]);
|
|
@@ -42,8 +42,25 @@ describe("#4965 selectChildren", () => {
|
|
|
42
42
|
it("an --epic root outside the batch yields its children", () => {
|
|
43
43
|
assert.deepEqual(selectChildren({ list, roots: [77] }), [6]);
|
|
44
44
|
});
|
|
45
|
+
it("#4982 selectChildren drops children of an untrusted author and reports each", () => {
|
|
46
|
+
const l = [
|
|
47
|
+
{ number: 1, body: "", labels: [], authorAssociation: "OWNER", subIssues: { nodes: [{ number: 5 }] } },
|
|
48
|
+
{ number: 2, body: "**Parent**: #1", labels: [], authorAssociation: "NONE", author: { login: "eve" } },
|
|
49
|
+
{ number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
50
|
+
{ number: 4, body: "**Parent**: #1", labels: [], author: { login: "app", is_bot: true } },
|
|
51
|
+
{ number: 5, body: "", labels: [] },
|
|
52
|
+
{ number: 6, body: "", labels: [], parent: { number: 1 } },
|
|
53
|
+
];
|
|
54
|
+
const skipped = [];
|
|
55
|
+
assert.deepEqual(selectChildren({ list: l, roots: [1], skip: [1], onSkip: (s) => skipped.push(s) }), [3, 4]);
|
|
56
|
+
assert.deepEqual(skipped, [2, 5, 6].map((number) => ({ number, reason: "untrusted author" })));
|
|
57
|
+
});
|
|
58
|
+
it("#4982 readChildList merges the REST author association per issue", async () => {
|
|
59
|
+
const gh = async (a) => a[0] === "api" ? "1\tMEMBER\n" : a.includes("--help") ? "JSON FIELDS\n number, body, labels, author\n\nEXAMPLES\n" : "[{\"number\":1},{\"number\":2}]";
|
|
60
|
+
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: "MEMBER" }, { number: 2, authorAssociation: undefined }]);
|
|
61
|
+
});
|
|
45
62
|
it("children filed later appear on a later list", () => {
|
|
46
|
-
assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [] }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
|
|
63
|
+
assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
|
|
47
64
|
});
|
|
48
65
|
});
|
|
49
66
|
|
|
@@ -59,17 +76,17 @@ describe("#4965 readers", () => {
|
|
|
59
76
|
};
|
|
60
77
|
it("readChildList asks the host gh for its fields, then makes one list call", async () => {
|
|
61
78
|
const calls = [];
|
|
62
|
-
const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "parent", "subIssues", "title"], calls), repo: "o/r" });
|
|
63
|
-
assert.equal(calls.filter((a) =>
|
|
64
|
-
assert.equal(calls.
|
|
65
|
-
assert.deepEqual(r, [{ number: 1 }]);
|
|
79
|
+
const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "author", "parent", "subIssues", "title"], calls), repo: "o/r" });
|
|
80
|
+
assert.equal(calls.filter((a) => a.includes("--json")).length, 1);
|
|
81
|
+
assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author,parent,subIssues");
|
|
82
|
+
assert.deepEqual(r, [{ number: 1, authorAssociation: undefined }]);
|
|
66
83
|
});
|
|
67
84
|
it("a host gh without parent/subIssues still lists, with the fields it has (children found by Parent line)", async () => {
|
|
68
85
|
const calls = [];
|
|
69
|
-
const gh = fakeGh(["number", "body", "labels", "title"], calls);
|
|
70
|
-
assert.deepEqual(await hostListFields(gh), ["number", "body", "labels"]);
|
|
71
|
-
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1 }]);
|
|
72
|
-
assert.equal(calls.
|
|
86
|
+
const gh = fakeGh(["number", "body", "labels", "author", "title"], calls);
|
|
87
|
+
assert.deepEqual(await hostListFields(gh), ["number", "body", "labels", "author"]);
|
|
88
|
+
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: undefined }]);
|
|
89
|
+
assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author");
|
|
73
90
|
});
|
|
74
91
|
it("liveElsewhere holds the unsettled issues of other live batches of the repo only", () => {
|
|
75
92
|
const mk = (o) => ({ readReport: () => o.report ?? null, readPlan: () => ({ repo: o.repo ?? "o/r" }), store: { load: () => ({ order: o.order, results: o.results ?? {} }) } });
|
|
@@ -109,11 +126,12 @@ appendFileSync(${JSON.stringify(launches)}, a[1] + "\\n"); writeFileSync(rf, JSO
|
|
|
109
126
|
b.setParallel(2);
|
|
110
127
|
const lists = [];
|
|
111
128
|
const gh = async (a) => {
|
|
112
|
-
if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, parent, subIssues\n\nEXAMPLES\n";
|
|
113
|
-
if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [] }, { number: 51, body: "**Parent**: #9", labels: [] }]); }
|
|
129
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, author, parent, subIssues\n\nEXAMPLES\n";
|
|
130
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,author,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #9", labels: [], authorAssociation: "MEMBER" }]); }
|
|
131
|
+
if (a[0] === "api" && /issues\?state=open/.test(a[1])) return "9\tMEMBER\n51\tMEMBER\n";
|
|
114
132
|
if (a.includes("milestone")) return JSON.stringify({ milestone: null });
|
|
115
133
|
if (a[0] === "repo") return "main\n";
|
|
116
|
-
return JSON.stringify({ body: "", state: "OPEN", labels: [] });
|
|
134
|
+
return JSON.stringify({ body: "", state: "OPEN", labels: [], authorAssociation: "MEMBER" });
|
|
117
135
|
};
|
|
118
136
|
const deps = { batchRoot: root, allowTmpfs: true, laneScript: stub, pollMs: 20, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" },
|
|
119
137
|
leanIo: { gh, git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") } };
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -191,6 +191,8 @@ export function openBatch(root, id) {
|
|
|
191
191
|
}
|
|
192
192
|
return { requeued, refused };
|
|
193
193
|
},
|
|
194
|
+
/** #4917: the issues still `pending` in state.json (deferred or requeued included); [] when none or unreadable. */
|
|
195
|
+
pendingIssues: () => { const p = readJson(f("state.json"))?.pending; return Array.isArray(p) ? p.filter((n) => Number.isSafeInteger(n)) : []; },
|
|
194
196
|
/** Every `--requeue` entry, in order; [] when missing or invalid. */
|
|
195
197
|
readRequeued: () => readRequeueFile(f("requeue")),
|
|
196
198
|
/** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
|
|
@@ -265,7 +267,7 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
|
|
|
265
267
|
const out = openSync(batch.logFile(issue), "a", 0o600);
|
|
266
268
|
try {
|
|
267
269
|
// FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
|
|
268
|
-
const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
|
|
270
|
+
const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
|
|
269
271
|
child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
|
|
270
272
|
child.unref?.();
|
|
271
273
|
return { pid: child.pid };
|