forgedock 1.32.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
|
@@ -46,10 +46,12 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
46
46
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
47
47
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
48
48
|
CLI_TIMEOUT: "cli.timeout",
|
|
49
|
+
CLI_HOST_KILLED: "cli.host_killed", // forge#4518: the builder CLI was SIGTERM/SIGKILLed from outside (host, OOM guard, operator); deferred, never counted by the breaker
|
|
49
50
|
CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
|
|
50
51
|
ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
|
|
51
52
|
ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
|
|
52
53
|
ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
|
|
54
|
+
ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
|
|
53
55
|
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
|
|
54
56
|
SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
|
|
55
57
|
LEASE_HELD: "lease.held",
|
|
@@ -175,6 +177,7 @@ export function codeForTerminalReason(reason) {
|
|
|
175
177
|
case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
|
|
176
178
|
case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
|
|
177
179
|
case "dependency-blocked": return OUTCOME_CODES.DEPENDENCY_BLOCKED;
|
|
180
|
+
case "budget-exceeded": return OUTCOME_CODES.BUDGET_EXCEEDED; // forge#4505: the non-human turn-overrun end
|
|
178
181
|
default: return UNCLASSIFIED;
|
|
179
182
|
}
|
|
180
183
|
}
|
|
@@ -183,20 +186,21 @@ export function codeForTerminalReason(reason) {
|
|
|
183
186
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
184
187
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
185
188
|
*/
|
|
186
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
189
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
187
190
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
188
191
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
189
192
|
|
|
190
193
|
/**
|
|
191
194
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
192
|
-
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
193
|
-
* edit (`issueEdited`),
|
|
195
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
196
|
+
* edit (`issueEdited`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
194
197
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
195
198
|
*/
|
|
196
199
|
export function classifyNeedsHuman(row) {
|
|
197
200
|
if (!row || typeof row !== "object") return "terminal";
|
|
198
201
|
const outcome = row.outcome ?? row.terminalReason;
|
|
199
202
|
if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
|
|
203
|
+
if (outcome === "budget-exceeded") return "recoverable";
|
|
200
204
|
if (outcome !== "needs-human") return "terminal";
|
|
201
205
|
if (HUMAN_CODES.includes(row.code)) return "human";
|
|
202
206
|
if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -308,20 +308,41 @@ async function readIssueRecord(n, io) {
|
|
|
308
308
|
} catch { return null; }
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
// A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case),
|
|
312
|
-
//
|
|
313
|
-
|
|
311
|
+
// A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), then one linear run of blanks, colons
|
|
312
|
+
// and bold markers (no chained quantifiers: #4517), then a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
|
|
313
|
+
// (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
|
|
314
|
+
// "see #99", "Fixes #12", a code span or a sentence never adds an edge (release review #4515).
|
|
315
|
+
const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t:*_]*(#\d+\b[^\n]*)/gim;
|
|
316
|
+
const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
|
|
317
|
+
const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
|
|
318
|
+
|
|
319
|
+
/** The `#N` list at the start of a declaration's tail: items and joiners only, stopping at the first other text. */
|
|
320
|
+
function dependsOnList(tail) {
|
|
321
|
+
const out = [];
|
|
322
|
+
let rest = tail;
|
|
323
|
+
for (;;) {
|
|
324
|
+
const item = DEP_ITEM_RE.exec(rest);
|
|
325
|
+
if (!item) break;
|
|
326
|
+
out.push(Number(item[1]));
|
|
327
|
+
rest = rest.slice(item[0].length);
|
|
328
|
+
const join = DEP_JOIN_RE.exec(rest);
|
|
329
|
+
if (!join || !/^#\d/.test(rest.slice(join[0].length))) break;
|
|
330
|
+
rest = rest.slice(join[0].length);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
314
334
|
|
|
315
335
|
/**
|
|
316
336
|
* Issue numbers an issue body declares it waits on: `**Depends on**: #X`, `**Depends on** #X, #Y`, `Depends on #X`,
|
|
317
|
-
* `- Depends on: #X and #Y`, `Blocked by #X`. Only declaration lines count
|
|
318
|
-
*
|
|
337
|
+
* `- Depends on: #X and #Y`, `Depends on #X (why) and #Y (why)`, `Blocked by #X`. Only declaration lines count, and on
|
|
338
|
+
* them only the leading `#N` list; a mid-line or trailing mention is prose. Shared by the engine's admission
|
|
339
|
+
* (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
|
|
319
340
|
* @returns {number[]} unique, positive, never `issue` itself
|
|
320
341
|
*/
|
|
321
342
|
export function parseDependsOn(body, issue = null) {
|
|
322
343
|
const out = new Set();
|
|
323
344
|
for (const m of String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEPENDS_ON_RE)) {
|
|
324
|
-
for (const
|
|
345
|
+
for (const n of dependsOnList(m[1])) out.add(n);
|
|
325
346
|
}
|
|
326
347
|
return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
|
|
327
348
|
}
|
|
@@ -329,8 +350,8 @@ export function parseDependsOn(body, issue = null) {
|
|
|
329
350
|
/**
|
|
330
351
|
* forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
|
|
331
352
|
* Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
|
|
332
|
-
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line `parseDependsOn`
|
|
333
|
-
* prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
353
|
+
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line's leading `#N` list as `parseDependsOn` reads it (mid-line
|
|
354
|
+
* and trailing prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
334
355
|
* when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
|
|
335
356
|
* An empty read of the issue itself means "no body to trust" and yields `null`.
|
|
336
357
|
*/
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
|
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
19
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
20
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
|
-
import { parseExecution } from "./lean/execution.mjs";
|
|
21
|
+
import { parseExecution, detectNeeds } from "./lean/execution.mjs";
|
|
22
22
|
import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
|
|
23
23
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
24
24
|
import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
|
|
@@ -326,6 +326,11 @@ export function readVerify(cwd = process.cwd()) {
|
|
|
326
326
|
return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
|
|
327
327
|
} catch { return undefined; }
|
|
328
328
|
}
|
|
329
|
+
/** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
|
|
330
|
+
export function readHostCaps(cwd = process.cwd()) {
|
|
331
|
+
const e = readExecution(cwd);
|
|
332
|
+
return e?.hostCapabilities?.length ? { hostCapabilities: e.hostCapabilities, ...(e.hostCapabilityPatterns ? { hostCapabilityPatterns: e.hostCapabilityPatterns } : {}) } : undefined;
|
|
333
|
+
}
|
|
329
334
|
/**
|
|
330
335
|
* forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
|
|
331
336
|
* ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
|
|
@@ -849,7 +854,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
849
854
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
850
855
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
851
856
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
852
|
-
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() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
857
|
+
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() } : {}) });
|
|
853
858
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
854
859
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
855
860
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1199,19 +1204,34 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1199
1204
|
}
|
|
1200
1205
|
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
|
|
1201
1206
|
}
|
|
1202
|
-
|
|
1207
|
+
let sock = await connect(), req = msg, reattached = false, losses = 0;
|
|
1203
1208
|
let id = resumeId;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1209
|
+
const onMsg = (m) => {
|
|
1210
|
+
if (m.type === "batch") id = m.id;
|
|
1211
|
+
else if (m.type === "row") { if (!(reattached && m.replay)) console.log(formatRowLine(m.row)); }
|
|
1212
|
+
else if (m.type === "line") console.log(m.text);
|
|
1213
|
+
else if (m.type === "done" && m.replay && m.report) console.log(m.report);
|
|
1214
|
+
};
|
|
1215
|
+
// #4503: when the daemon dies mid-batch, reconnect (spawning one unless `spawnDaemon: null`) and re-attach, within `reconnectMs`.
|
|
1216
|
+
const reconnectMs = deps.reconnectMs ?? 60_000, sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1217
|
+
for (;;) {
|
|
1218
|
+
try {
|
|
1219
|
+
return (await schedd.request(sock, req, onMsg)).result;
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
if (!id || !(/closed the connection/.test(e?.message ?? "") || ["ECONNRESET", "EPIPE"].includes(e?.code))) throw e;
|
|
1222
|
+
const giveUp = () => new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
|
|
1223
|
+
if (++losses > 10) throw giveUp();
|
|
1224
|
+
console.log(`schedd connection lost; reconnecting to batch ${id}`);
|
|
1225
|
+
const end = Date.now() + reconnectMs;
|
|
1226
|
+
for (sock = null; !sock;) {
|
|
1227
|
+
try { sock = await connect(); } catch {
|
|
1228
|
+
if (Date.now() >= end) throw giveUp();
|
|
1229
|
+
await sleep(250);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
req = { op: "attach", id };
|
|
1233
|
+
reattached = true;
|
|
1234
|
+
}
|
|
1215
1235
|
}
|
|
1216
1236
|
}
|
|
1217
1237
|
|
|
@@ -1312,13 +1332,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1312
1332
|
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
|
|
1313
1333
|
// sandboxes when forge.yaml `execution` places them remote (#4253).
|
|
1314
1334
|
const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
|
|
1335
|
+
const needsOf = new Map(); // issue -> host-only capabilities its body names (#4542), filled by readBody
|
|
1336
|
+
let execForNeeds = null;
|
|
1315
1337
|
let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
|
|
1316
1338
|
const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
|
|
1317
1339
|
if (deps.leanModule) {
|
|
1318
1340
|
const { runCore } = deps.leanModule;
|
|
1319
1341
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1320
1342
|
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,
|
|
1321
|
-
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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1343
|
+
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) } : {}) }) };
|
|
1322
1344
|
} else {
|
|
1323
1345
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1324
1346
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1333,6 +1355,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1333
1355
|
}, adopt: l.adopt });
|
|
1334
1356
|
const execMod = await import("./lean/execution.mjs");
|
|
1335
1357
|
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1358
|
+
execForNeeds = exec ?? null;
|
|
1336
1359
|
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1337
1360
|
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1338
1361
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
@@ -1345,12 +1368,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1345
1368
|
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1346
1369
|
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1347
1370
|
lanes = withPlacement(remoteLanes);
|
|
1348
|
-
|
|
1371
|
+
{
|
|
1349
1372
|
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
1350
1373
|
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1351
1374
|
lanes = withPlacement({
|
|
1352
1375
|
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1353
|
-
adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
|
|
1376
|
+
adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
|
|
1354
1377
|
});
|
|
1355
1378
|
}
|
|
1356
1379
|
// #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
|
|
@@ -1358,15 +1381,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1358
1381
|
execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
|
|
1359
1382
|
const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1360
1383
|
// #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
|
|
1361
|
-
const
|
|
1384
|
+
const relays = Array.isArray(provider.relays) ? provider.relays : [];
|
|
1385
|
+
const place = !hybrid ? (n) => execMod.placeLane({ exec, needs: needsOf.get(n) ?? [], relays }) : await (async () => {
|
|
1362
1386
|
const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
|
|
1363
1387
|
let weight = deps.validationWeight ?? null;
|
|
1364
1388
|
if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
|
|
1365
1389
|
const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
|
|
1366
|
-
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
|
|
1367
|
-
})()
|
|
1390
|
+
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
|
|
1391
|
+
})();
|
|
1392
|
+
// #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
|
|
1393
|
+
let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity; // #4547: online = agents alive, null unknown
|
|
1394
|
+
const capacity = () => {
|
|
1395
|
+
const t = deps.now ? deps.now() : Date.now();
|
|
1396
|
+
if (typeof provider.capacity === "function" && t - capLast >= (deps.pollMs ?? 5000)) {
|
|
1397
|
+
capLast = t;
|
|
1398
|
+
Promise.resolve().then(() => provider.capacity()).then((c) => { capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capAt++; });
|
|
1399
|
+
}
|
|
1400
|
+
return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
|
|
1401
|
+
};
|
|
1368
1402
|
remote = {
|
|
1369
|
-
max: exec.maxRemote, isRemote: () => true, ...(
|
|
1403
|
+
capacity, max: exec.maxRemote, isRemote: () => true, place, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1370
1404
|
admit: (u) => execBudget.admit(u),
|
|
1371
1405
|
onUsage: (n, sb, rec) => {
|
|
1372
1406
|
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
@@ -1507,6 +1541,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1507
1541
|
...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
|
|
1508
1542
|
// #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
|
|
1509
1543
|
...(signal ? { signal } : {}),
|
|
1544
|
+
// #4525: a parked needs-human PR is merged with its moved base: clean or union-resolved merges are pushed, other conflicts reported
|
|
1545
|
+
...(deps.syncParked !== undefined ? (deps.syncParked ? { syncParked: deps.syncParked } : {}) : orch.baseSync?.enabled !== true ? {} : { syncParked: async ({ pr, last }) => {
|
|
1546
|
+
try {
|
|
1547
|
+
const v = JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "state,headRefName,baseRefName,isCrossRepository"]));
|
|
1548
|
+
if (v.state !== "OPEN" || v.isCrossRepository) return { status: "unchanged" };
|
|
1549
|
+
const { execFile } = await import("node:child_process"), { promisify } = await import("node:util");
|
|
1550
|
+
const run = promisify(execFile);
|
|
1551
|
+
return await (await import("./lean/basesync.mjs")).syncParked({ git: async (a, o = {}) => String((await run("git", a, { cwd: o.cwd ?? cwd, maxBuffer: 1 << 24 })).stdout), base: v.baseRefName, branch: v.headRefName,
|
|
1552
|
+
unionGlobs: orch.baseSync?.unionGlobs ?? [], last });
|
|
1553
|
+
} catch (e) { return { status: "error", detail: String(e?.message ?? e).slice(0, 160) }; }
|
|
1554
|
+
} }),
|
|
1510
1555
|
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1511
1556
|
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1512
1557
|
}),
|
|
@@ -1515,8 +1560,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1515
1560
|
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1516
1561
|
},
|
|
1517
1562
|
readBody: maint || registry || hybrid
|
|
1518
|
-
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
|
|
1519
|
-
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1563
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(v.body, execForNeeds)); return v.body; }
|
|
1564
|
+
: async (n) => { const b = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body; needsOf.set(n, detectNeeds(b, execForNeeds)); return b; },
|
|
1520
1565
|
onUpdate: (r) => sink.row(r),
|
|
1521
1566
|
});
|
|
1522
1567
|
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
@@ -1645,6 +1690,16 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1645
1690
|
return { running: false };
|
|
1646
1691
|
}
|
|
1647
1692
|
|
|
1693
|
+
/**
|
|
1694
|
+
* `forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>` (#4544)
|
|
1695
|
+
* The built-in remote compute pool (`bin/pool/`). `pool router` runs in the foreground (for a systemd-user unit:
|
|
1696
|
+
* `ExecStart=forgedock pool router`); `router install` installs that unit. `enroll` prints the one-time install command.
|
|
1697
|
+
*/
|
|
1698
|
+
export async function poolFromCli(argv, deps = {}) {
|
|
1699
|
+
const { runPoolCli } = await import("./pool/cli.mjs");
|
|
1700
|
+
return (deps.runPoolCli ?? runPoolCli)(argv, deps.poolDeps ?? {});
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1648
1703
|
/**
|
|
1649
1704
|
* `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
|
|
1650
1705
|
* Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
|
|
@@ -1787,6 +1842,8 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
1787
1842
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
1788
1843
|
* `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
|
|
1789
1844
|
* issue for a mechanical needs-human, default 1, 0 turns recovery off.
|
|
1845
|
+
* `orchestration.base_sync.enabled` (#4525; default true, false opts out) and `base_sync.union_globs` (comma list of globs of
|
|
1846
|
+
* append-only files whose two-sided line additions a parked PR's base sync keeps both of).
|
|
1790
1847
|
*/
|
|
1791
1848
|
export function readOrchestration(cwd = process.cwd()) {
|
|
1792
1849
|
let o = {};
|
|
@@ -1796,7 +1853,10 @@ export function readOrchestration(cwd = process.cwd()) {
|
|
|
1796
1853
|
const gp = int(o.global_parallel, null);
|
|
1797
1854
|
const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
|
|
1798
1855
|
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
|
|
1799
|
-
recovery: { maxAttempts: recoveryMax }
|
|
1856
|
+
recovery: { maxAttempts: recoveryMax },
|
|
1857
|
+
// #4525: parked-PR base sync. The parser flattens one level, so `base_sync.enabled|union_globs` arrive as `enabled|union_globs` (or flat `base_sync_*`).
|
|
1858
|
+
baseSync: { enabled: String(o.base_sync_enabled ?? o.enabled ?? "true").trim().toLowerCase() !== "false",
|
|
1859
|
+
unionGlobs: String(o.base_sync_union_globs ?? o.union_globs ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } };
|
|
1800
1860
|
}
|
|
1801
1861
|
|
|
1802
1862
|
/**
|
package/bin/forgedock.mjs
CHANGED
|
@@ -1878,6 +1878,12 @@ function selfUpdateGlobalInstall(version) {
|
|
|
1878
1878
|
}
|
|
1879
1879
|
}
|
|
1880
1880
|
|
|
1881
|
+
/** #4545: after an update, push the new code + lane image to pool agents (no-op without a pool router config). */
|
|
1882
|
+
async function upgradePoolAgents() {
|
|
1883
|
+
const { upgradeAgentsAfterUpdate } = await import("./pool/version.mjs");
|
|
1884
|
+
await upgradeAgentsAfterUpdate({ warn: (m) => console.log(` ${YELLOW}${m}${RESET}`) });
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1881
1887
|
async function update() {
|
|
1882
1888
|
console.log("");
|
|
1883
1889
|
console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
|
|
@@ -1994,6 +2000,7 @@ async function update() {
|
|
|
1994
2000
|
printGitCloneChangelogSummary(before, after);
|
|
1995
2001
|
}
|
|
1996
2002
|
await relinkAndHint();
|
|
2003
|
+
await upgradePoolAgents();
|
|
1997
2004
|
} catch (err) {
|
|
1998
2005
|
console.log(
|
|
1999
2006
|
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
@@ -2081,6 +2088,7 @@ async function update() {
|
|
|
2081
2088
|
}
|
|
2082
2089
|
|
|
2083
2090
|
await relinkAndHint();
|
|
2091
|
+
await upgradePoolAgents();
|
|
2084
2092
|
}
|
|
2085
2093
|
console.log("");
|
|
2086
2094
|
}
|
|
@@ -3296,6 +3304,7 @@ function help() {
|
|
|
3296
3304
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3297
3305
|
["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)"],
|
|
3298
3306
|
["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"],
|
|
3307
|
+
["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"],
|
|
3299
3308
|
["npx forgedock gc [--apply] [--older-than 6h] [--docker] [--worktrees] [--branches] [--tmp] [--processes] [--sandboxes]", "Report (dry-run default) and reclaim ForgeDock-owned leftovers: stale lane ledgers, forgedock-labelled containers/volumes, lean-*/agent-* worktrees, abandoned lane branches, ~/.forge/tmp scratch, orphaned lane processes, orphaned lane sandboxes; unowned resources and unpushed work are only reported"],
|
|
3300
3309
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3301
3310
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
@@ -3876,7 +3885,7 @@ async function watch() {
|
|
|
3876
3885
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3877
3886
|
const KNOWN_COMMANDS = new Set([
|
|
3878
3887
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3879
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "check", "help", "--help", "-h",
|
|
3888
|
+
"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",
|
|
3880
3889
|
"version", "--version", "-v",
|
|
3881
3890
|
]);
|
|
3882
3891
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4141,6 +4150,17 @@ switch (command) {
|
|
|
4141
4150
|
}
|
|
4142
4151
|
break;
|
|
4143
4152
|
}
|
|
4153
|
+
case "pool": {
|
|
4154
|
+
// #4544: the built-in remote compute pool (router, enrollment, status, upgrade, remove)
|
|
4155
|
+
const { poolFromCli } = await import("./engine-cli.mjs");
|
|
4156
|
+
try {
|
|
4157
|
+
await poolFromCli(restArgs);
|
|
4158
|
+
} catch (err) {
|
|
4159
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4160
|
+
exitCode = String(err.message).startsWith("usage:") ? 2 : 1;
|
|
4161
|
+
}
|
|
4162
|
+
break;
|
|
4163
|
+
}
|
|
4144
4164
|
case "sched": {
|
|
4145
4165
|
const { schedFromCli } = await import("./engine-cli.mjs");
|
|
4146
4166
|
try {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Base sync of a parked PR (#4525): a lane that ended needs-human leaves its PR open and nothing owns the branch, so it
|
|
5
|
+
* rots as the base moves. `syncParked` merges the base into the PR branch in a throwaway worktree. A clean merge, or one
|
|
6
|
+
* whose only conflicts are hunks where both sides purely added lines in a file matching `base_sync.union_globs`
|
|
7
|
+
* (`unionResolve`: keep both), is pushed as a plain merge commit (never forced). Any other conflict is aborted and its
|
|
8
|
+
* paths returned, the branch untouched. Deterministic (git only, no LLM); sequence/journal files are never resolved here
|
|
9
|
+
* (they belong to sequence.mjs). Never throws.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
import { seqNumber } from "./sequence.mjs";
|
|
15
|
+
|
|
16
|
+
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
17
|
+
|
|
18
|
+
/** Glob (`*` within a segment, `**` across, `?`) to a RegExp; a pattern with no `/` matches the file name anywhere. */
|
|
19
|
+
const globRe = (g) => {
|
|
20
|
+
const body = String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\u0000").replace(/\*\*/g, "\u0001").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, "(?:.*/)?").replace(/\u0001/g, ".*");
|
|
21
|
+
return new RegExp(`^${String(g).includes("/") ? "" : "(?:.*/)?"}${body}$`);
|
|
22
|
+
};
|
|
23
|
+
export const matchesGlobs = (path, globs = []) => globs.some((g) => String(g).trim() && globRe(String(g).trim()).test(String(path)));
|
|
24
|
+
|
|
25
|
+
/** Never resolved here: a migration journal and numbered sequence files are sequence.mjs's. */
|
|
26
|
+
export const isSequenceFile = (path) => basename(String(path)) === "_journal.json" || seqNumber(path) !== null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a diff3-style conflicted text (`<<<<<<<` ours, `|||||||` base, `=======` theirs, `>>>>>>>`): a hunk resolves
|
|
30
|
+
* only when its base section is empty (both sides only added lines), keeping ours then theirs. Returns the resolved
|
|
31
|
+
* text, or null when the text has no conflict, no base section, or a hunk where either side changed or removed base lines.
|
|
32
|
+
*/
|
|
33
|
+
export function unionResolve(text) {
|
|
34
|
+
const src = String(text ?? "").split("\n");
|
|
35
|
+
const out = [];
|
|
36
|
+
let i = 0, hunks = 0;
|
|
37
|
+
while (i < src.length) {
|
|
38
|
+
if (!src[i].startsWith("<<<<<<<")) { out.push(src[i++]); continue; }
|
|
39
|
+
const ours = [], base = [], theirs = [];
|
|
40
|
+
let part = ours, hasBase = false, closed = false;
|
|
41
|
+
for (i++; i < src.length; i++) {
|
|
42
|
+
const l = src[i];
|
|
43
|
+
if (l.startsWith("|||||||")) { part = base; hasBase = true; } else if (l.startsWith("=======") && part !== theirs) part = theirs;
|
|
44
|
+
else if (l.startsWith(">>>>>>>") && part === theirs) { closed = true; i++; break; } else part.push(l);
|
|
45
|
+
}
|
|
46
|
+
if (!closed || !hasBase || base.length) return null;
|
|
47
|
+
out.push(...ours, ...theirs); hunks++;
|
|
48
|
+
}
|
|
49
|
+
return hunks ? out.join("\n") : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{git:(args:string[], o?:{cwd?:string})=>Promise<string>, base:string, branch:string, unionGlobs?:string[], last?:?string, tmp?:string}} p
|
|
54
|
+
* `git` runs in the repo checkout and throws on failure; `last` is the base sha already handled for this PR.
|
|
55
|
+
* @returns {Promise<{status:"unchanged"|"up-to-date"|"merged"|"conflict"|"error", base?:string, paths?:string[], detail?:string}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function syncParked({ git, base, branch, unionGlobs = [], last = null, tmp = tmpdir() }) {
|
|
58
|
+
let wt = null, baseSha;
|
|
59
|
+
try {
|
|
60
|
+
await git(["fetch", "origin", base, branch]);
|
|
61
|
+
baseSha = String(await git(["rev-parse", `origin/${base}`])).trim();
|
|
62
|
+
if (last && last === baseSha) return { status: "unchanged", base: baseSha };
|
|
63
|
+
try { await git(["merge-base", "--is-ancestor", `origin/${base}`, `origin/${branch}`]); return { status: "up-to-date", base: baseSha }; } catch { /* behind */ }
|
|
64
|
+
wt = mkdtempSync(join(tmp, "basesync-"));
|
|
65
|
+
await git(["worktree", "add", "--detach", wt, `origin/${branch}`]);
|
|
66
|
+
const g = (a) => git(a, { cwd: wt });
|
|
67
|
+
const ident = String(await g(["config", "user.email"]).catch(() => "")).trim() ? [] : ["-c", "user.name=ForgeDock", "-c", "user.email=forgedock@users.noreply.github.com"];
|
|
68
|
+
let clean = true;
|
|
69
|
+
try { await g([...ident, "merge", "--no-edit", `origin/${base}`]); } catch { clean = false; }
|
|
70
|
+
if (!clean) {
|
|
71
|
+
const conflicted = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=U"]).catch(() => ""));
|
|
72
|
+
const stages = new Map();
|
|
73
|
+
for (const l of lines(await g(["-c", "core.quotepath=off", "ls-files", "-u"]).catch(() => ""))) {
|
|
74
|
+
const m = /^\d+ \S+ (\d)\t(.+)$/.exec(l); if (m) stages.set(m[2], (stages.get(m[2]) ?? 0) + (1 << Number(m[1])));
|
|
75
|
+
}
|
|
76
|
+
const fixes = [], refused = [];
|
|
77
|
+
for (const f of conflicted) {
|
|
78
|
+
let resolved = null;
|
|
79
|
+
if (stages.get(f) === 14 && !isSequenceFile(f) && matchesGlobs(f, unionGlobs)) { // stages 1,2,3 present: both sides edited an existing file
|
|
80
|
+
try { await g(["checkout", "--conflict=diff3", "--", f]); resolved = unionResolve(readFileSync(join(wt, f), "utf8")); } catch { resolved = null; }
|
|
81
|
+
}
|
|
82
|
+
if (resolved === null) refused.push(f); else fixes.push([f, resolved]);
|
|
83
|
+
}
|
|
84
|
+
if (!conflicted.length || refused.length) {
|
|
85
|
+
await g(["merge", "--abort"]).catch(() => {});
|
|
86
|
+
return { status: "conflict", base: baseSha, paths: refused.length ? refused : ["(unknown files)"] };
|
|
87
|
+
}
|
|
88
|
+
for (const [f, text] of fixes) { writeFileSync(join(wt, f), text); await g(["add", "--", f]); }
|
|
89
|
+
await g([...ident, "commit", "--no-edit"]);
|
|
90
|
+
}
|
|
91
|
+
await git(["push", "origin", `${(await g(["rev-parse", "HEAD"])).trim()}:refs/heads/${branch}`]); // plain push: a rejected race is an error, never forced
|
|
92
|
+
return { status: "merged", base: baseSha };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { status: "error", ...(baseSha ? { base: baseSha } : {}), detail: String(e?.message ?? e).split("\n")[0].slice(0, 200) };
|
|
95
|
+
} finally {
|
|
96
|
+
if (wt) { await git(["worktree", "remove", "--force", wt]).catch(() => {}); try { rmSync(wt, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
97
|
+
}
|
|
98
|
+
}
|