forgedock 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/bin/engine-cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
|
|
9
9
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
13
13
|
import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
|
|
14
14
|
import { makeProjector } from "./engine/projector.mjs";
|
|
15
15
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
@@ -18,6 +18,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
|
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
19
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
20
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
|
+
import { parseExecution } from "./lean/execution.mjs";
|
|
21
22
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
22
23
|
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
23
24
|
|
|
@@ -103,6 +104,46 @@ export function readConformanceMode(cwd = process.cwd()) {
|
|
|
103
104
|
return ["shadow", "enforce"].includes(v) ? v : undefined;
|
|
104
105
|
} catch { return undefined; }
|
|
105
106
|
}
|
|
107
|
+
/** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
|
|
108
|
+
export function readReviewIncomplete(cwd = process.cwd()) {
|
|
109
|
+
try {
|
|
110
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.review_incomplete ?? "").trim();
|
|
111
|
+
return ["needs-human", "degraded"].includes(v) ? v : undefined;
|
|
112
|
+
} catch { return undefined; }
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
|
|
116
|
+
* list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
|
|
117
|
+
* shadow with the built-in risk patterns). An invalid mode reads as unset.
|
|
118
|
+
*/
|
|
119
|
+
export function readReviewTrigger(cwd = process.cwd()) {
|
|
120
|
+
let raw;
|
|
121
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
122
|
+
let sec = {};
|
|
123
|
+
try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
|
|
124
|
+
const m = String(sec?.trigger ?? "").trim();
|
|
125
|
+
const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
|
|
126
|
+
const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
|
|
127
|
+
let inReview = false, listIndent = null;
|
|
128
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
129
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
130
|
+
if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
131
|
+
if (!inReview) continue;
|
|
132
|
+
const indent = line.length - line.trimStart().length;
|
|
133
|
+
if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
|
|
134
|
+
if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
135
|
+
const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
|
|
136
|
+
if (li) riskPaths.push(li[1].trim());
|
|
137
|
+
}
|
|
138
|
+
return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
|
|
141
|
+
export function readReproTest(cwd = process.cwd()) {
|
|
142
|
+
try {
|
|
143
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.repro_test ?? "").trim();
|
|
144
|
+
return ["off", "prefer"].includes(v) ? v : undefined;
|
|
145
|
+
} catch { return undefined; }
|
|
146
|
+
}
|
|
106
147
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
107
148
|
try {
|
|
108
149
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -124,6 +165,20 @@ export function readAdmission(cwd = process.cwd()) {
|
|
|
124
165
|
max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
|
|
125
166
|
} catch { return {}; }
|
|
126
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* forge.yaml heavy-step semaphore (#4169), read next to `engine.admission`: `engine.heavy_enabled|heavy_max|
|
|
170
|
+
* heavy_step_mem_mb|heavy_reserve_mb` (or flat `admission_heavy_*`). Unset keys keep the heavy.mjs defaults.
|
|
171
|
+
*/
|
|
172
|
+
export function readHeavy(cwd = process.cwd()) {
|
|
173
|
+
try {
|
|
174
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
175
|
+
const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n > 0 ? n : undefined; };
|
|
176
|
+
const pick = (k) => e[`heavy_${k}`] ?? e[`admission_heavy_${k}`];
|
|
177
|
+
const en = String(pick("enabled") ?? "").trim().toLowerCase();
|
|
178
|
+
const out = { ...(en === "false" ? { enabled: false } : {}), max: num(pick("max")), step_mem_mb: num(pick("step_mem_mb")), reserve_mb: num(pick("reserve_mb")) };
|
|
179
|
+
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
|
|
180
|
+
} catch { return {}; }
|
|
181
|
+
}
|
|
127
182
|
/**
|
|
128
183
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
129
184
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
@@ -159,6 +214,62 @@ export function readVerify(cwd = process.cwd()) {
|
|
|
159
214
|
return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
|
|
160
215
|
} catch { return undefined; }
|
|
161
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
|
|
219
|
+
* ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
|
|
220
|
+
* ` token_env`, ` cache_store`, ` allow_actions`; #4255 budget: ` budget_minutes`, ` budget_usd`, ` day_budget_minutes`,
|
|
221
|
+
* ` day_budget_usd`, ` on_budget: stop|local`, ` cost_per_minute`) for `orchestrate`; undefined when the section is
|
|
222
|
+
* absent or unreadable.
|
|
223
|
+
*/
|
|
224
|
+
export function readExecution(cwd = process.cwd()) {
|
|
225
|
+
try {
|
|
226
|
+
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.execution;
|
|
227
|
+
return sec && typeof sec === "object" ? parseExecution(sec) : undefined;
|
|
228
|
+
} catch { return undefined; }
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* forge.yaml `learned.test_commands` (#4240): the repo's declared check commands, run by the core path in the lane
|
|
232
|
+
* worktree before the PR. A block list (` test_commands:` then ` - "cmd"` or ` - cmd`); parseForgeYaml reads
|
|
233
|
+
* scalars only. undefined when absent, empty or unreadable.
|
|
234
|
+
*/
|
|
235
|
+
export function readPrecheckCommands(cwd = process.cwd()) {
|
|
236
|
+
let raw;
|
|
237
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
238
|
+
const out = [];
|
|
239
|
+
let inLearned = false, listIndent = null;
|
|
240
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
241
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
242
|
+
if (/^\S/.test(line)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
243
|
+
if (!inLearned) continue;
|
|
244
|
+
const indent = line.length - line.trimStart().length;
|
|
245
|
+
if (listIndent === null) {
|
|
246
|
+
if (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
250
|
+
const m = /^\s+-\s+(.*)$/.exec(line);
|
|
251
|
+
if (!m) continue;
|
|
252
|
+
let v = m[1].trim();
|
|
253
|
+
const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
|
|
254
|
+
if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
|
|
255
|
+
else v = v.replace(/\s+#.*$/, "").trim();
|
|
256
|
+
if (v) out.push(v);
|
|
257
|
+
}
|
|
258
|
+
return out.length ? out : undefined;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
|
|
262
|
+
* per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
|
|
263
|
+
*/
|
|
264
|
+
export function readEnvConfig(cwd = process.cwd()) {
|
|
265
|
+
try {
|
|
266
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.env ?? {};
|
|
267
|
+
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
268
|
+
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
269
|
+
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
270
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
|
|
271
|
+
} catch { return {}; }
|
|
272
|
+
}
|
|
162
273
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
163
274
|
|
|
164
275
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
@@ -512,7 +623,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
512
623
|
const backend = flag(argv, "--backend");
|
|
513
624
|
const model = flag(argv, "--model");
|
|
514
625
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
515
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file"].includes(argv[i - 1]));
|
|
626
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason"].includes(argv[i - 1]));
|
|
516
627
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
517
628
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
518
629
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
@@ -527,6 +638,10 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
527
638
|
if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
|
|
528
639
|
const io = deps.io ?? makeIo();
|
|
529
640
|
const resultFile = flag(argv, "--result-file");
|
|
641
|
+
// #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
|
|
642
|
+
const placementWhere = flag(argv, "--placement");
|
|
643
|
+
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
644
|
+
? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
|
|
530
645
|
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
531
646
|
try {
|
|
532
647
|
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
@@ -589,9 +704,9 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
589
704
|
let lres;
|
|
590
705
|
try {
|
|
591
706
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
592
|
-
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}),
|
|
707
|
+
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}),
|
|
593
708
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
594
|
-
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
709
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
595
710
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
596
711
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
597
712
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -774,8 +889,106 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
774
889
|
* `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
|
|
775
890
|
* forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
|
|
776
891
|
* burst, and `--set-parallel <id> K` changes K of a running batch.
|
|
892
|
+
* Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
|
|
893
|
+
* a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
|
|
894
|
+
* unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
|
|
895
|
+
* Host lane registry (#4191): every lane takes a slot in `~/.forge/sched/` (or `FORGEDOCK_SCHED_DIR`), capped host-wide by
|
|
896
|
+
* `forgedock sched cap N` or forge.yaml `orchestration.global_parallel`; each issue's priority comes from its
|
|
897
|
+
* `priority:P<n>` / `P<n>` labels (unlabelled = P2), read in the same gh call as its body.
|
|
898
|
+
* Host scheduler (#4192): the batch runs in the `forgedock schedd` daemon (auto-spawned, `lean/schedd.mjs`), one process
|
|
899
|
+
* for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
|
|
900
|
+
* running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
|
|
901
|
+
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
777
902
|
*/
|
|
778
903
|
export async function orchestrateFromCli(argv, deps = {}) {
|
|
904
|
+
const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
|
|
905
|
+
|| argv.includes("--foreground") || process.env.FORGEDOCK_SCHEDD === "0";
|
|
906
|
+
if (inProcess) return runBatch({ argv: argv.filter((a) => a !== "--foreground"), cwd: process.cwd(), env: process.env, sink: consoleSink(), deps });
|
|
907
|
+
return orchestrateClient(argv, deps);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
911
|
+
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
912
|
+
|
|
913
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
|
|
914
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
915
|
+
const issuesOf = (argv) => {
|
|
916
|
+
const issues = [];
|
|
917
|
+
for (let i = 0; i < argv.length; i++) {
|
|
918
|
+
if (ORCH_VALUED.includes(argv[i])) { i++; continue; }
|
|
919
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
920
|
+
}
|
|
921
|
+
return issues;
|
|
922
|
+
};
|
|
923
|
+
const parallelOf = (argv) => {
|
|
924
|
+
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
925
|
+
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
926
|
+
return parallel;
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
|
|
931
|
+
* streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
|
|
932
|
+
*/
|
|
933
|
+
async function orchestrateClient(argv, deps = {}) {
|
|
934
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
935
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
936
|
+
const connect = () => schedd.connectDaemon({ dir, ...(deps.spawnDaemon !== undefined ? { spawnDaemon: deps.spawnDaemon } : {}), ...(deps.waitMs ? { waitMs: deps.waitMs } : {}) });
|
|
937
|
+
if (argv.includes("--set-parallel")) {
|
|
938
|
+
const i = argv.indexOf("--set-parallel");
|
|
939
|
+
const id = argv[i + 1], k = Number(argv[i + 2]);
|
|
940
|
+
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
941
|
+
let sock = null;
|
|
942
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
943
|
+
if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
|
|
944
|
+
else {
|
|
945
|
+
// No daemon: the batch is not running anywhere; the parallel file is all there is to change.
|
|
946
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
947
|
+
const b = batchMod.openBatch(batchMod.batchRoot(), id);
|
|
948
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${batchMod.batchRoot()}`);
|
|
949
|
+
b.setParallel(k);
|
|
950
|
+
}
|
|
951
|
+
console.log(`batch ${id}: parallel set to ${k}`);
|
|
952
|
+
return { ok: true, batch: id, parallel: k };
|
|
953
|
+
}
|
|
954
|
+
const resumeId = flag(argv, "--resume");
|
|
955
|
+
let msg;
|
|
956
|
+
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
957
|
+
else {
|
|
958
|
+
if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
|
|
959
|
+
parallelOf(argv);
|
|
960
|
+
let sub = [...argv];
|
|
961
|
+
if (!flag(sub, "--repo")) {
|
|
962
|
+
const repo = await resolveDefaultRepo(deps.io ?? makeIo());
|
|
963
|
+
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
964
|
+
sub = [...sub, "--repo", repo];
|
|
965
|
+
}
|
|
966
|
+
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
|
|
967
|
+
}
|
|
968
|
+
const sock = await connect();
|
|
969
|
+
let id = resumeId;
|
|
970
|
+
try {
|
|
971
|
+
const done = await schedd.request(sock, msg, (m) => {
|
|
972
|
+
if (m.type === "batch") id = m.id;
|
|
973
|
+
else if (m.type === "row") console.log(formatRowLine(m.row));
|
|
974
|
+
else if (m.type === "line") console.log(m.text);
|
|
975
|
+
else if (m.type === "done" && m.replay && m.report) console.log(m.report);
|
|
976
|
+
});
|
|
977
|
+
return done.result;
|
|
978
|
+
} catch (e) {
|
|
979
|
+
if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
|
|
980
|
+
throw e;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* One batch from start to report, in this process: the in-process `orchestrate` path and the schedd daemon's per-batch
|
|
986
|
+
* runner (#4192). `cwd` and `env` are the submitting client's (`env` picks the lanes' account); output goes to `sink`
|
|
987
|
+
* (`batch(b)` once the batch exists, `row(r)` per update, `line(text)` otherwise). A daemon-hosted batch records
|
|
988
|
+
* `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
|
|
989
|
+
* (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
|
|
990
|
+
*/
|
|
991
|
+
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
|
|
779
992
|
const batchMod = await import("./lean/batch.mjs");
|
|
780
993
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
781
994
|
if (argv.includes("--set-parallel")) {
|
|
@@ -784,7 +997,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
784
997
|
if (!b.exists()) throw new Error(`no batch ${argv[i + 1]} in ${root}`);
|
|
785
998
|
const k = Number(argv[i + 2]);
|
|
786
999
|
b.setParallel(k);
|
|
787
|
-
|
|
1000
|
+
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
788
1001
|
return { ok: true, batch: b.id, parallel: k };
|
|
789
1002
|
}
|
|
790
1003
|
const resumeId = flag(argv, "--resume");
|
|
@@ -795,39 +1008,40 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
795
1008
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
796
1009
|
argv = plan.argv;
|
|
797
1010
|
}
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
806
|
-
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1011
|
+
const issues = issuesOf(argv);
|
|
1012
|
+
if (!issues.length) throw new Error(ORCH_USAGE);
|
|
1013
|
+
const parallel = parallelOf(argv);
|
|
1014
|
+
// The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
|
|
1015
|
+
// A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
|
|
1016
|
+
const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
|
|
1017
|
+
const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
|
|
807
1018
|
const lane = flag(argv, "--lane");
|
|
808
1019
|
const laneOverride = argv.includes("--lane-override");
|
|
809
1020
|
const model = flag(argv, "--model");
|
|
810
1021
|
const io = deps.io ?? makeIo();
|
|
811
1022
|
const repo = flag(argv, "--repo") ?? plan?.repo ?? (await resolveDefaultRepo(io));
|
|
812
1023
|
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
813
|
-
|
|
814
|
-
|
|
1024
|
+
const cwd = plan?.cwd ?? callerCwd;
|
|
1025
|
+
await assertLaneNotDefault(io, lane, repo, cwd);
|
|
815
1026
|
if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
|
|
816
1027
|
if (!batch) {
|
|
817
1028
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
818
1029
|
batch = batchMod.openBatch(root, base);
|
|
819
1030
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
820
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString() };
|
|
1031
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
821
1032
|
batch.writePlan(plan);
|
|
822
1033
|
batch.setParallel(parallel);
|
|
823
1034
|
} else {
|
|
824
1035
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
825
1036
|
const st = batch.store.load();
|
|
826
|
-
if (st) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1037
|
+
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1038
|
+
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1039
|
+
batch.clearReport();
|
|
827
1040
|
}
|
|
828
|
-
|
|
1041
|
+
sink.batch?.(batch);
|
|
1042
|
+
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
829
1043
|
const dir = deps.dir ?? runDir();
|
|
830
|
-
const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
|
|
1044
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary } = await import("./lean/orchestrate.mjs");
|
|
831
1045
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
832
1046
|
const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
833
1047
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
@@ -839,40 +1053,280 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
839
1053
|
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 */ } }
|
|
840
1054
|
return r;
|
|
841
1055
|
};
|
|
842
|
-
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes
|
|
843
|
-
|
|
1056
|
+
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
|
|
1057
|
+
// sandboxes when forge.yaml `execution` places them remote (#4253).
|
|
1058
|
+
const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
|
|
1059
|
+
let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
|
|
1060
|
+
const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
|
|
844
1061
|
if (deps.leanModule) {
|
|
845
1062
|
const { runCore } = deps.leanModule;
|
|
846
1063
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
847
|
-
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
848
|
-
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1064
|
+
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1065
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
849
1066
|
} else {
|
|
850
|
-
|
|
851
|
-
|
|
1067
|
+
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1068
|
+
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1069
|
+
const placementOf = new Map();
|
|
1070
|
+
const laneArgs = (n, ln) => { const pl = placementOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
|
|
1071
|
+
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : [])]; };
|
|
1072
|
+
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => { if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n); return l.runOne(n, ln, ctx); }, adopt: l.adopt });
|
|
1073
|
+
const execMod = await import("./lean/execution.mjs");
|
|
1074
|
+
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1075
|
+
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1076
|
+
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1077
|
+
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1078
|
+
let gitDir = null;
|
|
1079
|
+
try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
|
|
1080
|
+
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env });
|
|
1081
|
+
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1082
|
+
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1083
|
+
const bud = exec.budget ?? {};
|
|
1084
|
+
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1085
|
+
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1086
|
+
lanes = withPlacement(remoteLanes);
|
|
1087
|
+
if (bud.onBudget === "local" || hybrid) {
|
|
1088
|
+
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
1089
|
+
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1090
|
+
lanes = withPlacement({
|
|
1091
|
+
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1092
|
+
adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
// #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
|
|
1096
|
+
execConf = exec;
|
|
1097
|
+
execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
|
|
1098
|
+
const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1099
|
+
// #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
|
|
1100
|
+
const place = hybrid ? await (async () => {
|
|
1101
|
+
const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
|
|
1102
|
+
let weight = deps.validationWeight ?? null;
|
|
1103
|
+
if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
|
|
1104
|
+
const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
|
|
1105
|
+
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
|
|
1106
|
+
})() : null;
|
|
1107
|
+
remote = {
|
|
1108
|
+
max: exec.maxRemote, isRemote: () => true, ...(place ? { place, fallbackLocal: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1109
|
+
admit: (u) => execBudget.admit(u),
|
|
1110
|
+
onUsage: (n, sb, rec) => {
|
|
1111
|
+
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
1112
|
+
try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
|
|
1113
|
+
Promise.resolve(emitSbx({ event: "SANDBOX_USAGE", issue: n, provider: sb.provider, sandbox_id: sb.id, minutes: sb.minutes, cost_usd: sb.cost_usd, batch: batch.id })).catch(() => {});
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
|
|
1117
|
+
} else {
|
|
1118
|
+
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1119
|
+
}
|
|
852
1120
|
}
|
|
853
1121
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1122
|
+
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1123
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1124
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
1125
|
+
const identity = deps.identity ?? (() => limitsMod.laneBackendId(env));
|
|
1126
|
+
const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
|
|
1127
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1128
|
+
const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
|
|
1129
|
+
const labelNames = async (n) => {
|
|
1130
|
+
if (!labelsOf.has(n)) labelsOf.set(n, (JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "labels"])).labels ?? []).map((x) => x.name));
|
|
1131
|
+
return labelsOf.get(n);
|
|
1132
|
+
};
|
|
1133
|
+
let budget = {};
|
|
1134
|
+
if (maint) {
|
|
1135
|
+
const mLabels = maint.labels ?? DEFAULT_MAINTENANCE_LABELS;
|
|
1136
|
+
for (const l of mLabels) {
|
|
1137
|
+
let listed = [];
|
|
1138
|
+
try { listed = JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--state", "open", "--label", l, "--json", "number,labels", "--limit", "100"])); } catch { listed = []; }
|
|
1139
|
+
for (const it of Array.isArray(listed) ? listed : []) {
|
|
1140
|
+
const names = (it.labels ?? []).map((x) => x.name);
|
|
1141
|
+
if (!labelsOf.has(it.number)) labelsOf.set(it.number, names);
|
|
1142
|
+
if (!names.includes("needs-human") && !issues.includes(it.number)) issues.push(it.number);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const classify = async (n) => {
|
|
1146
|
+
const names = await labelNames(n);
|
|
1147
|
+
const p = names.map((x) => /^priority:P(\d+)$/i.exec(x)?.[1]).find((x) => x !== undefined);
|
|
1148
|
+
return { maintenance: names.some((x) => mLabels.includes(x)), priority: p === undefined ? 2 : Number(p) };
|
|
1149
|
+
};
|
|
1150
|
+
budget = { classify, maintenance: { share: maint.share ?? 0.2, whenIdle: maint.whenIdle ?? true } };
|
|
1151
|
+
}
|
|
1152
|
+
// forge#4161: milestone drift. Always folds merged milestone lanes into the summary; thresholds and holds only with config.
|
|
1153
|
+
let drift = deps.drift;
|
|
1154
|
+
if (drift === undefined) {
|
|
1155
|
+
const { makeDrift } = await import("./lean/drift.mjs");
|
|
1156
|
+
const { affectedFiles } = await import("./orchestrate-preflight.mjs");
|
|
1157
|
+
drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
|
|
1158
|
+
onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
|
|
1159
|
+
}
|
|
854
1160
|
const crossLane = [];
|
|
1161
|
+
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1162
|
+
const gcMod = await import("./gc.mjs");
|
|
1163
|
+
const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
|
|
1164
|
+
const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
|
|
1165
|
+
const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
|
|
1166
|
+
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
|
|
1167
|
+
const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
|
|
1168
|
+
const nowMs = deps.now ?? (() => Date.now());
|
|
1169
|
+
let lastGcAt = null;
|
|
1170
|
+
const runGcPhase = async (phase) => {
|
|
1171
|
+
if (!gcRun) return;
|
|
1172
|
+
lastGcAt = nowMs();
|
|
1173
|
+
try {
|
|
1174
|
+
const r = (await gcRun({ phase })) ?? {};
|
|
1175
|
+
const n = Number(r.reclaimed) || 0;
|
|
1176
|
+
reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
|
|
1177
|
+
reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
|
|
1178
|
+
for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
|
|
1179
|
+
} catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1180
|
+
};
|
|
1181
|
+
await runGcPhase("preflight");
|
|
1182
|
+
const GC_ADMISSION_INTERVAL_MS = 5 * 60 * 1000;
|
|
1183
|
+
const beforeAdmit = !gcRun ? null : async () => {
|
|
1184
|
+
if (lastGcAt !== null && nowMs() - lastGcAt < GC_ADMISSION_INTERVAL_MS) return;
|
|
1185
|
+
let near = false;
|
|
1186
|
+
try {
|
|
1187
|
+
const { nearCeiling } = await import("./lean/admission.mjs");
|
|
1188
|
+
const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
|
|
1189
|
+
near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
|
|
1190
|
+
} catch { near = false; }
|
|
1191
|
+
if (near) await runGcPhase("admission");
|
|
1192
|
+
};
|
|
855
1193
|
const rows = await orchestrate({
|
|
1194
|
+
beforeAdmit,
|
|
856
1195
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
|
|
857
|
-
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
|
|
1196
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1197
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1198
|
+
...(remote ? { remote } : {}),
|
|
858
1199
|
onCrossLane: (e) => {
|
|
859
1200
|
crossLane.push(e);
|
|
860
|
-
|
|
1201
|
+
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`);
|
|
861
1202
|
},
|
|
862
|
-
readBody:
|
|
863
|
-
|
|
1203
|
+
readBody: maint || registry || hybrid
|
|
1204
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
|
|
1205
|
+
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1206
|
+
onUpdate: (r) => sink.row(r),
|
|
864
1207
|
});
|
|
865
|
-
|
|
1208
|
+
const report = ["\n" + formatTable(rows)];
|
|
866
1209
|
const pfLine = formatPrefilterSummary(rows);
|
|
867
|
-
if (pfLine)
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1210
|
+
if (pfLine) report.push(pfLine);
|
|
1211
|
+
let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
|
|
1212
|
+
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1213
|
+
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1214
|
+
if (waitLine) report.push(waitLine);
|
|
1215
|
+
let day = null;
|
|
1216
|
+
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1217
|
+
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
1218
|
+
if (sbxLine) report.push(sbxLine);
|
|
1219
|
+
if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
|
|
1220
|
+
if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
|
|
1221
|
+
report.push(formatReclaimed(reclaimed));
|
|
1222
|
+
for (const l of report) sink.line(l);
|
|
1223
|
+
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1224
|
+
try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1225
|
+
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** The batch report's "reclaimed" line (#4183). */
|
|
1229
|
+
export function formatReclaimed(r) {
|
|
1230
|
+
if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
|
|
1231
|
+
const b = r.bytes, size = b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`;
|
|
1232
|
+
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
871
1233
|
}
|
|
872
1234
|
|
|
873
1235
|
/**
|
|
874
|
-
*
|
|
875
|
-
|
|
1236
|
+
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1237
|
+
*/
|
|
1238
|
+
export async function scheddFromCli(argv, deps = {}) {
|
|
1239
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
1240
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
1241
|
+
if (argv[0] === "status") {
|
|
1242
|
+
let sock;
|
|
1243
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
1244
|
+
const st = await schedd.request(sock, { op: "status" });
|
|
1245
|
+
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}`);
|
|
1246
|
+
return { running: true, ...st };
|
|
1247
|
+
}
|
|
1248
|
+
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1249
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1250
|
+
if (!d.running) return d;
|
|
1251
|
+
for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => d.close());
|
|
1252
|
+
await d.closed;
|
|
1253
|
+
return { running: false };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
|
|
1258
|
+
* Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
|
|
1259
|
+
* shared by every batch on the host. `show` lists the active holds (identity, reset time, source; `*` marks the account
|
|
1260
|
+
* this shell's lanes would use); `clear` drops one hold or all of them, so held batches admit on their next pass.
|
|
1261
|
+
*/
|
|
1262
|
+
export async function limitsFromCli(argv, deps = {}) {
|
|
1263
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1264
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
|
|
1265
|
+
const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env)))();
|
|
1266
|
+
const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
|
|
1267
|
+
const [sub, arg] = argv;
|
|
1268
|
+
if (!sub || sub === "show") {
|
|
1269
|
+
const holds = limits.list();
|
|
1270
|
+
if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
|
|
1271
|
+
for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
|
|
1272
|
+
return { holds, current };
|
|
1273
|
+
}
|
|
1274
|
+
if (sub === "clear") {
|
|
1275
|
+
if (arg === "--all") { const n = limits.clearAll(); console.log(`cleared ${n} usage-limit hold(s)`); return { cleared: n }; }
|
|
1276
|
+
if (!arg || arg.startsWith("--")) throw new Error(usage);
|
|
1277
|
+
if (!limits.clear(arg)) throw new Error(`no active usage-limit hold for ${arg}`);
|
|
1278
|
+
console.log(`cleared usage-limit hold for ${arg}`);
|
|
1279
|
+
return { cleared: 1 };
|
|
1280
|
+
}
|
|
1281
|
+
throw new Error(usage);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
|
|
1286
|
+
* The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
|
|
1287
|
+
* `show` prints the cap, the repo weights, the running slots and the queued issues in grant order; `cap N` sets the
|
|
1288
|
+
* host-wide cap live (the next admission pass of every batch uses it); `cap --clear` falls back to each batch's
|
|
1289
|
+
* `orchestration.global_parallel`. `weight <owner/repo> N` sets that repo's share of slots within a priority (default 1);
|
|
1290
|
+
* `--clear` resets it to 1.
|
|
1291
|
+
*/
|
|
1292
|
+
export async function schedFromCli(argv, deps = {}) {
|
|
1293
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1294
|
+
const reg = deps.sched ?? schedMod.openSched(schedMod.schedDir());
|
|
1295
|
+
const usage = "usage: forgedock sched show | forgedock sched cap <N>|--clear | forgedock sched weight <owner/repo> <N>|--clear";
|
|
1296
|
+
const [sub, arg, arg2] = argv;
|
|
1297
|
+
if (!sub || sub === "show") {
|
|
1298
|
+
const st = reg.list();
|
|
1299
|
+
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
1300
|
+
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
1301
|
+
const ws = Object.entries(st.weights ?? {});
|
|
1302
|
+
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
1303
|
+
for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
|
|
1304
|
+
for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
|
|
1305
|
+
return st;
|
|
1306
|
+
}
|
|
1307
|
+
if (sub === "cap") {
|
|
1308
|
+
if (arg === "--clear") { reg.setCap(null); console.log("host cap cleared"); return { cap: null }; }
|
|
1309
|
+
const k = Number(arg);
|
|
1310
|
+
if (!/^\d+$/.test(String(arg ?? "")) || !Number.isInteger(k) || k < 1) throw new Error(usage);
|
|
1311
|
+
reg.setCap(k);
|
|
1312
|
+
console.log(`host cap set to ${k}`);
|
|
1313
|
+
return { cap: k };
|
|
1314
|
+
}
|
|
1315
|
+
if (sub === "weight") {
|
|
1316
|
+
if (!arg || arg.startsWith("-")) throw new Error(usage);
|
|
1317
|
+
if (arg2 === "--clear") { reg.setWeight(arg, null); console.log(`weight of ${arg} reset to 1`); return { repo: arg, weight: null }; }
|
|
1318
|
+
const n = Number(arg2);
|
|
1319
|
+
if (!/^\d+$/.test(String(arg2 ?? "")) || !Number.isInteger(n) || n < 1) throw new Error(usage);
|
|
1320
|
+
reg.setWeight(arg, n);
|
|
1321
|
+
console.log(`weight of ${arg} set to ${n}`);
|
|
1322
|
+
return { repo: arg, weight: n };
|
|
1323
|
+
}
|
|
1324
|
+
throw new Error(usage);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
1329
|
+
* `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
|
|
876
1330
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
877
1331
|
*/
|
|
878
1332
|
export function readOrchestration(cwd = process.cwd()) {
|
|
@@ -880,7 +1334,57 @@ export function readOrchestration(cwd = process.cwd()) {
|
|
|
880
1334
|
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
|
|
881
1335
|
const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
|
|
882
1336
|
const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
|
|
883
|
-
|
|
1337
|
+
const gp = int(o.global_parallel, null);
|
|
1338
|
+
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
|
|
1343
|
+
* only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
|
|
1344
|
+
* only: no script run, no issue, no hold). The parser flattens `orchestrate.drift.*` to `orchestrate.*`.
|
|
1345
|
+
*/
|
|
1346
|
+
export function readDrift(cwd = process.cwd()) {
|
|
1347
|
+
let o = null;
|
|
1348
|
+
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestrate ?? null; } catch { o = null; }
|
|
1349
|
+
if (!o || typeof o !== "object") return null;
|
|
1350
|
+
const int = (v) => { const n = Number(v); return v !== undefined && String(v).trim() !== "" && Number.isInteger(n) && n > 0 ? n : null; };
|
|
1351
|
+
const out = { pairGrowth: int(o.pair_growth), packageLines: int(o.package_lines), fileLines: int(o.file_lines),
|
|
1352
|
+
blockDispatch: String(o.block_dispatch ?? "").trim() === "true",
|
|
1353
|
+
generatedGlobs: String(o.generated_globs ?? "").split(",").map((x) => x.trim()).filter(Boolean) };
|
|
1354
|
+
return out.pairGrowth == null && out.packageLines == null && out.fileLines == null && !out.blockDispatch ? null : out;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* forge.yaml text without the `maintenance.hotspot:` sub-map (forge#4195, read by scripts/hotspots.sh via yq). The
|
|
1359
|
+
* parser flattens one nested level, so its `enabled`/`file_lines` would otherwise overwrite the budget's own keys.
|
|
1360
|
+
*/
|
|
1361
|
+
function withoutHotspotMap(raw) {
|
|
1362
|
+
let inMaint = false, sub = -1;
|
|
1363
|
+
return String(raw).split(/\r?\n/).filter((l) => {
|
|
1364
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(l)) { inMaint = /^maintenance:\s*(?:#.*)?$/.test(l); sub = -1; return true; }
|
|
1365
|
+
const indent = /^\s*/.exec(l)[0].length;
|
|
1366
|
+
if (sub >= 0) { if (/^\s*(?:#.*)?$/.test(l) || indent > sub) return false; sub = -1; }
|
|
1367
|
+
if (inMaint && /^\s+hotspot:\s*(?:#.*)?$/.test(l)) { sub = indent; return false; }
|
|
1368
|
+
return true;
|
|
1369
|
+
}).join("\n");
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export const DEFAULT_MAINTENANCE_LABELS = ["maintenance", "refactor:split", "dead-code"];
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* forge.yaml `maintenance:` (#4197): null unless `enabled: true`. `share` in (0,1] (default 0.2), `when_idle` (default
|
|
1376
|
+
* true), `labels` comma-separated (default maintenance,refactor:split,dead-code), `scan` (default weekly), and the
|
|
1377
|
+
* maintenance-scan thresholds `file_lines` (2000) / `doc_kb` (64). Block form only: the parser does not read `{ ... }`.
|
|
1378
|
+
*/
|
|
1379
|
+
export function readMaintenance(cwd = process.cwd()) {
|
|
1380
|
+
let m = null;
|
|
1381
|
+
try { m = parseForgeYaml(withoutHotspotMap(readFileSync(join(cwd, "forge.yaml"), "utf8")))?.maintenance ?? null; } catch { m = null; }
|
|
1382
|
+
if (!m || typeof m !== "object" || String(m.enabled ?? "").trim() !== "true") return null;
|
|
1383
|
+
const num = (v, d, ok) => { const n = Number(v); return v !== undefined && v !== "" && ok(n) ? n : d; };
|
|
1384
|
+
const labels = String(m.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1385
|
+
return { share: num(m.share, 0.2, (n) => n > 0 && n <= 1), whenIdle: String(m.when_idle ?? "true").trim() !== "false",
|
|
1386
|
+
labels: labels.length ? labels : [...DEFAULT_MAINTENANCE_LABELS], scan: String(m.scan ?? "weekly").trim() || "weekly",
|
|
1387
|
+
fileLines: num(m.file_lines, 2000, (n) => Number.isInteger(n) && n > 0), docKb: num(m.doc_kb, 64, (n) => n > 0) };
|
|
884
1388
|
}
|
|
885
1389
|
|
|
886
1390
|
/** `--prefilter-model M`, else forge.yaml `engine.lean_prefilter_model`, else the pre-filter default. */
|