forgedock 1.35.0 → 1.37.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 +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/engine-cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `forgedock resume-stalled [--dry-run] [--lane <lane>]` enumerates all in-flight
|
|
5
5
|
* issues, calls scanStalls, and re-dispatches each stalled issue via run-issue.
|
|
6
6
|
*/
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
@@ -16,6 +16,8 @@ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from ".
|
|
|
16
16
|
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
17
17
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
|
+
import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
|
|
20
|
+
import { readExperiment } from "./experiment.mjs";
|
|
19
21
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
22
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
23
|
import { parseExecution, detectNeeds } from "./lean/execution.mjs";
|
|
@@ -123,6 +125,31 @@ export function readLaneInstructions(cwd = process.cwd()) {
|
|
|
123
125
|
}
|
|
124
126
|
const warnedContextPack = new Set();
|
|
125
127
|
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
128
|
+
/** Run each emitter in order; one failing never blocks the next (#4630). */
|
|
129
|
+
function composeEmit(...fns) { return async (rec) => { for (const f of fns) { try { await f(rec); } catch { /* telemetry and events never fail a run */ } } }; }
|
|
130
|
+
/**
|
|
131
|
+
* forge.yaml `events:` (#4630): `webhook_url`, `webhook_secret_env` (the NAME of the env var holding the HMAC secret; the secret itself is
|
|
132
|
+
* never read from forge.yaml) and `dir` (local JSONL directory). Returns {webhookUrl, secretEnv, dir}; {} when none is set.
|
|
133
|
+
*/
|
|
134
|
+
export function readEvents(cwd = process.cwd()) {
|
|
135
|
+
try {
|
|
136
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.events ?? {};
|
|
137
|
+
const v = (k) => String(e[k] ?? "").trim();
|
|
138
|
+
return { ...(v("webhook_url") ? { webhookUrl: v("webhook_url") } : {}), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(v("webhook_secret_env")) ? { secretEnv: v("webhook_secret_env") } : {}), ...(v("dir") ? { dir: v("dir") } : {}) };
|
|
139
|
+
} catch { return {}; }
|
|
140
|
+
}
|
|
141
|
+
/** The probe sweep's `onResult`: one probe.passed / probe.failed event per recorded pass or fail row. Never throws. */
|
|
142
|
+
function probeEventSink(repo) {
|
|
143
|
+
const events = makeEventEmitter({ repo, config: readEvents() });
|
|
144
|
+
return async (row) => { for (const p of probeSweepEvents(row)) await events.emitEvent(p.type, p.data, p.ctx); };
|
|
145
|
+
}
|
|
146
|
+
/** `forgedock stats --experiment <id> --emit-event` (#4632): one experiment.verdict event for a met or missed report; inconclusive emits nothing. Never throws. */
|
|
147
|
+
export async function experimentEventSink(repo, report) {
|
|
148
|
+
try {
|
|
149
|
+
const p = experimentEvent(report);
|
|
150
|
+
if (p && p.data.verdict !== "inconclusive") await makeEventEmitter({ repo, config: readEvents() }).emitEvent(p.type, p.data, p.ctx);
|
|
151
|
+
} catch { /* events never fail stats */ }
|
|
152
|
+
}
|
|
126
153
|
/**
|
|
127
154
|
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
128
155
|
* (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
@@ -235,6 +262,10 @@ export function readBaseMerge(cwd = process.cwd()) {
|
|
|
235
262
|
export function readSequenceRegenerate(cwd = process.cwd()) {
|
|
236
263
|
try { const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; return String(l.sequence_regenerate ?? l.regenerate ?? "").trim() || undefined; } catch { return undefined; }
|
|
237
264
|
}
|
|
265
|
+
/** #4664: forge.yaml `followups.default_milestone` (milestone title for deferred follow-ups whose parent's milestone has shipped), or undefined. */
|
|
266
|
+
export function readFollowupsDefaultMilestone(cwd = process.cwd()) {
|
|
267
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.default_milestone ?? "").trim() || undefined; } catch { return undefined; }
|
|
268
|
+
}
|
|
238
269
|
/** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
|
|
239
270
|
export function readPreconditionCmd(cwd = process.cwd()) {
|
|
240
271
|
try {
|
|
@@ -249,6 +280,10 @@ export async function readPostDeployMinAge(cwd = process.cwd()) {
|
|
|
249
280
|
return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
|
|
250
281
|
} catch { return null; }
|
|
251
282
|
}
|
|
283
|
+
/** forge.yaml `milestones.close_on` (#4657): `staging` or `default` (the default). */
|
|
284
|
+
export function readMilestoneCloseOn(cwd = process.cwd()) {
|
|
285
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.close_on ?? "").trim().toLowerCase() === "staging" ? "staging" : "default"; } catch { return "default"; }
|
|
286
|
+
}
|
|
252
287
|
/** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
|
|
253
288
|
export function readExplore(cwd = process.cwd()) {
|
|
254
289
|
try {
|
|
@@ -300,7 +335,7 @@ export function readHeavy(cwd = process.cwd()) {
|
|
|
300
335
|
/**
|
|
301
336
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
302
337
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
303
|
-
* `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
|
|
338
|
+
* `lane_limits_memory_max`/`lane_limits_cpu_quota`), plus `tasks_max` (#4690, default 1024). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
|
|
304
339
|
* `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
|
|
305
340
|
* #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
|
|
306
341
|
* #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
|
|
@@ -309,17 +344,18 @@ export function readCoreLimits(cwd = process.cwd()) {
|
|
|
309
344
|
try {
|
|
310
345
|
const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {};
|
|
311
346
|
const pick = (k) => { for (const sec of [y.pipeline, y.engine]) { const v = sec?.[k] ?? sec?.[`lane_limits_${k}`]; if (v !== undefined && v !== "") return String(v).trim(); } return undefined; };
|
|
312
|
-
const mem = pick("memory_max"), cpu = pick("cpu_quota");
|
|
347
|
+
const mem = pick("memory_max"), cpu = pick("cpu_quota"), tasks = pick("tasks_max");
|
|
313
348
|
const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : undefined; };
|
|
314
349
|
return {
|
|
315
|
-
|
|
350
|
+
// #4690: lanes get TasksMax by default; MemoryMax is opt-in (a default K-derived cap killed healthy lanes, #4740); `off`/0 disables one limit
|
|
351
|
+
laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}), ...(tasks ? { tasks_max: tasks } : {}) },
|
|
316
352
|
...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
|
|
317
353
|
...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
|
|
318
354
|
...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
|
|
319
355
|
...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
|
|
320
356
|
turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
|
|
321
357
|
};
|
|
322
|
-
} catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
|
|
358
|
+
} catch { return { laneLimits: {}, turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
|
|
323
359
|
}
|
|
324
360
|
/** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
|
|
325
361
|
export function readMergeWait(cwd = process.cwd()) {
|
|
@@ -876,8 +912,8 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
876
912
|
try {
|
|
877
913
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
878
914
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
879
|
-
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
880
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
|
|
915
|
+
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
916
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
881
917
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
882
918
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
883
919
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1075,6 +1111,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
1075
1111
|
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
1076
1112
|
* `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
|
|
1077
1113
|
* (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
|
|
1114
|
+
* `--requeue <id> N... [--reason]` (#4550) returns a terminal issue (and the dependents skipped behind it) to pending after an operator decision.
|
|
1078
1115
|
* `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
|
|
1079
1116
|
* SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
|
|
1080
1117
|
*/
|
|
@@ -1085,11 +1122,49 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
1085
1122
|
return orchestrateClient(argv, deps);
|
|
1086
1123
|
}
|
|
1087
1124
|
|
|
1088
|
-
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
1125
|
+
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
1089
1126
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
1090
1127
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1128
|
+
/** forge.yaml `orchestration.env_passthrough` / `path_exclude` (comma-separated) and `execution.token_env` (#4691): what the pinned env keeps beyond the allowlist. */
|
|
1129
|
+
function readPinnedEnvConfig(cwd) {
|
|
1130
|
+
try {
|
|
1131
|
+
const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"));
|
|
1132
|
+
const list = (v) => String(v ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1133
|
+
const tok = String(y?.execution?.token_env ?? "").trim();
|
|
1134
|
+
return { passthrough: [...list(y?.orchestration?.env_passthrough), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(tok) ? [tok] : [])], pathExclude: list(y?.orchestration?.path_exclude) };
|
|
1135
|
+
} catch { return { passthrough: [], pathExclude: [] }; }
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** `--show-env <id>` (secrets redacted), `--set-env <id> KEY=VAL`, `--unset-env <id> KEY` (#4691): edits the batch's pinned env in plan.json; newly launched lanes read it. */
|
|
1139
|
+
async function envBatch({ argv, root, print = (l) => console.log(l) }) {
|
|
1140
|
+
const op = ["--show-env", "--set-env", "--unset-env"].find((f) => argv.includes(f));
|
|
1141
|
+
const i = argv.indexOf(op), id = argv[i + 1], arg = argv[i + 2];
|
|
1142
|
+
if (!id || id.startsWith("--") || (op !== "--show-env" && !arg)) throw new Error(ORCH_USAGE);
|
|
1143
|
+
const batchMod = await import("./lean/batch.mjs"), { redactEnv } = await import("./lean/schedd.mjs");
|
|
1144
|
+
const b = batchMod.openBatch(root, id);
|
|
1145
|
+
const plan = b.readPlan();
|
|
1146
|
+
if (!plan) throw new Error(`no batch ${id} in ${root}`);
|
|
1147
|
+
if (op === "--show-env") {
|
|
1148
|
+
const env = redactEnv(plan.env);
|
|
1149
|
+
print(plan.env ? Object.entries(env).sort(([a], [c]) => (a < c ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join("\n") : `batch ${id}: pins no env (its lanes use the daemon's)`);
|
|
1150
|
+
return { ok: true, batch: id, env };
|
|
1151
|
+
}
|
|
1152
|
+
const env = { ...(plan.env ?? {}) };
|
|
1153
|
+
if (op === "--set-env") {
|
|
1154
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(arg);
|
|
1155
|
+
if (!m) throw new Error("--set-env needs KEY=VAL");
|
|
1156
|
+
env[m[1]] = m[2];
|
|
1157
|
+
print(`batch ${id}: set ${m[1]} (applies to newly launched lanes)`);
|
|
1158
|
+
} else {
|
|
1159
|
+
delete env[arg];
|
|
1160
|
+
print(`batch ${id}: unset ${arg} (applies to newly launched lanes)`);
|
|
1161
|
+
}
|
|
1162
|
+
b.writePlan({ ...plan, env });
|
|
1163
|
+
return { ok: true, batch: id };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
|
|
1167
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force"];
|
|
1093
1168
|
const issuesOf = (argv) => {
|
|
1094
1169
|
const issues = [];
|
|
1095
1170
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1139,10 +1214,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1139
1214
|
if (st === "OPEN") open.push(n);
|
|
1140
1215
|
else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
|
|
1141
1216
|
}
|
|
1142
|
-
let res = { added: [], skipped: [] };
|
|
1143
|
-
if (open.length)
|
|
1144
|
-
|
|
1145
|
-
|
|
1217
|
+
let res = { added: [], skipped: [], requeued: [] };
|
|
1218
|
+
if (open.length) {
|
|
1219
|
+
res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
|
|
1220
|
+
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
|
|
1221
|
+
}
|
|
1222
|
+
const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1223
|
+
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(", ")}` : ""}`);
|
|
1146
1224
|
return out;
|
|
1147
1225
|
}
|
|
1148
1226
|
|
|
@@ -1169,20 +1247,55 @@ async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) =
|
|
|
1169
1247
|
return { ok: true, batch: id, changed: res.changed ?? [] };
|
|
1170
1248
|
}
|
|
1171
1249
|
|
|
1250
|
+
/**
|
|
1251
|
+
* `--requeue <id> N... [--reason "..."]` (#4550): after an operator decision, a terminal issue of the batch (and the dependents
|
|
1252
|
+
* skipped behind it) goes back to pending. Closed issues and issues still labelled `needs-human` are refused before anything is
|
|
1253
|
+
* written. Through the daemon (`viaDaemon`, null when none runs: the batch's `requeue` file, and a finished batch's report is
|
|
1254
|
+
* cleared so `--resume` runs it again). The runner applies the entries and posts the FORGE:DECISION.
|
|
1255
|
+
*/
|
|
1256
|
+
async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1257
|
+
if (!id || String(id).startsWith("--") || !tokens.length) throw new Error(ORCH_USAGE);
|
|
1258
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
1259
|
+
const b = batchMod.openBatch(root, id);
|
|
1260
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1261
|
+
const repo = b.readPlan()?.repo ?? null;
|
|
1262
|
+
const refused = [], open = [];
|
|
1263
|
+
for (const t of tokens) {
|
|
1264
|
+
if (!/^#?\d+$/.test(String(t))) { refused.push({ issue: t, reason: "not an integer" }); continue; }
|
|
1265
|
+
const n = parseInt(String(t).replace("#", ""), 10);
|
|
1266
|
+
if (open.includes(n) || refused.some((x) => x.issue === n)) continue;
|
|
1267
|
+
let v = null;
|
|
1268
|
+
try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,labels"])); } catch { v = null; }
|
|
1269
|
+
if (!v) refused.push({ issue: n, reason: "unreadable" });
|
|
1270
|
+
else if (v.state !== "OPEN") refused.push({ issue: n, reason: "closed" });
|
|
1271
|
+
else if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) refused.push({ issue: n, reason: "still labelled needs-human: record the decision and remove the label first" });
|
|
1272
|
+
else open.push(n);
|
|
1273
|
+
}
|
|
1274
|
+
const why = reason || "operator decision", by = "operator";
|
|
1275
|
+
let res = { requeued: [], refused: [] };
|
|
1276
|
+
if (open.length) {
|
|
1277
|
+
res = (viaDaemon ? await viaDaemon(open, why, by) : null) ?? b.requeue(open, { reason: why, by });
|
|
1278
|
+
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
|
|
1279
|
+
}
|
|
1280
|
+
const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
|
|
1281
|
+
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(", ")}` : ""}`);
|
|
1282
|
+
return out;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1172
1285
|
/**
|
|
1173
1286
|
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1174
1287
|
* A finished batch (report written) is left alone.
|
|
1175
1288
|
*/
|
|
1176
|
-
async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1289
|
+
async function cancelBatch({ id, root, stopRemote = false, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1177
1290
|
if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
|
|
1178
1291
|
let res = viaDaemon ? await viaDaemon() : null;
|
|
1179
1292
|
if (!res) {
|
|
1180
1293
|
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1181
1294
|
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1182
1295
|
if (b.readReport()) res = { cancelled: false, finished: true };
|
|
1183
|
-
else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
|
|
1296
|
+
else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
|
|
1184
1297
|
}
|
|
1185
|
-
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1298
|
+
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown${stopRemote ? " (remote sandboxes too, #4612)" : ""}, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1186
1299
|
: `batch ${id}: already finished`);
|
|
1187
1300
|
return { ok: true, batch: id, cancelled: !!res.cancelled };
|
|
1188
1301
|
}
|
|
@@ -1218,20 +1331,31 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1218
1331
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1219
1332
|
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1220
1333
|
};
|
|
1334
|
+
const requeueVia = (id) => async (issues, reason, by) => {
|
|
1335
|
+
let sock = null;
|
|
1336
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1337
|
+
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
|
|
1338
|
+
};
|
|
1221
1339
|
const holdVia = (id) => async (op, issues) => {
|
|
1222
1340
|
let sock = null;
|
|
1223
1341
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1224
1342
|
return sock ? schedd.request(sock, { op, id, issues }) : null;
|
|
1225
1343
|
};
|
|
1226
1344
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1345
|
+
if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
|
|
1346
|
+
if (argv.includes("--clear-breaker")) return runBatch({ argv, deps, sink: consoleSink() });
|
|
1227
1347
|
if (argv.includes("--cancel")) {
|
|
1228
1348
|
const id = flag(argv, "--cancel");
|
|
1229
|
-
return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
|
|
1349
|
+
return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
|
|
1230
1350
|
let sock = null;
|
|
1231
1351
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1232
|
-
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1352
|
+
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
|
|
1233
1353
|
} });
|
|
1234
1354
|
}
|
|
1355
|
+
if (argv.includes("--requeue")) {
|
|
1356
|
+
const i = argv.indexOf("--requeue"), id = argv[i + 1];
|
|
1357
|
+
return requeueBatch({ id, tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: requeueVia(id) });
|
|
1358
|
+
}
|
|
1235
1359
|
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1236
1360
|
const id = flag(argv, `--${op}`);
|
|
1237
1361
|
return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
|
|
@@ -1258,7 +1382,10 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1258
1382
|
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
1259
1383
|
sub = [...sub, "--repo", repo];
|
|
1260
1384
|
}
|
|
1261
|
-
|
|
1385
|
+
const pinned = schedd.batchEnv(process.env, readPinnedEnvConfig(process.cwd()));
|
|
1386
|
+
const dropped = schedd.droppedEnv(process.env, pinned);
|
|
1387
|
+
if (dropped.length) console.log(`orchestrate: not pinned into the batch (lanes use the daemon's env or resolve them at lane start): ${dropped.join(", ")}; name them in forge.yaml orchestration.env_passthrough to pin them`);
|
|
1388
|
+
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: pinned };
|
|
1262
1389
|
}
|
|
1263
1390
|
let sock = await connect(), req = msg, reattached = false, losses = 0;
|
|
1264
1391
|
let id = resumeId;
|
|
@@ -1301,6 +1428,15 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1301
1428
|
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
|
|
1302
1429
|
const batchMod = await import("./lean/batch.mjs");
|
|
1303
1430
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
1431
|
+
if (argv.includes("--clear-breaker")) {
|
|
1432
|
+
// #4685: close a tripped breaker without editing state.json; the next --resume (or a running batch's next save) starts closed.
|
|
1433
|
+
const b = batchMod.openBatch(root, flag(argv, "--clear-breaker"));
|
|
1434
|
+
if (!b.exists()) throw new Error(`no batch ${flag(argv, "--clear-breaker")} in ${root}`);
|
|
1435
|
+
const st = b.store.load();
|
|
1436
|
+
if (st) b.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1437
|
+
sink.line(`batch ${b.id}: ${st?.breaker ? "breaker cleared" : "no breaker was tripped"}`);
|
|
1438
|
+
return { ok: true, batch: b.id, cleared: Boolean(st?.breaker) };
|
|
1439
|
+
}
|
|
1304
1440
|
if (argv.includes("--set-parallel")) {
|
|
1305
1441
|
const i = argv.indexOf("--set-parallel");
|
|
1306
1442
|
const b = batchMod.openBatch(root, argv[i + 1]);
|
|
@@ -1310,7 +1446,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1310
1446
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1311
1447
|
return { ok: true, batch: b.id, parallel: k };
|
|
1312
1448
|
}
|
|
1313
|
-
if (argv.includes(
|
|
1449
|
+
if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
|
|
1450
|
+
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
|
|
1451
|
+
if (argv.includes("--requeue")) {
|
|
1452
|
+
const i = argv.indexOf("--requeue");
|
|
1453
|
+
return requeueBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1454
|
+
}
|
|
1314
1455
|
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1315
1456
|
return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
|
|
1316
1457
|
}
|
|
@@ -1337,8 +1478,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1337
1478
|
const parallel = parallelOf(argv);
|
|
1338
1479
|
// The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
|
|
1339
1480
|
// A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
|
|
1340
|
-
|
|
1341
|
-
const
|
|
1481
|
+
// #4691: a fresh pin is allowlisted and PATH-sanitised again here (idempotent for a client that already did it).
|
|
1482
|
+
const scheddMod = await import("./lean/schedd.mjs");
|
|
1483
|
+
const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? scheddMod.batchEnv(callerEnv, readPinnedEnvConfig(plan?.cwd ?? callerCwd)) : null);
|
|
1484
|
+
const env = kept ? scheddMod.mergeEnv(process.env, kept) : process.env;
|
|
1342
1485
|
const lane = flag(argv, "--lane");
|
|
1343
1486
|
const laneOverride = argv.includes("--lane-override");
|
|
1344
1487
|
const model = flag(argv, "--model");
|
|
@@ -1349,16 +1492,29 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1349
1492
|
await assertLaneNotDefault(io, lane, repo, cwd);
|
|
1350
1493
|
if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
|
|
1351
1494
|
if (!batch) {
|
|
1495
|
+
// #4659: a milestone start is refused unless every dependency of its issues is merged into its lane, or --force <reason>
|
|
1496
|
+
const force = flag(argv, "--force");
|
|
1497
|
+
if (argv.includes("--force") && (!force || force.startsWith("--"))) throw new Error("--force requires a reason: --force \"<why the unmet dependencies are acceptable>\"");
|
|
1498
|
+
if (!force) {
|
|
1499
|
+
const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
|
|
1500
|
+
const gateBranches = readBranches(cwd);
|
|
1501
|
+
const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1502
|
+
const unmet = await unmetDependencies({ issues, repo, gh: io.gh, baseOf: async (n) => {
|
|
1503
|
+
const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
|
|
1504
|
+
return r.ok ? r.lane : null;
|
|
1505
|
+
} });
|
|
1506
|
+
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>".`);
|
|
1507
|
+
}
|
|
1352
1508
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
1353
1509
|
batch = batchMod.openBatch(root, base);
|
|
1354
1510
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
1355
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1511
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1356
1512
|
batch.writePlan(plan);
|
|
1357
1513
|
batch.setParallel(parallel);
|
|
1358
1514
|
} else {
|
|
1359
1515
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
1360
1516
|
const st = batch.store.load();
|
|
1361
|
-
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1517
|
+
if (st && (!keepBreaker || st.breaker?.transport)) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1362
1518
|
if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
|
|
1363
1519
|
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1364
1520
|
batch.clearReport();
|
|
@@ -1376,7 +1532,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1376
1532
|
// #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
|
|
1377
1533
|
const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
|
|
1378
1534
|
if (sweep && !argv.includes("--no-probes")) {
|
|
1379
|
-
try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
1535
|
+
try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo, onResult: probeEventSink(repo) })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
1380
1536
|
}
|
|
1381
1537
|
const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1382
1538
|
const branches = readBranches(cwd);
|
|
@@ -1400,9 +1556,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1400
1556
|
const { runCore } = deps.leanModule;
|
|
1401
1557
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1402
1558
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1403
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
|
|
1559
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
|
|
1404
1560
|
} else {
|
|
1405
1561
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1562
|
+
// #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
|
|
1563
|
+
const laneEnv = { ...env, FORGEDOCK_LANE_PARALLEL: String(parallel) };
|
|
1564
|
+
// #4691: --set-env / --unset-env edit plan.json while the batch runs; each newly launched lane re-reads the pinned env.
|
|
1565
|
+
const laneSpawn = (bin, args, o) => {
|
|
1566
|
+
const p = batch.readPlan();
|
|
1567
|
+
const fresh = p?.env ? { ...scheddMod.mergeEnv(process.env, p.env), FORGEDOCK_LANE_PARALLEL: String(parallel) } : null;
|
|
1568
|
+
return (deps.spawnFn ?? spawn)(bin, args, fresh ? { ...o, env: { ...fresh, FORGEDOCK_BATCH_ID: o.env?.FORGEDOCK_BATCH_ID } } : o);
|
|
1569
|
+
};
|
|
1570
|
+
const effects = await import("./lean/effects.mjs");
|
|
1571
|
+
const uncapped = effects.laneCapNotice(deps.laneCapProbe ?? effects.systemdRunAvailable);
|
|
1572
|
+
if (uncapped) (deps.out ?? ((l) => console.log(l)))(uncapped);
|
|
1406
1573
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1407
1574
|
// #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
|
|
1408
1575
|
const placementOf = new Map(), recoverOf = new Map();
|
|
@@ -1417,6 +1584,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1417
1584
|
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1418
1585
|
execForNeeds = exec ?? null;
|
|
1419
1586
|
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1587
|
+
let remoteStop = null; // #4612: the sandbox provider's checkpoint stop, used only when the cancel asks for it
|
|
1420
1588
|
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1421
1589
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1422
1590
|
let gitDir = null;
|
|
@@ -1426,11 +1594,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1426
1594
|
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1427
1595
|
const bud = exec.budget ?? {};
|
|
1428
1596
|
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1597
|
+
...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
|
|
1429
1598
|
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1599
|
+
remoteStop = remoteLanes.stopLane;
|
|
1430
1600
|
lanes = withPlacement(remoteLanes);
|
|
1431
1601
|
{
|
|
1432
1602
|
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
1433
|
-
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1603
|
+
const local = batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn });
|
|
1434
1604
|
lanes = withPlacement({
|
|
1435
1605
|
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1436
1606
|
adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
|
|
@@ -1470,11 +1640,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1470
1640
|
};
|
|
1471
1641
|
sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
|
|
1472
1642
|
} else {
|
|
1473
|
-
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1643
|
+
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
|
|
1474
1644
|
}
|
|
1475
|
-
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
|
|
1645
|
+
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
|
|
1476
1646
|
const { killLedgerPid } = await import("./lean/effects.mjs");
|
|
1477
|
-
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.
|
|
1647
|
+
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
|
|
1478
1648
|
lanes = { ...lanes, stopLane };
|
|
1479
1649
|
}
|
|
1480
1650
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
@@ -1519,6 +1689,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1519
1689
|
drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
|
|
1520
1690
|
onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
|
|
1521
1691
|
}
|
|
1692
|
+
let lifecycle = deps.lifecycle; // forge#4657: milestone lifecycle; closes at `milestones.close_on`
|
|
1693
|
+
if (lifecycle === undefined) {
|
|
1694
|
+
const { makeLifecycle } = await import("./lean/lifecycle.mjs");
|
|
1695
|
+
lifecycle = makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: branches.default ?? "main" });
|
|
1696
|
+
}
|
|
1697
|
+
// forge#4667: milestone integration runs (every N lane merges) and the red-streak admission hold; off without forge.yaml ci.workflow.
|
|
1698
|
+
let integration = deps.integration;
|
|
1699
|
+
if (integration === undefined) {
|
|
1700
|
+
const { makeIntegration } = await import("./lean/integration.mjs");
|
|
1701
|
+
integration = makeIntegration({ repo, io: leanIo, cfg: await readIntegration(cwd), pattern: branches.featurePattern ?? undefined,
|
|
1702
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)), onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
|
|
1703
|
+
try { await integration?.prime(); } catch { /* streaks unknown: nothing is held */ }
|
|
1704
|
+
}
|
|
1522
1705
|
const crossLane = [];
|
|
1523
1706
|
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1524
1707
|
const gcMod = await import("./gc.mjs");
|
|
@@ -1580,8 +1763,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1580
1763
|
rows = await orchestrate({
|
|
1581
1764
|
...(estimate ? { estimate } : {}),
|
|
1582
1765
|
beforeAdmit,
|
|
1583
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
|
|
1584
|
-
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1766
|
+
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,
|
|
1767
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1585
1768
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1586
1769
|
...(remote ? { remote } : {}),
|
|
1587
1770
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
@@ -1625,6 +1808,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1625
1808
|
onUpdate: (r) => sink.row(r),
|
|
1626
1809
|
});
|
|
1627
1810
|
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
1811
|
+
try { await integration?.settle?.(); } catch { /* best effort: the state block is the record */ }
|
|
1628
1812
|
const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
|
|
1629
1813
|
const pfLine = formatPrefilterSummary(rows);
|
|
1630
1814
|
if (pfLine) report.push(pfLine);
|
|
@@ -1682,8 +1866,9 @@ export function makeDispatchCheck({ gh, repo }) {
|
|
|
1682
1866
|
} catch { return null; }
|
|
1683
1867
|
};
|
|
1684
1868
|
return async (n) => {
|
|
1685
|
-
const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
|
|
1869
|
+
const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"]);
|
|
1686
1870
|
const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
|
|
1871
|
+
const notPlanned = String(iss?.stateReason ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
|
|
1687
1872
|
const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
|
|
1688
1873
|
const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
|
|
1689
1874
|
if (pr !== null && state === "OPEN") {
|
|
@@ -1692,7 +1877,7 @@ export function makeDispatchCheck({ gh, repo }) {
|
|
|
1692
1877
|
if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
|
|
1693
1878
|
}
|
|
1694
1879
|
if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
|
|
1695
|
-
if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
|
|
1880
|
+
if (state === "CLOSED") return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
|
|
1696
1881
|
return null;
|
|
1697
1882
|
};
|
|
1698
1883
|
}
|
|
@@ -1703,7 +1888,7 @@ export function makeDispatchCheck({ gh, repo }) {
|
|
|
1703
1888
|
* resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
|
|
1704
1889
|
*/
|
|
1705
1890
|
export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
|
|
1706
|
-
return async ({ issue, evidence }) => {
|
|
1891
|
+
return async ({ issue, evidence, parked = null }) => {
|
|
1707
1892
|
const { stateOf } = await import("./lean/context.mjs");
|
|
1708
1893
|
try {
|
|
1709
1894
|
const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
|
|
@@ -1711,13 +1896,28 @@ export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
|
|
|
1711
1896
|
const lease = stateOf(v?.body)?.lease;
|
|
1712
1897
|
if (lease && Number(lease.until) > now()) return "skipped";
|
|
1713
1898
|
} catch { /* unreadable: relabel as before */ }
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
await gh(["issue", "
|
|
1719
|
-
|
|
1720
|
-
|
|
1899
|
+
// #4656: a mechanical park carries its code and PR on `parked:<code>` and joins the remediation queue, not the human queue.
|
|
1900
|
+
if (parked?.code) {
|
|
1901
|
+
const { parkedLabel, REMEDIATION_QUEUE_LABEL } = await import("./engine/outcome-codes.mjs");
|
|
1902
|
+
try { await gh(["label", "create", parkedLabel(parked.code), "-R", repo, "--force", "--color", "C5DEF5", "--description", "Mechanical park on a terminal code. Managed by ForgeDock."]); } catch { /* exists, or no rights: the edit says */ }
|
|
1903
|
+
try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `${parkedLabel(parked.code)},${REMEDIATION_QUEUE_LABEL}`, "--remove-label", "needs-human"]); } catch { return "failed"; }
|
|
1904
|
+
try {
|
|
1905
|
+
const { capComment } = await import("./lean/render.mjs");
|
|
1906
|
+
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:PARKED code=${parked.code}${parked.pr ? ` pr=${parked.pr}` : ""} -->\n## Parked (\`${parked.code}\`)\nCode: \`${parked.code}\`\nPR: ${parked.pr ? `#${parked.pr}` : "none"}\nQueue: \`${REMEDIATION_QUEUE_LABEL}\` (\`/work-on:remediate\`)\n${evidence}`)]);
|
|
1907
|
+
} catch { /* best effort */ }
|
|
1908
|
+
return true;
|
|
1909
|
+
}
|
|
1910
|
+
// #4656: a human-owned wait is `needs-human:external` and its comment names the owner and the unblock condition.
|
|
1911
|
+
{
|
|
1912
|
+
const external = { owner: "", unblock: "" }; // the default non-parked relabel is a human-owned wait
|
|
1913
|
+
const { NEEDS_HUMAN_EXTERNAL_LABEL } = await import("./engine/outcome-codes.mjs");
|
|
1914
|
+
try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `needs-human,${NEEDS_HUMAN_EXTERNAL_LABEL}`]); } catch { return "failed"; }
|
|
1915
|
+
try {
|
|
1916
|
+
const { capComment } = await import("./lean/render.mjs");
|
|
1917
|
+
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN external=true -->\n## Needs human (external)\nOwner: ${external.owner || "unassigned (name an owner)"}\nUnblock condition: ${external.unblock || "unstated (name the condition that unblocks this issue)"}\n${evidence}`)]);
|
|
1918
|
+
} catch { /* best effort */ }
|
|
1919
|
+
return true;
|
|
1920
|
+
}
|
|
1721
1921
|
};
|
|
1722
1922
|
}
|
|
1723
1923
|
|
|
@@ -1728,6 +1928,26 @@ export function formatReclaimed(r) {
|
|
|
1728
1928
|
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
1729
1929
|
}
|
|
1730
1930
|
|
|
1931
|
+
/** #4627: the daemon's timer callback: re-run the due windowed post-deploy checks of the cwd repo. No repo, no work. */
|
|
1932
|
+
async function scheddDue() {
|
|
1933
|
+
const io = makeIo();
|
|
1934
|
+
const repo = await resolveDefaultRepo(io);
|
|
1935
|
+
if (!repo) return;
|
|
1936
|
+
const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
|
|
1937
|
+
const leanIo = (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1938
|
+
await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
|
|
1939
|
+
await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
|
|
1943
|
+
async function sweepParkedFollowups({ repo, io, dryRun = false }) {
|
|
1944
|
+
try {
|
|
1945
|
+
const { sweepParked } = await import("./lean/parked-sweep.mjs");
|
|
1946
|
+
const branches = readBranches();
|
|
1947
|
+
return await sweepParked({ repo, cwd: process.cwd(), io, dryRun, defaultBranch: (await import("./lean/prefilter.mjs")).lazyDefaultBranch({ io, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() });
|
|
1948
|
+
} catch { return []; }
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1731
1951
|
/**
|
|
1732
1952
|
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1733
1953
|
*/
|
|
@@ -1742,7 +1962,8 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1742
1962
|
return { running: true, ...st };
|
|
1743
1963
|
}
|
|
1744
1964
|
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1745
|
-
|
|
1965
|
+
process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
1966
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
|
|
1746
1967
|
if (!d.running) return d;
|
|
1747
1968
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
1748
1969
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
@@ -1788,6 +2009,41 @@ export async function limitsFromCli(argv, deps = {}) {
|
|
|
1788
2009
|
throw new Error(usage);
|
|
1789
2010
|
}
|
|
1790
2011
|
|
|
2012
|
+
/**
|
|
2013
|
+
* `forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]` (#4695): the anchored decisions of the
|
|
2014
|
+
* local index (`~/.forge/decisions/`); `--refresh` rebuilds it from the repo's FORGE:DECISION comments first; `--stale`
|
|
2015
|
+
* lists the active ones with an anchor that matches no file on the base ref (default HEAD). Read-only apart from the index.
|
|
2016
|
+
*/
|
|
2017
|
+
export async function decisionsFromCli(argv, deps = {}) {
|
|
2018
|
+
const dec = await import("./lean/decisions.mjs");
|
|
2019
|
+
const usage = "usage: forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]";
|
|
2020
|
+
const [sub, ...rest] = argv;
|
|
2021
|
+
if (sub !== "list") throw new Error(usage);
|
|
2022
|
+
const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
|
|
2023
|
+
const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024 })).stdout));
|
|
2024
|
+
const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
|
|
2025
|
+
if (rest.includes("--refresh")) {
|
|
2026
|
+
const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
|
|
2027
|
+
const lines = String(await run("gh", ["api", "--paginate", `repos/${repo}/issues/comments?per_page=100`, "--jq", jq])).split("\n").filter(Boolean);
|
|
2028
|
+
const { isTrustedIssueComment } = await import("./lean/prefilter.mjs");
|
|
2029
|
+
const recs = [];
|
|
2030
|
+
for (const l of lines) {
|
|
2031
|
+
try {
|
|
2032
|
+
const c = JSON.parse(l);
|
|
2033
|
+
const r = isTrustedIssueComment(c) ? dec.parseDecisionComment(c.body, { issue: Number(/(\d+)$/.exec(c.issue_url ?? "")?.[1]) || null }) : null;
|
|
2034
|
+
if (r) recs.push(r);
|
|
2035
|
+
} catch { /* skip a malformed comment */ }
|
|
2036
|
+
}
|
|
2037
|
+
dec.writeDecisionIndex(repo, recs);
|
|
2038
|
+
}
|
|
2039
|
+
const all = dec.foldDecisions(dec.readDecisionIndex(repo));
|
|
2040
|
+
let rows = all.filter((d) => d.status !== "superseded").map((d) => ({ decision: d, dead: [] }));
|
|
2041
|
+
if (rest.includes("--stale")) rows = dec.staleDecisions(all, String(await run("git", ["ls-tree", "-r", "--name-only", flag("--base") ?? "HEAD"])).split("\n").filter(Boolean));
|
|
2042
|
+
for (const { decision: d, dead } of rows) console.log(`${d.id} #${d.issue ?? "?"} ${d.choice} [${d.anchors.join(", ")}]${dead.length ? ` stale: ${dead.join(", ")}` : ""}${d.supersedes ? ` supersedes ${d.supersedes}` : ""}`);
|
|
2043
|
+
if (!rows.length) console.log(rest.includes("--stale") ? "no stale decisions" : "no active decisions");
|
|
2044
|
+
return { rows };
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1791
2047
|
/**
|
|
1792
2048
|
* `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
|
|
1793
2049
|
* builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
|
|
@@ -2037,13 +2293,72 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
2037
2293
|
}
|
|
2038
2294
|
|
|
2039
2295
|
/**
|
|
2040
|
-
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
|
|
2296
|
+
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N] [--due]` (#4399; `--due` #4627: re-run due windowed post-deploy checks)
|
|
2041
2297
|
* Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
|
|
2042
2298
|
* declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
|
|
2043
2299
|
*/
|
|
2300
|
+
/** `forgedock integration run` (#4667): one pass of the milestone integration runs (lean/integration.mjs). Resolves the exit code. */
|
|
2301
|
+
export async function integrationFromCli(argv, deps = {}) {
|
|
2302
|
+
const { runIntegrationCli: run } = await import("./lean/integration.mjs");
|
|
2303
|
+
return run(argv, deps);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/** `forgedock milestone-sync <slug> --reason ...` (#4663): on-demand milestone branch sync (lean/milestonesync.mjs). Resolves the exit code. */
|
|
2307
|
+
export async function milestoneSyncFromCli(argv, deps = {}) {
|
|
2308
|
+
const { runMilestoneSyncCli: run } = await import("./lean/milestonesync.mjs");
|
|
2309
|
+
return run(argv, deps);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* `forgedock milestone train` (#4658): lean/train.mjs over `orchestrateFromCli` (one batch per milestone) and the #4657 lifecycle.
|
|
2314
|
+
* The train reads forge.yaml (`orchestration.global_parallel`, `milestones.close_on`) and never writes it.
|
|
2315
|
+
*/
|
|
2316
|
+
export async function trainFromCli(argv, deps = {}) {
|
|
2317
|
+
const { trainCli } = await import("./lean/train.mjs");
|
|
2318
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
2319
|
+
const cwd = process.cwd();
|
|
2320
|
+
const io = deps.io ?? makeIo();
|
|
2321
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
2322
|
+
if (!repo) throw new Error("milestone train needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
2323
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
2324
|
+
const { makeLifecycle } = await import("./lean/lifecycle.mjs");
|
|
2325
|
+
const { readLive } = await import("../scripts/plan-validate-live.mjs");
|
|
2326
|
+
const orchestrate = deps.orchestrate ?? orchestrateFromCli;
|
|
2327
|
+
return trainCli(argv, {
|
|
2328
|
+
root: deps.batchRoot ?? batchMod.batchRoot(), repo, id: deps.id, print: deps.print,
|
|
2329
|
+
globalParallel: readOrchestration(cwd).globalParallel,
|
|
2330
|
+
readLive: deps.readLive ?? (() => readLive({ gh: io.gh, repo })),
|
|
2331
|
+
onBatchEnd: (titles) => (deps.lifecycle ?? makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: readBranches(cwd).default ?? "main" })).onBatchEnd(titles),
|
|
2332
|
+
runMilestone: async (title, { parallel, resume }) => {
|
|
2333
|
+
if (resume) return orchestrate(["--resume", resume, "--repo", repo]);
|
|
2334
|
+
const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "200", "--json", "number"])) || "[]");
|
|
2335
|
+
if (!open.length) return { ok: true, rows: [] };
|
|
2336
|
+
return orchestrate([...open.map((i) => String(i.number)), "--parallel", String(parallel), "--repo", repo]);
|
|
2337
|
+
},
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
/** `forgedock guards list|backfill` (#4692): fix guards from the FORGE:GUARD comments on GitHub. */
|
|
2342
|
+
export async function guardsFromCli(argv, deps = {}) {
|
|
2343
|
+
const { guardsCli } = await import("./lean/guards.mjs");
|
|
2344
|
+
const io = deps.io ?? makeIo();
|
|
2345
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
2346
|
+
if (!repo) throw new Error("guards needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
2347
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
2348
|
+
return guardsCli(argv.filter((a, i) => a !== "--repo" && argv[i - 1] !== "--repo"), { gh: (a) => leanIo.gh(a), git: (a) => leanIo.git(a, { cwd: process.cwd() }), repo, print: deps.print, root: deps.root });
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** forge.yaml `ci` (#4667): the milestone integration config, or null when no `workflow` is set (feature off). */
|
|
2352
|
+
export function readIntegration(cwd = process.cwd()) {
|
|
2353
|
+
let ci = null;
|
|
2354
|
+
try { ci = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.ci ?? null; } catch { ci = null; }
|
|
2355
|
+
return import("./lean/integration.mjs").then((m) => m.parseIntegration(ci));
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2044
2358
|
export async function probeFromCli(argv, deps = {}) {
|
|
2045
2359
|
const valued = ["--repo", "--limit"];
|
|
2046
2360
|
const issues = [];
|
|
2361
|
+
const due = argv.includes("--due"); // #4627
|
|
2047
2362
|
for (let i = 0; i < argv.length; i++) {
|
|
2048
2363
|
if (valued.includes(argv[i])) { i++; continue; }
|
|
2049
2364
|
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
@@ -2054,8 +2369,19 @@ export async function probeFromCli(argv, deps = {}) {
|
|
|
2054
2369
|
const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
|
|
2055
2370
|
if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
|
|
2056
2371
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
2372
|
+
if (due) {
|
|
2373
|
+
const { sweepDue, formatDueSummary } = await import("./lean/postdeploy-monitor.mjs");
|
|
2374
|
+
const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2375
|
+
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2376
|
+
console.log(formatDueSummary(rows) || "monitoring: nothing due");
|
|
2377
|
+
const parkedRows = await sweepParkedFollowups({ repo, io: leanIo, dryRun: argv.includes("--dry-run") }); // #4689
|
|
2378
|
+
for (const r of parkedRows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2379
|
+
const { formatParkedSweepSummary } = await import("./lean/parked-sweep.mjs");
|
|
2380
|
+
console.log(formatParkedSweepSummary(parkedRows) || "parked: nothing released");
|
|
2381
|
+
return { rows, parkedRows };
|
|
2382
|
+
}
|
|
2057
2383
|
const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
|
|
2058
|
-
const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2384
|
+
const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2059
2385
|
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2060
2386
|
console.log(formatProbeSummary(rows) || "probes: nothing to run");
|
|
2061
2387
|
return { rows };
|