forgedock 1.35.0 → 1.36.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/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +113 -18
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/lean/batch.mjs +42 -2
- package/bin/lean/batch.test.mjs +101 -1
- package/bin/lean/conformance.mjs +14 -3
- package/bin/lean/conformance.test.mjs +26 -0
- package/bin/lean/effects.mjs +3 -0
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +37 -4
- package/bin/lean/lean.test.mjs +124 -5
- package/bin/lean/orchestrate.mjs +49 -5
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +24 -4
- package/bin/lean/schedd.test.mjs +54 -0
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +49 -0
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Versioned outbound lifecycle events (#4630, docs/EVENTS.md). The engine hands the same run-log shaped records it gives
|
|
5
|
+
* the telemetry seam to `makeEventEmitter`, which maps them to a stable envelope, appends it to a local JSONL file and,
|
|
6
|
+
* when forge.yaml `events.webhook_url` and the secret env var are both set, POSTs it signed with HMAC-SHA256. No model call.
|
|
7
|
+
* The emitter NEVER throws; FORGE_EVENTS=off disables it. Values are ids, counts, codes and costs only.
|
|
8
|
+
*/
|
|
9
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
10
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const EVENT_SCHEMA = "forgedock.event";
|
|
15
|
+
export const EVENT_VERSION = 1;
|
|
16
|
+
export const DEFAULT_SECRET_ENV = "FORGE_EVENTS_SECRET";
|
|
17
|
+
export const WEBHOOK_TIMEOUT_MS = 5000;
|
|
18
|
+
|
|
19
|
+
/** type -> data keys (documented in docs/EVENTS.md; the contract test pins them). */
|
|
20
|
+
export const EVENT_TYPES = {
|
|
21
|
+
"lane.picked_up": ["lane", "path"],
|
|
22
|
+
"contract.frozen": ["hash", "requirements", "deferred", "derived"],
|
|
23
|
+
"pr.opened": [],
|
|
24
|
+
"pr.merged": ["milestone", "code"],
|
|
25
|
+
"release.promoted": ["fix", "default_branch"],
|
|
26
|
+
"probe.passed": ["detail", "fix"],
|
|
27
|
+
"probe.failed": ["detail", "fix"],
|
|
28
|
+
"regression.detected": ["subject", "metric", "detail"],
|
|
29
|
+
"experiment.verdict": ["experiment", "verdict", "detail"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "boolean" || (typeof v === "string" && v !== ""));
|
|
33
|
+
const round6 = (n) => Math.round(n * 1e6) / 1e6;
|
|
34
|
+
const pickData = (type, src) => Object.fromEntries(EVENT_TYPES[type].filter((k) => scalar(src?.[k])).map((k) => [k, typeof src[k] === "string" ? src[k].slice(0, 200) : src[k]]));
|
|
35
|
+
|
|
36
|
+
/** Envelope for one event; null for an unknown type. `ctx` = {repo, issue, run, pr, cost_usd, wall_ms}. */
|
|
37
|
+
export function buildEvent(type, data = {}, ctx = {}, now = new Date()) {
|
|
38
|
+
if (!Object.hasOwn(EVENT_TYPES, type)) return null;
|
|
39
|
+
const int = (v) => (Number.isSafeInteger(v) && v > 0 ? v : null);
|
|
40
|
+
return {
|
|
41
|
+
schema: EVENT_SCHEMA, v: EVENT_VERSION, id: `${Math.floor(now.getTime() / 1000)}-${randomBytes(6).toString("hex")}`, type,
|
|
42
|
+
ts: now.toISOString().replace(/\.\d+Z$/, "Z"), repo: ctx.repo ?? null, issue: int(ctx.issue), pr: int(ctx.pr), run: ctx.run ?? null,
|
|
43
|
+
...(Number.isFinite(ctx.cost_usd) ? { cost_usd: round6(ctx.cost_usd) } : {}), ...(Number.isFinite(ctx.wall_ms) ? { wall_ms: Math.round(ctx.wall_ms) } : {}),
|
|
44
|
+
data: pickData(type, data),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Probe sweep row ({issue, outcome, detail, fix}) -> event type + data; null unless pass or fail. */
|
|
49
|
+
export function probeEvent(row) {
|
|
50
|
+
if (row?.outcome !== "pass" && row?.outcome !== "fail") return null;
|
|
51
|
+
return { type: row.outcome === "pass" ? "probe.passed" : "probe.failed", data: { detail: row.detail, fix: row.fix }, ctx: { issue: row.issue } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Monitor sweep row ({issue, outcome, detail, regression}) -> regression.detected; null unless a fail that filed a regression issue. */
|
|
55
|
+
export function regressionEvent(row) {
|
|
56
|
+
if (row?.outcome !== "fail" || !Number.isSafeInteger(row.regression) || row.regression < 1) return null;
|
|
57
|
+
return { type: "regression.detected", data: { subject: `post-deploy check of #${row.issue}`, metric: "post_deploy_check", detail: row.detail }, ctx: { issue: row.issue } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Experiment report ({id, targets, targets_met}) -> experiment.verdict (met | missed | inconclusive); null without an id. */
|
|
61
|
+
export function experimentEvent(report) {
|
|
62
|
+
if (!report || typeof report.id !== "string" || !report.id) return null;
|
|
63
|
+
const passes = Object.values(report.targets ?? {}).map((t) => t?.pass);
|
|
64
|
+
const verdict = report.targets_met === true ? "met" : passes.includes(false) ? "missed" : "inconclusive";
|
|
65
|
+
return { type: "experiment.verdict", data: { experiment: report.id, verdict, detail: `${passes.length} target(s)` }, ctx: {} };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A fix reached the default branch -> release.promoted; null without a fix. Reserved: no detection emits it yet. */
|
|
69
|
+
export function promotedEvent({ fix, default_branch } = {}) {
|
|
70
|
+
return typeof fix === "string" && fix ? { type: "release.promoted", data: { fix, default_branch }, ctx: {} } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pure mapping of one run-log record to {type, data, ctx} or null. `cost` is the lane's running spend, kept by the caller
|
|
75
|
+
* from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
|
|
76
|
+
*/
|
|
77
|
+
export function mapRunRecord(rec, { cost = 0 } = {}) {
|
|
78
|
+
if (!rec || typeof rec !== "object") return null;
|
|
79
|
+
switch (rec.event) {
|
|
80
|
+
case "RUN_START": return { type: "lane.picked_up", data: rec, ctx: {} };
|
|
81
|
+
case "CONTRACT_FROZEN": return { type: "contract.frozen", data: rec, ctx: {} };
|
|
82
|
+
case "PR_OPENED": return { type: "pr.opened", data: {}, ctx: { pr: rec.pr } };
|
|
83
|
+
case "RUN_TERMINAL":
|
|
84
|
+
if (rec.reason !== "merged") return null;
|
|
85
|
+
return { type: "pr.merged", data: rec, ctx: { pr: rec.pr, cost_usd: cost, wall_ms: rec.wall_ms } };
|
|
86
|
+
default: return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** HMAC-SHA256 hex of `${ts}.${body}`; the receiver recomputes it from X-Forge-Timestamp and the raw body. */
|
|
91
|
+
export const sign = (secret, ts, body) => createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
|
|
92
|
+
|
|
93
|
+
/** https, or http only for a loopback host; anything else is not a usable webhook. */
|
|
94
|
+
export function webhookUrlOk(u) {
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(u);
|
|
97
|
+
return url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
|
|
98
|
+
} catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** POST one envelope; resolves true on 2xx. Never throws. */
|
|
102
|
+
export async function postWebhook(event, { url, secret, fetchFn = globalThis.fetch, timeoutMs = WEBHOOK_TIMEOUT_MS, now = Date.now } = {}) {
|
|
103
|
+
try {
|
|
104
|
+
if (!url || !secret || !webhookUrlOk(url) || typeof fetchFn !== "function") return false;
|
|
105
|
+
const body = JSON.stringify(event);
|
|
106
|
+
const ts = String(Math.floor(now() / 1000));
|
|
107
|
+
const res = await fetchFn(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
|
108
|
+
headers: { "content-type": "application/json", "x-forge-event": event.type, "x-forge-timestamp": ts, "x-forge-signature": `sha256=${sign(secret, ts, body)}` }, body });
|
|
109
|
+
return Boolean(res?.ok);
|
|
110
|
+
} catch { return false; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Local sink path: `dir` (config or FORGE_EVENTS_DIR), else ~/.forge/events; one file per repo. */
|
|
114
|
+
export const sinkPath = (repo, dir) => join(dir || join(homedir(), ".forge", "events"), `${String(repo || "unknown").replace(/[^\w.-]/g, "_")}.jsonl`);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {{repo?: string|null, config?: {webhookUrl?: string, secretEnv?: string, dir?: string}, env?: object, fetchFn?: Function, now?: () => Date, run?: string}} o
|
|
118
|
+
* @returns {{emit: (rec: object) => Promise<void>, emitEvent: (type: string, data?: object, ctx?: object) => Promise<object|null>}}
|
|
119
|
+
*/
|
|
120
|
+
export function makeEventEmitter({ repo = null, config = {}, env = process.env, fetchFn, now = () => new Date(), run = null } = {}) {
|
|
121
|
+
let cost = 0, runId = run;
|
|
122
|
+
const off = () => (env.FORGE_EVENTS || "on") === "off";
|
|
123
|
+
async function emitEvent(type, data = {}, ctx = {}) {
|
|
124
|
+
try {
|
|
125
|
+
if (off()) return null;
|
|
126
|
+
const ev = buildEvent(type, data, { repo, run: runId, ...ctx }, now());
|
|
127
|
+
if (!ev) return null;
|
|
128
|
+
try {
|
|
129
|
+
const file = sinkPath(repo, env.FORGE_EVENTS_DIR || config.dir);
|
|
130
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
131
|
+
appendFileSync(file, `${JSON.stringify(ev)}\n`);
|
|
132
|
+
} catch { /* a lost local line costs completeness only */ }
|
|
133
|
+
const secret = env[config.secretEnv || DEFAULT_SECRET_ENV];
|
|
134
|
+
if (config.webhookUrl && secret) await postWebhook(ev, { url: config.webhookUrl, secret, fetchFn, now: () => now().getTime() });
|
|
135
|
+
return ev;
|
|
136
|
+
} catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
async function emit(rec) {
|
|
139
|
+
try {
|
|
140
|
+
if (rec?.event === "PHASE_ATTEMPT_USAGE") {
|
|
141
|
+
const c = Number.isFinite(rec.cost_usd_delta) ? rec.cost_usd_delta : rec.cost_usd;
|
|
142
|
+
if (Number.isFinite(c)) cost += c;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (rec?.event === "RUN_START" && typeof rec.run === "string") runId = rec.run;
|
|
146
|
+
const m = mapRunRecord(rec, { cost });
|
|
147
|
+
if (!m) return;
|
|
148
|
+
await emitEvent(m.type, m.data, { issue: rec.issue, ...m.ctx });
|
|
149
|
+
} catch { /* events never fail a run */ }
|
|
150
|
+
}
|
|
151
|
+
return { emit, emitEvent };
|
|
152
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Contract test for the outbound event stream (#4630, docs/EVENTS.md): changing a type, key or the signature is a breaking change.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { createHmac } from "node:crypto";
|
|
10
|
+
import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
|
|
11
|
+
|
|
12
|
+
const tmp = () => tmpDir("forge-events-");
|
|
13
|
+
const fixed = () => new Date("2026-10-26T12:00:00Z");
|
|
14
|
+
const lines = (dir) => readFileSync(join(dir, "o_r.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
|
15
|
+
|
|
16
|
+
describe("event contract (v1)", () => {
|
|
17
|
+
it("documents exactly the named lifecycle types", () => {
|
|
18
|
+
assert.deepEqual(Object.keys(EVENT_TYPES), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged", "release.promoted", "probe.passed", "probe.failed", "regression.detected", "experiment.verdict"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("every envelope has the same top-level keys and only documented data keys", () => {
|
|
22
|
+
for (const [type, keys] of Object.entries(EVENT_TYPES)) {
|
|
23
|
+
const data = Object.fromEntries(keys.map((k) => [k, "x"]));
|
|
24
|
+
const e = buildEvent(type, { ...data, secret: "no" }, { repo: "o/r", issue: 3, pr: 4, run: "core_3_1", cost_usd: 1.23456789, wall_ms: 10.4 }, fixed());
|
|
25
|
+
assert.deepEqual(Object.keys(e), ["schema", "v", "id", "type", "ts", "repo", "issue", "pr", "run", "cost_usd", "wall_ms", "data"]);
|
|
26
|
+
assert.equal(e.schema, "forgedock.event"); assert.equal(e.v, EVENT_VERSION); assert.equal(e.ts, "2026-10-26T12:00:00Z");
|
|
27
|
+
assert.equal(e.cost_usd, 1.234568); assert.equal(e.wall_ms, 10);
|
|
28
|
+
assert.deepEqual(Object.keys(e.data), keys);
|
|
29
|
+
}
|
|
30
|
+
assert.equal(buildEvent("nope", {}, {}, fixed()), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("maps run-log records and sums attempt cost onto pr.merged", async () => {
|
|
34
|
+
const dir = tmp();
|
|
35
|
+
const { emit } = makeEventEmitter({ repo: "o/r", config: { dir }, env: {}, now: fixed });
|
|
36
|
+
await emit({ event: "RUN_START", run: "core_9_5", issue: 9, lane: "staging", path: "core" });
|
|
37
|
+
await emit({ event: "CONTRACT_FROZEN", issue: 9, hash: "abc", requirements: 2, deferred: 1 });
|
|
38
|
+
await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 5, cost_usd_delta: 0.5 });
|
|
39
|
+
await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 0.25 });
|
|
40
|
+
await emit({ event: "PR_OPENED", issue: 9, pr: 40 });
|
|
41
|
+
await emit({ event: "RUN_TERMINAL", issue: 9, reason: "needs-human", wall_ms: 1 });
|
|
42
|
+
await emit({ event: "RUN_TERMINAL", issue: 9, reason: "merged", pr: 40, wall_ms: 90000, code: "ok", milestone: "m1" });
|
|
43
|
+
const got = lines(dir);
|
|
44
|
+
assert.deepEqual(got.map((e) => e.type), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged"]);
|
|
45
|
+
assert.deepEqual(got[0].data, { lane: "staging", path: "core" });
|
|
46
|
+
assert.equal(got[0].run, "core_9_5"); assert.equal(got[3].run, "core_9_5");
|
|
47
|
+
assert.deepEqual(got[1].data, { hash: "abc", requirements: 2, deferred: 1 });
|
|
48
|
+
assert.equal(got[2].pr, 40);
|
|
49
|
+
assert.equal(got[3].cost_usd, 0.75); assert.equal(got[3].wall_ms, 90000); assert.equal(got[3].issue, 9);
|
|
50
|
+
assert.equal(mapRunRecord({ event: "CI_PENDING_WAIT" }), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("maps probe rows: only pass and fail", () => {
|
|
54
|
+
assert.equal(probeEvent({ issue: 1, outcome: "awaiting-promotion" }), null);
|
|
55
|
+
assert.equal(probeEvent({ issue: 1, outcome: "pass", detail: "exit 0", fix: "abc" }).type, "probe.passed");
|
|
56
|
+
assert.equal(probeEvent({ issue: 1, outcome: "fail" }).type, "probe.failed");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("regression, experiment and promoted builders (#4632)", () => {
|
|
61
|
+
it("regressionEvent: only a fail row that filed a regression issue", () => {
|
|
62
|
+
assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
|
|
63
|
+
assert.equal(regressionEvent({ issue: 5, outcome: "fail" }), null);
|
|
64
|
+
const r = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "broke" });
|
|
65
|
+
assert.equal(r.type, "regression.detected"); assert.equal(r.ctx.issue, 5);
|
|
66
|
+
assert.deepEqual(r.data, { subject: "post-deploy check of #5", metric: "post_deploy_check", detail: "broke" });
|
|
67
|
+
});
|
|
68
|
+
it("experimentEvent: met, missed, inconclusive", () => {
|
|
69
|
+
const t = (...p) => Object.fromEntries(p.map((pass, i) => [`t${i}`, { pass }]));
|
|
70
|
+
assert.equal(experimentEvent({ id: "e", targets: t(true), targets_met: true }).data.verdict, "met");
|
|
71
|
+
assert.equal(experimentEvent({ id: "e", targets: t(true, false), targets_met: false }).data.verdict, "missed");
|
|
72
|
+
assert.equal(experimentEvent({ id: "e", targets: t(null), targets_met: false }).data.verdict, "inconclusive");
|
|
73
|
+
assert.equal(experimentEvent({}), null);
|
|
74
|
+
});
|
|
75
|
+
it("promotedEvent needs a fix; types and keys stay pinned", () => {
|
|
76
|
+
assert.equal(promotedEvent({}), null);
|
|
77
|
+
assert.deepEqual(promotedEvent({ fix: "abc", default_branch: "main" }).data, { fix: "abc", default_branch: "main" });
|
|
78
|
+
assert.deepEqual(EVENT_TYPES["regression.detected"], ["subject", "metric", "detail"]);
|
|
79
|
+
assert.deepEqual(EVENT_TYPES["experiment.verdict"], ["experiment", "verdict", "detail"]);
|
|
80
|
+
});
|
|
81
|
+
it("a regression row is written to the sink", async () => {
|
|
82
|
+
const dir = tmp();
|
|
83
|
+
const p = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "d" });
|
|
84
|
+
await makeEventEmitter({ repo: "o/r", config: { dir }, env: {} }).emitEvent(p.type, p.data, p.ctx);
|
|
85
|
+
const ev = JSON.parse(readFileSync(join(dir, "o_r.jsonl"), "utf8"));
|
|
86
|
+
assert.equal(ev.type, "regression.detected"); assert.equal(ev.issue, 5);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("webhook", () => {
|
|
91
|
+
const cfg = { webhookUrl: "https://hooks.example/x", secretEnv: "MY_SECRET", dir: undefined };
|
|
92
|
+
it("signs `${timestamp}.${body}` with HMAC-SHA256 from the env secret", async () => {
|
|
93
|
+
const calls = [];
|
|
94
|
+
const fetchFn = async (url, init) => { calls.push([url, init]); return { ok: true }; };
|
|
95
|
+
const { emitEvent } = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: tmp() }, env: { MY_SECRET: "s3" }, fetchFn, now: fixed });
|
|
96
|
+
const ev = await emitEvent("pr.opened", {}, { issue: 1, pr: 2 });
|
|
97
|
+
assert.equal(calls.length, 1);
|
|
98
|
+
const [url, init] = calls[0];
|
|
99
|
+
assert.equal(url, cfg.webhookUrl); assert.equal(init.method, "POST");
|
|
100
|
+
assert.equal(init.body, JSON.stringify(ev));
|
|
101
|
+
const ts = init.headers["x-forge-timestamp"];
|
|
102
|
+
assert.equal(ts, String(Math.floor(fixed().getTime() / 1000)));
|
|
103
|
+
assert.equal(init.headers["x-forge-signature"], `sha256=${createHmac("sha256", "s3").update(`${ts}.${init.body}`).digest("hex")}`);
|
|
104
|
+
assert.equal(init.headers["x-forge-event"], "pr.opened");
|
|
105
|
+
assert.equal(sign("s3", ts, init.body), init.headers["x-forge-signature"].slice(7));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("sends nothing without the secret, with a non-https URL, or when off", async () => {
|
|
109
|
+
let n = 0;
|
|
110
|
+
const fetchFn = async () => { n++; return { ok: true }; };
|
|
111
|
+
for (const [config, env] of [[cfg, {}], [{ ...cfg, webhookUrl: "http://evil.example/x" }, { MY_SECRET: "s" }], [cfg, { MY_SECRET: "s", FORGE_EVENTS: "off" }]]) {
|
|
112
|
+
await makeEventEmitter({ repo: "o/r", config: { ...config, dir: tmp() }, env, fetchFn }).emitEvent("pr.opened", {}, { issue: 1 });
|
|
113
|
+
}
|
|
114
|
+
assert.equal(n, 0);
|
|
115
|
+
assert.equal(webhookUrlOk("http://localhost:3000/h"), true); assert.equal(webhookUrlOk("ftp://x"), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("off writes no file; sink and webhook failures never throw", async () => {
|
|
119
|
+
const dir = tmp();
|
|
120
|
+
await makeEventEmitter({ repo: "o/r", config: { dir }, env: { FORGE_EVENTS: "off" } }).emitEvent("pr.opened", {}, { issue: 1 });
|
|
121
|
+
assert.equal(existsSync(join(dir, "o_r.jsonl")), false);
|
|
122
|
+
const boom = async () => { throw new Error("net"); };
|
|
123
|
+
writeFileSync(join(dir, "f"), ""); const bad = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: join(dir, "f", "x") }, env: { MY_SECRET: "s" }, fetchFn: boom });
|
|
124
|
+
assert.ok(await bad.emitEvent("pr.opened", {}, { issue: 1 }));
|
|
125
|
+
await bad.emit({ event: "PR_OPENED", pr: 1, issue: 1 });
|
|
126
|
+
assert.equal(await postWebhook({ type: "x" }, { url: cfg.webhookUrl, secret: "s", fetchFn: boom }), false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
|
|
5
5
|
* seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
|
|
6
|
-
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
|
|
6
|
+
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (experiment + arm when forge.yaml declares one, forge#4629; attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
|
|
7
7
|
* lean adds baseline_tokens and lane_profile, forge#4395)
|
|
8
8
|
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
|
|
9
9
|
* builder_tier/class/model/escalated 0|1, forge#4394)
|
|
@@ -59,14 +59,15 @@ export function mapRecord(rec, wallMs) {
|
|
|
59
59
|
cost_usd_session: Number.isFinite(rec.cost_usd_session) ? Math.round(rec.cost_usd_session * 1e6) / 1e6 : undefined,
|
|
60
60
|
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
61
61
|
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
|
62
|
-
baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile,
|
|
62
|
+
baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile, experiment: rec.experiment, arm: rec.arm, // #4629
|
|
63
63
|
}) };
|
|
64
64
|
}
|
|
65
65
|
if (rec.event === "RUN_TERMINAL") {
|
|
66
66
|
return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
|
|
67
67
|
milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would,
|
|
68
68
|
builder_tier: rec.builder_tier, builder_class: rec.builder_class, builder_model: rec.builder_model, // #4394: the model per merge
|
|
69
|
-
builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined
|
|
69
|
+
builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined,
|
|
70
|
+
experiment: rec.experiment, arm: rec.arm }) }; // #4629
|
|
70
71
|
}
|
|
71
72
|
if (rec.event === "SANDBOX_USAGE") {
|
|
72
73
|
return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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, probeEvent, regressionEvent, 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 [probeEvent(row), regressionEvent(row)]) if (p) 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`,
|
|
@@ -876,8 +903,8 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
876
903
|
try {
|
|
877
904
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
878
905
|
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() } : {}) });
|
|
906
|
+
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
907
|
+
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() } : {}) });
|
|
881
908
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
882
909
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
883
910
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1075,6 +1102,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
1075
1102
|
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
1076
1103
|
* `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
|
|
1077
1104
|
* (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
|
|
1105
|
+
* `--requeue <id> N... [--reason]` (#4550) returns a terminal issue (and the dependents skipped behind it) to pending after an operator decision.
|
|
1078
1106
|
* `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
|
|
1079
1107
|
* SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
|
|
1080
1108
|
*/
|
|
@@ -1088,8 +1116,8 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
1088
1116
|
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
1089
1117
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
1090
1118
|
|
|
1091
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --cancel <id> | --hold <id> <issue...> | --release <id> <issue...>";
|
|
1092
|
-
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
1119
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...>";
|
|
1120
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason"];
|
|
1093
1121
|
const issuesOf = (argv) => {
|
|
1094
1122
|
const issues = [];
|
|
1095
1123
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1169,20 +1197,55 @@ async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) =
|
|
|
1169
1197
|
return { ok: true, batch: id, changed: res.changed ?? [] };
|
|
1170
1198
|
}
|
|
1171
1199
|
|
|
1200
|
+
/**
|
|
1201
|
+
* `--requeue <id> N... [--reason "..."]` (#4550): after an operator decision, a terminal issue of the batch (and the dependents
|
|
1202
|
+
* skipped behind it) goes back to pending. Closed issues and issues still labelled `needs-human` are refused before anything is
|
|
1203
|
+
* written. Through the daemon (`viaDaemon`, null when none runs: the batch's `requeue` file, and a finished batch's report is
|
|
1204
|
+
* cleared so `--resume` runs it again). The runner applies the entries and posts the FORGE:DECISION.
|
|
1205
|
+
*/
|
|
1206
|
+
async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1207
|
+
if (!id || String(id).startsWith("--") || !tokens.length) throw new Error(ORCH_USAGE);
|
|
1208
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
1209
|
+
const b = batchMod.openBatch(root, id);
|
|
1210
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1211
|
+
const repo = b.readPlan()?.repo ?? null;
|
|
1212
|
+
const refused = [], open = [];
|
|
1213
|
+
for (const t of tokens) {
|
|
1214
|
+
if (!/^#?\d+$/.test(String(t))) { refused.push({ issue: t, reason: "not an integer" }); continue; }
|
|
1215
|
+
const n = parseInt(String(t).replace("#", ""), 10);
|
|
1216
|
+
if (open.includes(n) || refused.some((x) => x.issue === n)) continue;
|
|
1217
|
+
let v = null;
|
|
1218
|
+
try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,labels"])); } catch { v = null; }
|
|
1219
|
+
if (!v) refused.push({ issue: n, reason: "unreadable" });
|
|
1220
|
+
else if (v.state !== "OPEN") refused.push({ issue: n, reason: "closed" });
|
|
1221
|
+
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" });
|
|
1222
|
+
else open.push(n);
|
|
1223
|
+
}
|
|
1224
|
+
const why = reason || "operator decision", by = "operator";
|
|
1225
|
+
let res = { requeued: [], refused: [] };
|
|
1226
|
+
if (open.length) {
|
|
1227
|
+
res = (viaDaemon ? await viaDaemon(open, why, by) : null) ?? b.requeue(open, { reason: why, by });
|
|
1228
|
+
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
|
|
1229
|
+
}
|
|
1230
|
+
const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
|
|
1231
|
+
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(", ")}` : ""}`);
|
|
1232
|
+
return out;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1172
1235
|
/**
|
|
1173
1236
|
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1174
1237
|
* A finished batch (report written) is left alone.
|
|
1175
1238
|
*/
|
|
1176
|
-
async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1239
|
+
async function cancelBatch({ id, root, stopRemote = false, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1177
1240
|
if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
|
|
1178
1241
|
let res = viaDaemon ? await viaDaemon() : null;
|
|
1179
1242
|
if (!res) {
|
|
1180
1243
|
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1181
1244
|
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1182
1245
|
if (b.readReport()) res = { cancelled: false, finished: true };
|
|
1183
|
-
else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
|
|
1246
|
+
else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
|
|
1184
1247
|
}
|
|
1185
|
-
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1248
|
+
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
1249
|
: `batch ${id}: already finished`);
|
|
1187
1250
|
return { ok: true, batch: id, cancelled: !!res.cancelled };
|
|
1188
1251
|
}
|
|
@@ -1218,6 +1281,11 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1218
1281
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1219
1282
|
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1220
1283
|
};
|
|
1284
|
+
const requeueVia = (id) => async (issues, reason, by) => {
|
|
1285
|
+
let sock = null;
|
|
1286
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1287
|
+
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
|
|
1288
|
+
};
|
|
1221
1289
|
const holdVia = (id) => async (op, issues) => {
|
|
1222
1290
|
let sock = null;
|
|
1223
1291
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
@@ -1226,12 +1294,16 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1226
1294
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1227
1295
|
if (argv.includes("--cancel")) {
|
|
1228
1296
|
const id = flag(argv, "--cancel");
|
|
1229
|
-
return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
|
|
1297
|
+
return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
|
|
1230
1298
|
let sock = null;
|
|
1231
1299
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1232
|
-
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1300
|
+
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
|
|
1233
1301
|
} });
|
|
1234
1302
|
}
|
|
1303
|
+
if (argv.includes("--requeue")) {
|
|
1304
|
+
const i = argv.indexOf("--requeue"), id = argv[i + 1];
|
|
1305
|
+
return requeueBatch({ id, tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: requeueVia(id) });
|
|
1306
|
+
}
|
|
1235
1307
|
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1236
1308
|
const id = flag(argv, `--${op}`);
|
|
1237
1309
|
return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
|
|
@@ -1310,7 +1382,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1310
1382
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1311
1383
|
return { ok: true, batch: b.id, parallel: k };
|
|
1312
1384
|
}
|
|
1313
|
-
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
|
|
1385
|
+
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
|
|
1386
|
+
if (argv.includes("--requeue")) {
|
|
1387
|
+
const i = argv.indexOf("--requeue");
|
|
1388
|
+
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) });
|
|
1389
|
+
}
|
|
1314
1390
|
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1315
1391
|
return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
|
|
1316
1392
|
}
|
|
@@ -1376,7 +1452,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1376
1452
|
// #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
|
|
1377
1453
|
const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
|
|
1378
1454
|
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)})`); }
|
|
1455
|
+
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
1456
|
}
|
|
1381
1457
|
const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1382
1458
|
const branches = readBranches(cwd);
|
|
@@ -1400,7 +1476,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1400
1476
|
const { runCore } = deps.leanModule;
|
|
1401
1477
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1402
1478
|
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) } : {}) }) };
|
|
1479
|
+
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) } : {}) }) };
|
|
1404
1480
|
} else {
|
|
1405
1481
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1406
1482
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1417,6 +1493,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1417
1493
|
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1418
1494
|
execForNeeds = exec ?? null;
|
|
1419
1495
|
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1496
|
+
let remoteStop = null; // #4612: the sandbox provider's checkpoint stop, used only when the cancel asks for it
|
|
1420
1497
|
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1421
1498
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1422
1499
|
let gitDir = null;
|
|
@@ -1427,6 +1504,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1427
1504
|
const bud = exec.budget ?? {};
|
|
1428
1505
|
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1429
1506
|
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1507
|
+
remoteStop = remoteLanes.stopLane;
|
|
1430
1508
|
lanes = withPlacement(remoteLanes);
|
|
1431
1509
|
{
|
|
1432
1510
|
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
@@ -1472,9 +1550,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1472
1550
|
} else {
|
|
1473
1551
|
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1474
1552
|
}
|
|
1475
|
-
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
|
|
1553
|
+
// #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
1554
|
const { killLedgerPid } = await import("./lean/effects.mjs");
|
|
1477
|
-
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.
|
|
1555
|
+
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
|
|
1478
1556
|
lanes = { ...lanes, stopLane };
|
|
1479
1557
|
}
|
|
1480
1558
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
@@ -1580,7 +1658,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1580
1658
|
rows = await orchestrate({
|
|
1581
1659
|
...(estimate ? { estimate } : {}),
|
|
1582
1660
|
beforeAdmit,
|
|
1583
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
|
|
1661
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
|
|
1584
1662
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1585
1663
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1586
1664
|
...(remote ? { remote } : {}),
|
|
@@ -1728,6 +1806,15 @@ export function formatReclaimed(r) {
|
|
|
1728
1806
|
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
1729
1807
|
}
|
|
1730
1808
|
|
|
1809
|
+
/** #4627: the daemon's timer callback: re-run the due windowed post-deploy checks of the cwd repo. No repo, no work. */
|
|
1810
|
+
async function scheddDue() {
|
|
1811
|
+
const io = makeIo();
|
|
1812
|
+
const repo = await resolveDefaultRepo(io);
|
|
1813
|
+
if (!repo) return;
|
|
1814
|
+
const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
|
|
1815
|
+
await sweepDue({ repo, cwd: process.cwd(), io: (await import("./lean/effects.mjs")).makeLeanIo({ repo }), onResult: probeEventSink(repo) });
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1731
1818
|
/**
|
|
1732
1819
|
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1733
1820
|
*/
|
|
@@ -1742,7 +1829,7 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1742
1829
|
return { running: true, ...st };
|
|
1743
1830
|
}
|
|
1744
1831
|
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1745
|
-
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1832
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
|
|
1746
1833
|
if (!d.running) return d;
|
|
1747
1834
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
1748
1835
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
@@ -2037,13 +2124,14 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
2037
2124
|
}
|
|
2038
2125
|
|
|
2039
2126
|
/**
|
|
2040
|
-
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
|
|
2127
|
+
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N] [--due]` (#4399; `--due` #4627: re-run due windowed post-deploy checks)
|
|
2041
2128
|
* Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
|
|
2042
2129
|
* declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
|
|
2043
2130
|
*/
|
|
2044
2131
|
export async function probeFromCli(argv, deps = {}) {
|
|
2045
2132
|
const valued = ["--repo", "--limit"];
|
|
2046
2133
|
const issues = [];
|
|
2134
|
+
const due = argv.includes("--due"); // #4627
|
|
2047
2135
|
for (let i = 0; i < argv.length; i++) {
|
|
2048
2136
|
if (valued.includes(argv[i])) { i++; continue; }
|
|
2049
2137
|
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
@@ -2054,8 +2142,15 @@ export async function probeFromCli(argv, deps = {}) {
|
|
|
2054
2142
|
const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
|
|
2055
2143
|
if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
|
|
2056
2144
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
2145
|
+
if (due) {
|
|
2146
|
+
const { sweepDue, formatDueSummary } = await import("./lean/postdeploy-monitor.mjs");
|
|
2147
|
+
const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2148
|
+
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2149
|
+
console.log(formatDueSummary(rows) || "monitoring: nothing due");
|
|
2150
|
+
return { rows };
|
|
2151
|
+
}
|
|
2057
2152
|
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 } : {}) });
|
|
2153
|
+
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
2154
|
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2060
2155
|
console.log(formatProbeSummary(rows) || "probes: nothing to run");
|
|
2061
2156
|
return { rows };
|