forgedock 1.36.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/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -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/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- 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 +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -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/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- 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 +8 -1
- 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 +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -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/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- 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/forgedock.mjs
CHANGED
|
@@ -86,6 +86,8 @@ import {
|
|
|
86
86
|
CYAN,
|
|
87
87
|
} from "./tui.mjs";
|
|
88
88
|
import { buildMinimalForgeYaml } from "./init-detect.mjs";
|
|
89
|
+
import { checkContext, refreshContextFlow } from "./review-context.mjs";
|
|
90
|
+
import { botTokenStatus } from "./bot-token-status.mjs";
|
|
89
91
|
import {
|
|
90
92
|
parseNameStatusDiff,
|
|
91
93
|
classifyCommandChanges,
|
|
@@ -234,7 +236,7 @@ const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
|
234
236
|
// --minimal is init-only. Subcommands with their own flag parsing (run,
|
|
235
237
|
// run-issue, demo) receive restArgs — everything after the command token.
|
|
236
238
|
const rawArgs = process.argv.slice(2);
|
|
237
|
-
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras"]);
|
|
239
|
+
const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras", "--refresh-context"]);
|
|
238
240
|
const flags = rawArgs.filter((a) => FLAGS.has(a));
|
|
239
241
|
// --global is stripped from positionals/restArgs for backward compatibility
|
|
240
242
|
// with existing invocations, but is otherwise a no-op — see the install-mode
|
|
@@ -1042,6 +1044,13 @@ function ensureToolStateIgnored(c) {
|
|
|
1042
1044
|
|
|
1043
1045
|
async function initFlow(c) {
|
|
1044
1046
|
const outputPath = join(c.cwd, "forge.yaml");
|
|
1047
|
+
if (flags.includes("--refresh-context")) {
|
|
1048
|
+
return refreshContextFlow({
|
|
1049
|
+
cwd: c.cwd,
|
|
1050
|
+
out: c.stdout,
|
|
1051
|
+
deps: { interactive: process.stdin.isTTY === true, confirm, backup: backupExisting, write: atomicWriteFile },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1045
1054
|
const hasExisting = existsSync(outputPath);
|
|
1046
1055
|
if (hasExisting && process.stdin.isTTY !== true) {
|
|
1047
1056
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
@@ -2704,6 +2713,15 @@ async function doctor(fix = false) {
|
|
|
2704
2713
|
);
|
|
2705
2714
|
}
|
|
2706
2715
|
|
|
2716
|
+
try {
|
|
2717
|
+
const ctxCheck = checkContext({ configPath: forgeYamlPath, root: process.cwd(), repo: forgeOwner && forgeRepo ? `${forgeOwner}/${forgeRepo}` : "" });
|
|
2718
|
+
if (ctxCheck.stale) {
|
|
2719
|
+
warn("review context", `REVIEW_CONTEXT_STALE: ${ctxCheck.reasons.map((r) => r.detail).join("; ")}. Run: npx forgedock init --refresh-context`);
|
|
2720
|
+
} else if (ctxCheck.advisory) {
|
|
2721
|
+
warn("review context", `REVIEW_CONTEXT_STALE: generated ${ctxCheck.generated_at}, ${ctxCheck.merged_since} PRs merged since (limit ${ctxCheck.max_merged_prs}). Run: npx forgedock init --refresh-context`);
|
|
2722
|
+
}
|
|
2723
|
+
} catch { /* advisory check: never fail doctor */ }
|
|
2724
|
+
|
|
2707
2725
|
const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
|
|
2708
2726
|
const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
|
|
2709
2727
|
if (
|
|
@@ -3243,14 +3261,20 @@ async function doctor(fix = false) {
|
|
|
3243
3261
|
}
|
|
3244
3262
|
|
|
3245
3263
|
// ── Check 12: GitHub App / bot token status ─────────────────────────────────
|
|
3246
|
-
// Informational
|
|
3264
|
+
// Informational without a refresh stamp (#4662: with one, a stale or expiring token fails). Installing the ForgeDock GitHub
|
|
3247
3265
|
// App registers it against the account/org but does not, by itself, mint a
|
|
3248
3266
|
// bot token: that requires the app's private key, which only the app owner
|
|
3249
3267
|
// holds. Pipeline gh calls always use whatever `gh auth` context is active
|
|
3250
3268
|
// locally (personal token unless the operator manually configured a bot
|
|
3251
3269
|
// token) — this check exists so that state is surfaced instead of silent.
|
|
3252
3270
|
// See docs/CONFIG.md "GitHub App Install" and forge#1890.
|
|
3253
|
-
|
|
3271
|
+
const botTok = botTokenStatus(); // #4662: lifetime from the refresh stamp; no stamp means no refresher is installed
|
|
3272
|
+
if (botTok.configured) {
|
|
3273
|
+
const min = (ms) => Math.round(ms / 60000);
|
|
3274
|
+
const detail = botTok.remainingMs != null ? `${min(botTok.remainingMs)} min left, refreshed ${min(botTok.ageMs)} min ago (${botTok.refreshedAt})` : botTok.reason;
|
|
3275
|
+
if (botTok.ok) pass("GitHub App / bot token", detail);
|
|
3276
|
+
else fail(`GitHub App / bot token: ${botTok.reason} (${detail})`, "run scripts/refresh-bot-token.sh and install the timer: docs/CONFIG.md \"Bot token refresh\"");
|
|
3277
|
+
} else {
|
|
3254
3278
|
warn(
|
|
3255
3279
|
"GitHub App / bot token",
|
|
3256
3280
|
"pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
|
|
@@ -3277,6 +3301,19 @@ async function doctor(fix = false) {
|
|
|
3277
3301
|
}
|
|
3278
3302
|
}
|
|
3279
3303
|
|
|
3304
|
+
// ── Check 14: Router/schedd restarts (#4673) ───────────────────────────────
|
|
3305
|
+
// A restart in the last hour usually means an OOM killer or SIGTERM took the coordinator and dropped in-flight lanes. Warn only.
|
|
3306
|
+
{
|
|
3307
|
+
try {
|
|
3308
|
+
const { restartCheck } = await import("./pool/health.mjs");
|
|
3309
|
+
for (const unit of ["computeshare-router", "forgedock-schedd"]) {
|
|
3310
|
+
const r = restartCheck({ unit });
|
|
3311
|
+
if (r.status === "warn") warn(`Restarts: ${unit}`, r.detail);
|
|
3312
|
+
else if (r.status === "pass") pass(`Restarts: ${unit}`, r.detail);
|
|
3313
|
+
}
|
|
3314
|
+
} catch { /* no systemd here: nothing to report */ }
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3280
3317
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
3281
3318
|
console.log("");
|
|
3282
3319
|
if (fix && fixesApplied > 0) {
|
|
@@ -3314,12 +3351,16 @@ function help() {
|
|
|
3314
3351
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3315
3352
|
["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
|
|
3316
3353
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3354
|
+
["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
|
|
3317
3355
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3318
|
-
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
|
|
3356
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
|
|
3319
3357
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3320
3358
|
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3321
3359
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
3360
|
+
["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
|
|
3361
|
+
["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
|
|
3322
3362
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3363
|
+
["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
|
|
3323
3364
|
["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
|
|
3324
3365
|
["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and batches"],
|
|
3325
3366
|
["npx forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>", "Built-in remote compute pool: run the router (foreground, or install it as a user service), print the one-time install command for a host, list agents, upgrade or remove them"],
|
|
@@ -3350,6 +3391,7 @@ function help() {
|
|
|
3350
3391
|
["--manual", "Plain text prompts instead of the review screen (init)"],
|
|
3351
3392
|
["--verbose", "Show detection sources for every field (init)"],
|
|
3352
3393
|
["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
|
|
3394
|
+
["--refresh-context", "Regenerate review.context/tech_stack from manifests and show a diff for approval (init)"],
|
|
3353
3395
|
["--version, -v", "Print the installed version and check for updates"],
|
|
3354
3396
|
];
|
|
3355
3397
|
|
|
@@ -3903,7 +3945,7 @@ async function watch() {
|
|
|
3903
3945
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3904
3946
|
const KNOWN_COMMANDS = new Set([
|
|
3905
3947
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3906
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3948
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3907
3949
|
"version", "--version", "-v",
|
|
3908
3950
|
]);
|
|
3909
3951
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4060,6 +4102,12 @@ switch (command) {
|
|
|
4060
4102
|
case "run":
|
|
4061
4103
|
await run();
|
|
4062
4104
|
break;
|
|
4105
|
+
case "plan": {
|
|
4106
|
+
// #4659: bootstrap-validate's dependency rules on live GitHub state
|
|
4107
|
+
const { runLive } = await import("../scripts/plan-validate-live.mjs");
|
|
4108
|
+
exitCode = await runLive(restArgs);
|
|
4109
|
+
break;
|
|
4110
|
+
}
|
|
4063
4111
|
case "bootstrap": {
|
|
4064
4112
|
// #4177: headless /bootstrap; --plan runs scripts/bootstrap-create.mjs directly (no LLM), a re-run resumes.
|
|
4065
4113
|
const { runBootstrap } = await import("./bootstrap.mjs");
|
|
@@ -4136,6 +4184,17 @@ switch (command) {
|
|
|
4136
4184
|
}
|
|
4137
4185
|
break;
|
|
4138
4186
|
}
|
|
4187
|
+
case "decisions": {
|
|
4188
|
+
// #4695: anchored decisions of the local index
|
|
4189
|
+
const { decisionsFromCli } = await import("./engine-cli.mjs");
|
|
4190
|
+
try {
|
|
4191
|
+
await decisionsFromCli(restArgs);
|
|
4192
|
+
} catch (err) {
|
|
4193
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4194
|
+
exitCode = 1;
|
|
4195
|
+
}
|
|
4196
|
+
break;
|
|
4197
|
+
}
|
|
4139
4198
|
case "check": {
|
|
4140
4199
|
// #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
|
|
4141
4200
|
const { checkFromCli } = await import("./engine-cli.mjs");
|
|
@@ -4199,6 +4258,50 @@ switch (command) {
|
|
|
4199
4258
|
}
|
|
4200
4259
|
break;
|
|
4201
4260
|
}
|
|
4261
|
+
case "milestone-sync": {
|
|
4262
|
+
// On-demand milestone branch sync (#4663): PR + auto-merge under protected branches, one debounced sync PR.
|
|
4263
|
+
const { milestoneSyncFromCli } = await import("./engine-cli.mjs");
|
|
4264
|
+
try {
|
|
4265
|
+
exitCode = await milestoneSyncFromCli(restArgs);
|
|
4266
|
+
} catch (err) {
|
|
4267
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4268
|
+
exitCode = 1;
|
|
4269
|
+
}
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
case "milestone": {
|
|
4273
|
+
// Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
|
|
4274
|
+
const { trainFromCli } = await import("./engine-cli.mjs");
|
|
4275
|
+
try {
|
|
4276
|
+
if (restArgs[0] !== "train") throw new Error("usage: forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> | --show <id>");
|
|
4277
|
+
const result = await trainFromCli(restArgs.slice(1));
|
|
4278
|
+
if (result && !result.ok) exitCode = 1;
|
|
4279
|
+
} catch (err) {
|
|
4280
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4281
|
+
exitCode = 1;
|
|
4282
|
+
}
|
|
4283
|
+
break;
|
|
4284
|
+
}
|
|
4285
|
+
case "guards": {
|
|
4286
|
+
// Fix guards (#4692): `forgedock guards list [--path p]` | `guards backfill --since <date>`.
|
|
4287
|
+
const { guardsFromCli } = await import("./engine-cli.mjs");
|
|
4288
|
+
try { exitCode = await guardsFromCli(restArgs); } catch (err) {
|
|
4289
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4290
|
+
exitCode = 1;
|
|
4291
|
+
}
|
|
4292
|
+
break;
|
|
4293
|
+
}
|
|
4294
|
+
case "integration": {
|
|
4295
|
+
// Milestone integration runs (#4667): `forgedock integration run` for cron or a scheduled workflow.
|
|
4296
|
+
const { integrationFromCli } = await import("./engine-cli.mjs");
|
|
4297
|
+
try {
|
|
4298
|
+
exitCode = await integrationFromCli(restArgs);
|
|
4299
|
+
} catch (err) {
|
|
4300
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4301
|
+
exitCode = 1;
|
|
4302
|
+
}
|
|
4303
|
+
break;
|
|
4304
|
+
}
|
|
4202
4305
|
case "probe": {
|
|
4203
4306
|
const { probeFromCli } = await import("./engine-cli.mjs");
|
|
4204
4307
|
try {
|
package/bin/labels.json
CHANGED
|
@@ -69,11 +69,31 @@
|
|
|
69
69
|
"color": "E4E669",
|
|
70
70
|
"description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
"name": "needs-human:external",
|
|
74
|
+
"color": "E4E669",
|
|
75
|
+
"description": "Waiting on an external prerequisite (credential, account, sign-off). Comment names the owner and unblock condition. Managed by ForgeDock."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "parked",
|
|
79
|
+
"color": "FBCA04",
|
|
80
|
+
"description": "Mechanical park on a terminal code; per-code labels are parked:<code>. Managed by ForgeDock."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "remediation-queue",
|
|
84
|
+
"color": "FBCA04",
|
|
85
|
+
"description": "Parked issue awaiting /work-on:remediate. Managed by ForgeDock."
|
|
86
|
+
},
|
|
72
87
|
{
|
|
73
88
|
"name": "workflow:engine-error",
|
|
74
89
|
"color": "B60205",
|
|
75
90
|
"description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
|
|
76
91
|
},
|
|
92
|
+
{
|
|
93
|
+
"name": "awaiting-due",
|
|
94
|
+
"color": "C5DEF5",
|
|
95
|
+
"description": "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock."
|
|
96
|
+
},
|
|
77
97
|
{
|
|
78
98
|
"name": "awaiting-promotion",
|
|
79
99
|
"color": "C5DEF5",
|
|
@@ -183,5 +203,35 @@
|
|
|
183
203
|
"name": "performance",
|
|
184
204
|
"color": "F9D0C4",
|
|
185
205
|
"description": "Performance optimization or regression. Managed by ForgeDock."
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"name": "integration-red",
|
|
209
|
+
"color": "D93F0B",
|
|
210
|
+
"description": "Scheduled full-suite integration run on a milestone branch is red. Managed by ForgeDock."
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"name": "followup:engineering",
|
|
214
|
+
"color": "BFD4F2",
|
|
215
|
+
"description": "Deferred buildable engineering work. Managed by ForgeDock."
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "followup:post-deploy",
|
|
219
|
+
"color": "BFD4F2",
|
|
220
|
+
"description": "Deferred post-deploy checks. Managed by ForgeDock."
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"name": "followup:external",
|
|
224
|
+
"color": "BFD4F2",
|
|
225
|
+
"description": "Deferred items blocked on something outside the repo (credentials, third parties). Managed by ForgeDock."
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"name": "followup:ops",
|
|
229
|
+
"color": "BFD4F2",
|
|
230
|
+
"description": "Deferred ops work (drills, runbooks). Managed by ForgeDock."
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"name": "external-prerequisite",
|
|
234
|
+
"color": "E99695",
|
|
235
|
+
"description": "Follow-up that waits on an external prerequisite. Managed by ForgeDock."
|
|
186
236
|
}
|
|
187
237
|
]
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function openBatch(root, id) {
|
|
|
99
99
|
if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
|
|
100
100
|
const dir = join(root, id);
|
|
101
101
|
const f = (name) => join(dir, name);
|
|
102
|
-
|
|
102
|
+
const self = {
|
|
103
103
|
id, dir,
|
|
104
104
|
exists: () => existsSync(f("plan.json")),
|
|
105
105
|
readPlan: () => readJson(f("plan.json")),
|
|
@@ -122,16 +122,19 @@ export function openBatch(root, id) {
|
|
|
122
122
|
* `order`, in the file or earlier in `list`: skipped `already in batch`. @returns {{added:number[], skipped:Array<{issue, reason}>}}
|
|
123
123
|
*/
|
|
124
124
|
addIssues: (list) => {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
125
|
+
const st = readJson(f("state.json")) ?? {};
|
|
126
|
+
const have = new Set([...(st.order ?? []), ...readAdded(f("add"))]);
|
|
127
|
+
const added = [], skipped = [], requeued = [];
|
|
127
128
|
for (const x of Array.isArray(list) ? list : []) {
|
|
128
129
|
const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
|
|
129
130
|
if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
|
|
131
|
+
// #4687: an issue skipped in the batch (a dependency ended) is re-queued, not refused; any other row is "already in batch"
|
|
132
|
+
if (have.has(n) && st.results?.[n]?.outcome === "skipped" && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
|
|
130
133
|
if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
|
|
131
134
|
have.add(n); added.push(n);
|
|
132
135
|
}
|
|
133
136
|
if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
|
|
134
|
-
return { added, skipped };
|
|
137
|
+
return { added, skipped, requeued };
|
|
135
138
|
},
|
|
136
139
|
/**
|
|
137
140
|
* `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
|
|
@@ -164,6 +167,8 @@ export function openBatch(root, id) {
|
|
|
164
167
|
unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
|
|
165
168
|
/** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
|
|
166
169
|
readHeld: () => readAdded(f("hold")),
|
|
170
|
+
/** #4656: gates waiting on issues (`gates` file: [{ name, waitsOn: [n...], since? }], written by the ship/settle pre-flight); [] when missing or invalid. */
|
|
171
|
+
readGates: () => { const v = readJson(f("gates")); return Array.isArray(v) ? v.filter((g) => g && typeof g.name === "string" && Array.isArray(g.waitsOn)) : []; },
|
|
167
172
|
/** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
|
|
168
173
|
holdIssues: (list) => {
|
|
169
174
|
const have = new Set(readAdded(f("hold")));
|
|
@@ -197,6 +202,7 @@ export function openBatch(root, id) {
|
|
|
197
202
|
resultFile: (n) => f(join("lanes", `${n}.json`)),
|
|
198
203
|
logFile: (n) => f(join("lanes", `${n}.log`)),
|
|
199
204
|
};
|
|
205
|
+
return self;
|
|
200
206
|
}
|
|
201
207
|
|
|
202
208
|
/** True when `pid` is alive and (where /proc exists) its command line names `marker`, so a reused pid is not adopted. */
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -89,6 +89,21 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
89
89
|
assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it("#4685 a breaker tripped by transport errors closes itself after the cooldown; any other stays tripped", async () => {
|
|
93
|
+
const run = async (detail) => {
|
|
94
|
+
let t = 0; const updates = []; const saves = [];
|
|
95
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms; }, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
|
|
96
|
+
onUpdate: (r) => updates.push(r), store: { load: () => null, save: (s) => saves.push(s) },
|
|
97
|
+
runOne: async (n) => { t += 1000; return n <= 3 ? { terminalReason: "engine-error", detail } : { terminalReason: "merged", pr: 5 }; } });
|
|
98
|
+
return { rows, updates, saves };
|
|
99
|
+
};
|
|
100
|
+
const tr = await run("sandbox launch: fetch failed");
|
|
101
|
+
const trip = tr.saves.map((s) => s.breaker).find(Boolean);
|
|
102
|
+
assert.equal(trip?.transport, true);
|
|
103
|
+
const other = await run("boom");
|
|
104
|
+
assert.equal(other.saves.map((s) => s.breaker).find(Boolean)?.transport, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
92
107
|
it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
|
|
93
108
|
const { now, sleep } = fakeClock();
|
|
94
109
|
const updates = [];
|
|
@@ -105,6 +120,31 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
105
120
|
assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
|
|
106
121
|
});
|
|
107
122
|
|
|
123
|
+
it("gh.auth deferrals (forge#4662) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
|
|
124
|
+
const { now, sleep } = fakeClock();
|
|
125
|
+
const updates = [];
|
|
126
|
+
let calls = 0;
|
|
127
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
|
|
128
|
+
onUpdate: (r) => updates.push(r),
|
|
129
|
+
runOne: async () => (++calls <= 3
|
|
130
|
+
? { terminalReason: "deferred", code: "gh.auth", retryInSeconds: 120, detail: "gh auth failed (HTTP 401)" }
|
|
131
|
+
: { terminalReason: "merged", pr: 5 }) });
|
|
132
|
+
assert.equal(calls, 4);
|
|
133
|
+
assert.equal(rows[0].outcome, "merged");
|
|
134
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
135
|
+
assert.equal(updates.filter((u) => /requeued in 120s: gh.auth/.test(u.detail ?? "")).length, 3);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("#4690 env.lane_limit (needs-human) is never counted by the breaker and the batch keeps admitting", async () => {
|
|
139
|
+
const { now, sleep } = fakeClock();
|
|
140
|
+
const updates = [], ran = [];
|
|
141
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 1, window_min: 30 }, onUpdate: (r) => updates.push(r),
|
|
142
|
+
runOne: async (n) => { ran.push(n); return n === 1 ? { terminalReason: "needs-human", code: "env.lane_limit", detail: "TasksMax" } : { terminalReason: "merged", pr: n }; } });
|
|
143
|
+
assert.deepEqual(ran, [1, 2, 3]);
|
|
144
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
145
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged", "merged"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
108
148
|
it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
|
|
109
149
|
const { now, sleep, c } = fakeClock();
|
|
110
150
|
const t0 = c.t, starts = {};
|
|
@@ -309,6 +349,26 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
|
|
|
309
349
|
const b = hook({ state: "OPEN", body: "" }, { comment: true });
|
|
310
350
|
assert.equal(await b.run(), true);
|
|
311
351
|
});
|
|
352
|
+
it("#4736: a mechanical park creates its parked:<code> label before the edit", async () => {
|
|
353
|
+
const calls = [];
|
|
354
|
+
const gh = async (a) => { calls.push(a.slice(0, 3).join(" ")); return a[1] === "view" ? JSON.stringify({ state: "OPEN", body: "" }) : ""; };
|
|
355
|
+
assert.equal(await makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "e", parked: { code: "ci.red", pr: 3 } }), true);
|
|
356
|
+
assert.deepEqual(calls.slice(0, 3), ["issue view 7", "label create parked:ci.red", "issue edit 7"]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("#4736: a parked relabel that reports failed is not reported as queued for remediation", async () => {
|
|
361
|
+
const leanIo = { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })), git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") };
|
|
362
|
+
const out = [], l = console.log;
|
|
363
|
+
console.log = (...x) => out.push(x.join(" "));
|
|
364
|
+
try {
|
|
365
|
+
await orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("pf-"), batchRoot: tmpDir("pf-batch-"), allowTmpfs: true,
|
|
366
|
+
emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
|
|
367
|
+
orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } },
|
|
368
|
+
relabelNeedsHuman: async (x) => (x.parked ? "failed" : true),
|
|
369
|
+
leanModule: { runCore: async () => ({ terminalReason: "needs-human", code: "ci.red", detail: "x", pr: 5 }) } });
|
|
370
|
+
} finally { console.log = l; }
|
|
371
|
+
assert.doesNotMatch(out.join("\n"), /remediation queue/);
|
|
312
372
|
});
|
|
313
373
|
});
|
|
314
374
|
|
|
@@ -428,6 +488,19 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
|
|
|
428
488
|
assert.deepEqual(plain.map((r) => r.issue), [1]);
|
|
429
489
|
});
|
|
430
490
|
|
|
491
|
+
it("#4687 --add re-queues an issue that is skipped in the batch; any other ended issue stays 'already in batch'", () => {
|
|
492
|
+
const root = tmpDir("batch-add-skipped-");
|
|
493
|
+
const b = openBatch(root, "b1");
|
|
494
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
495
|
+
b.store.save({ order: [1, 2, 3], results: { 1: { outcome: "needs-human" }, 2: { outcome: "skipped", detail: "dependency #1 ended needs-human" }, 3: { outcome: "merged" } } });
|
|
496
|
+
const r = b.addIssues([2, 3, 1]);
|
|
497
|
+
assert.deepEqual(r.requeued, [2]);
|
|
498
|
+
assert.deepEqual(r.added, []);
|
|
499
|
+
assert.deepEqual(r.skipped.map((x) => [x.issue, x.reason]), [[3, "already in batch"], [1, "already in batch"]]);
|
|
500
|
+
assert.deepEqual(b.readRequeued().map((e) => e.issue), [2]);
|
|
501
|
+
assert.deepEqual(b.readAdded(), []);
|
|
502
|
+
});
|
|
503
|
+
|
|
431
504
|
it("the add file: integers only, no issue twice, cumulative; --add through the CLI skips closed and unreadable issues", async () => {
|
|
432
505
|
const root = tmpDir("batch-add-");
|
|
433
506
|
const b = openBatch(root, "b1");
|
|
@@ -659,7 +732,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
659
732
|
|
|
660
733
|
it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
|
|
661
734
|
const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
662
|
-
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
|
|
735
|
+
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
|
|
663
736
|
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
664
737
|
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
665
738
|
if (prs instanceof Error) throw prs;
|
|
@@ -673,6 +746,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
673
746
|
assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
|
|
674
747
|
assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
|
|
675
748
|
assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
|
|
749
|
+
assert.deepEqual(await mk('{"state":"CLOSED","stateReason":"NOT_PLANNED"}', "[]")(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687: not planned is final");
|
|
676
750
|
assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
|
|
677
751
|
assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
|
|
678
752
|
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
@@ -56,7 +56,7 @@ const contract = (reqs) => ({ requirements: reqs, deliverables: reqs.filter((r)
|
|
|
56
56
|
preserve: [{ id: "P1", what: `${Q.AC3} (src/billing.js price())` }, { id: "P2", what: `${Q.AC2} (test/routes.test.js)` }], validate: [{ id: "V1", command: V1 }] });
|
|
57
57
|
const FULL = () => contract([req("R1"), req("R2"), req("R3"), req("R4"), req("R5")]);
|
|
58
58
|
const OMIT = () => contract([req("R1"), req("R2"), req("R4"), req("R5")]);
|
|
59
|
-
const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
|
|
59
|
+
const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { bucket: "new-issue", why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
|
|
60
60
|
|
|
61
61
|
// ---- world ----
|
|
62
62
|
function makeWorld(dir, script) {
|