forgedock 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/retry.mjs
CHANGED
|
@@ -43,3 +43,31 @@ export async function ghCacheRetry(call, cache) {
|
|
|
43
43
|
return await call(cache?.env?.());
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** #4662: an expired or revoked token (HTTP 401). 403 is a missing scope or a rate limit, which a refresh does not fix. */
|
|
48
|
+
export const GH_AUTH_RE = /HTTP 401|\b401\b[^\n]{0,40}(?:unauthorized|bad credentials)|bad credentials|requires authentication|authentication required|gh auth login|try authenticating/i;
|
|
49
|
+
export const GH_AUTH_RETRY_SECONDS = 120;
|
|
50
|
+
|
|
51
|
+
/** Runs scripts/refresh-bot-token.sh (or $FORGE_REFRESH_TOKEN_SCRIPT); resolves on exit 0, rejects otherwise. */
|
|
52
|
+
export async function runRefreshScript(env = process.env) {
|
|
53
|
+
const { execFile } = await import("node:child_process");
|
|
54
|
+
const script = env.FORGE_REFRESH_TOKEN_SCRIPT || new URL("../../scripts/refresh-bot-token.sh", import.meta.url).pathname;
|
|
55
|
+
return new Promise((res, rej) => execFile("bash", [script], { env, timeout: 60000 }, (err, _out, stderr) => (err ? rej(Object.assign(err, { stderr })) : res())));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* #4662: one refresh-and-retry for an auth failure. `refresh` runs once per `state` (shared across calls, so a dead token
|
|
60
|
+
* does not spawn a refresh per gh call); when the call still fails with an auth error the error carries
|
|
61
|
+
* `deferCode: "gh.auth"` so the lane ends `deferred` (requeued in `retryInSeconds`), never `engine-error`.
|
|
62
|
+
*/
|
|
63
|
+
export async function refreshAuthOnce(call, { refresh, state = { refreshed: false } }) {
|
|
64
|
+
try { return await call(); } catch (e) {
|
|
65
|
+
if (!GH_AUTH_RE.test(errText(e))) throw e;
|
|
66
|
+
if (!state.refreshed) {
|
|
67
|
+
state.refreshed = true;
|
|
68
|
+
try { await refresh(); state.ok = true; } catch { state.ok = false; }
|
|
69
|
+
if (state.ok) { try { const r = await call(); state.refreshed = false; return r; } catch (e2) { if (!GH_AUTH_RE.test(errText(e2))) throw e2; e = e2; } }
|
|
70
|
+
}
|
|
71
|
+
throw Object.assign(e, { deferCode: "gh.auth", deferDetail: `gh auth failed (HTTP 401) after a token refresh: ${errText(e).trim().split("\n")[0].slice(0, 200)}`, retryInSeconds: GH_AUTH_RETRY_SECONDS });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -186,9 +186,7 @@ describe("remote adapter (#4254)", () => {
|
|
|
186
186
|
const flaky = { ...fakeTransport(), status: async () => { throw new Error("ECONNRESET"); } };
|
|
187
187
|
const q = remoteProvider({ batch, repo: "o/r", transport: flaky, env: {} });
|
|
188
188
|
const h = await q.start({ issue: 6, args: [] });
|
|
189
|
-
|
|
190
|
-
for (let i = 0; i < 5; i++) seen.push(await q.status(h));
|
|
191
|
-
assert.deepEqual(seen, ["running", "running", "running", "running", "gone"]);
|
|
189
|
+
await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
|
|
192
190
|
});
|
|
193
191
|
|
|
194
192
|
it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
|
|
@@ -303,6 +301,13 @@ describe("pool provider (#4546)", () => {
|
|
|
303
301
|
assert.equal(Buffer.from(spec.env.CS_FORGE_YAML_B64, "base64").toString(), "verify:\n mode: ci\n");
|
|
304
302
|
});
|
|
305
303
|
|
|
304
|
+
it("#4685 a configured token_env that resolves empty falls back to the router token", async () => {
|
|
305
|
+
const { cfg } = setupPool();
|
|
306
|
+
const batch = openBatch(tmpDir("pool-b3-"), "bt");
|
|
307
|
+
const c = await launchOnce(() => providerByName("pool", { batch, repo: "o/r", cwd: tmpDir("pool-cwd3-"), exec: parseExecution({ mode: "remote", provider: "pool", token_env: "NO_SUCH_TOKEN_4685" }), env: {}, poolConfigPath: cfg }));
|
|
308
|
+
assert.equal(c.init.headers.authorization, "Bearer pool-token-789");
|
|
309
|
+
});
|
|
310
|
+
|
|
306
311
|
it("an explicit endpoint wins; no forge.yaml adds no env; a missing router config is misconfigured", async () => {
|
|
307
312
|
const { dir } = setupPool();
|
|
308
313
|
const batch = openBatch(tmpDir("pool-b2-"), "bt");
|
|
@@ -328,3 +333,31 @@ describe("remote capacity (GET /status, #4540)", () => {
|
|
|
328
333
|
assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
|
|
329
334
|
});
|
|
330
335
|
});
|
|
336
|
+
|
|
337
|
+
describe("remote stop at the checkpoint (#4612)", () => {
|
|
338
|
+
it("httpTransport.stop: POST .../stop, 2xx true, 404/405 false, other statuses throw", async () => {
|
|
339
|
+
const calls = [];
|
|
340
|
+
const mk = (status) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url, init) => { calls.push([init.method, url]); return { status, ok: status < 400, text: async () => "", headers: { get: () => null } }; } });
|
|
341
|
+
assert.equal(await mk(202).stop("a b"), true);
|
|
342
|
+
assert.deepEqual(calls[0], ["POST", "https://pool.example/v1/sandboxes/a%20b/stop"]);
|
|
343
|
+
assert.equal(await mk(404).stop("x"), false);
|
|
344
|
+
assert.equal(await mk(405).stop("x"), false);
|
|
345
|
+
await assert.rejects(mk(500).stop("x"), /stop: HTTP 500/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("provider.stop and providerLanes.stopLane signal the transport, never destroy; no transport stop = false", async () => {
|
|
349
|
+
const root = tmpDir("remote-stop-");
|
|
350
|
+
const batch = openBatch(root, "bt");
|
|
351
|
+
const { provider, transport } = remoteFixture(batch);
|
|
352
|
+
const stopped = [];
|
|
353
|
+
const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor: () => null });
|
|
354
|
+
assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), false, "the transport has no stop");
|
|
355
|
+
transport.stop = async (id) => { stopped.push(id); return true; };
|
|
356
|
+
assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), true);
|
|
357
|
+
assert.equal(await lanes.stopLane(1, {}), false, "no handle");
|
|
358
|
+
transport.stop = async () => { throw new Error("boom"); };
|
|
359
|
+
assert.equal(await provider.stop({ id: "sbx" }), false);
|
|
360
|
+
assert.deepEqual(stopped, ["sbx"]);
|
|
361
|
+
assert.deepEqual(transport.destroyed, []);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -23,7 +23,6 @@ import { scopedEnv } from "./execution.mjs";
|
|
|
23
23
|
import { resolveCredential } from "./credentials.mjs";
|
|
24
24
|
|
|
25
25
|
const REDACTED = "[redacted]";
|
|
26
|
-
const STATUS_ERRORS = 5;
|
|
27
26
|
/** Replace every value of `secrets` (4+ chars, longest first) in `text`. */
|
|
28
27
|
export function redact(text, secrets = []) {
|
|
29
28
|
let s = String(text ?? "");
|
|
@@ -44,6 +43,7 @@ export function isActionsEndpoint(endpoint) {
|
|
|
44
43
|
* GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
|
|
45
44
|
* GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`.
|
|
46
45
|
* 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
|
|
46
|
+
* Optional `POST /sandboxes/:id/stop` (#4612) asks the pool to SIGTERM the lane so its teardown checkpoints and it reports `deferred run.interrupted`; 2xx = sent, 404/405 = unsupported or gone (never a DELETE fallback).
|
|
47
47
|
* Optional `GET /status -> {free: N}` is the pool's free slots (#4540); anything else (404, error, malformed) is unknown (null).
|
|
48
48
|
*/
|
|
49
49
|
export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
|
|
@@ -57,6 +57,8 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
57
57
|
};
|
|
58
58
|
const fail = (what, r) => {
|
|
59
59
|
const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
|
|
60
|
+
e.status = r.status;
|
|
61
|
+
if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
|
|
60
62
|
if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
|
|
61
63
|
return e;
|
|
62
64
|
};
|
|
@@ -73,6 +75,7 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
73
75
|
async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
|
|
74
76
|
async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
|
|
75
77
|
async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
|
|
78
|
+
async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
|
|
76
79
|
async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
|
|
77
80
|
async capacity() {
|
|
78
81
|
// `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
|
|
@@ -111,7 +114,6 @@ export function gitIdentityEnv(identity, laneEnv = {}) {
|
|
|
111
114
|
export function remoteProvider({ batch, repo, settings = {}, name = "remote", transport, env = process.env, credentials = (name) => resolveCredential(name, { env }), now = () => Date.now(), gitIdentity = hostGitIdentity }) {
|
|
112
115
|
if (!transport) throw new Error("remote provider needs a transport");
|
|
113
116
|
const idFor = (issue) => `fdrsbx-${safeSegment(batch.id)}-${issue}`;
|
|
114
|
-
const errs = new Map(); // sandbox id -> consecutive status errors
|
|
115
117
|
const known = new Set(); // secret values seen this process, for redaction
|
|
116
118
|
const remember = (v) => { if (typeof v === "string" && v.length >= 4) known.add(v); };
|
|
117
119
|
for (const k of settings.secrets ?? []) remember(credentials(k));
|
|
@@ -154,8 +156,8 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
154
156
|
},
|
|
155
157
|
async status(h) {
|
|
156
158
|
let s;
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
+
// #4672: a transport error is not an exit; it propagates and providerLanes retries it for execution.unreachable_window_min.
|
|
160
|
+
s = await transport.status(h.id);
|
|
159
161
|
await pullLogs(h);
|
|
160
162
|
return s === "running" ? "running" : s === "gone" ? "gone" : "exited";
|
|
161
163
|
},
|
|
@@ -167,6 +169,11 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
167
169
|
async capacity() {
|
|
168
170
|
try { return (await transport.capacity?.()) ?? null; } catch { return null; }
|
|
169
171
|
},
|
|
172
|
+
/** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
|
|
173
|
+
async stop(h) {
|
|
174
|
+
if (typeof transport.stop !== "function") return false;
|
|
175
|
+
try { return (await transport.stop(h.id)) === true; } catch { return false; }
|
|
176
|
+
},
|
|
170
177
|
async teardown(h) {
|
|
171
178
|
await minutesOf(h);
|
|
172
179
|
try { return (await transport.destroy(h.id)) !== false; } catch { return false; }
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
|
|
12
12
|
* ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
|
|
13
13
|
* {op:"set-parallel", id, k} → {type:"ok", parallel}
|
|
14
|
+
* {op:"requeue", id, issues, reason, by} → {type:"ok", requeued, refused, hosted, reopened} (#4550): see batch.requeue; a finished batch is hosted again
|
|
14
15
|
* {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
|
|
15
16
|
* hosted runner reads on its next pass and an unhosted batch on its next resume
|
|
16
|
-
* {op:"cancel", id}
|
|
17
|
+
* {op:"cancel", id, stopRemote?} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
|
|
17
18
|
* admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
|
|
18
19
|
* and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
|
|
19
20
|
* {type:"ok", cancelled:false, finished:true}, nothing written
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
35
36
|
import { createConnection, createServer } from "node:net";
|
|
36
37
|
import { spawn } from "node:child_process";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
37
39
|
import { join } from "node:path";
|
|
38
40
|
import { schedDir, pidAlive } from "./sched.mjs";
|
|
39
41
|
import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
@@ -41,22 +43,63 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
|
41
43
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
42
44
|
export const STOP_MS = 10 * 1000;
|
|
43
45
|
export const LOCK_CHECK_MS = 5 * 1000;
|
|
46
|
+
export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
|
|
44
47
|
|
|
45
48
|
export function scheddPaths(dir = schedDir()) {
|
|
46
49
|
return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
// Env keys a batch keeps for its lanes (account
|
|
50
|
-
|
|
52
|
+
// Env keys a batch keeps for its lanes (account selectors, tool paths); persisted in plan.json (#4691). Session-scoped
|
|
53
|
+
// `CLAUDE_CODE_*` / `CLAUDE_PID` and `ANTHROPIC_*` are not kept, except the non-secret provider/endpoint/model selectors
|
|
54
|
+
// (`CLAUDE_CODE_USE_*`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, ...; #4734); more names come from `orchestration.env_passthrough`.
|
|
55
|
+
const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_.*|TMPDIR|CLAUDE_CONFIG_DIR|CLAUDE_CODE_USE_.*|ANTHROPIC_(BASE_URL|MODEL|SMALL_FAST_MODEL|DEFAULT_.*_MODEL)|FORGEDOCK_.*|GH_.*|GITHUB_.*)$/;
|
|
56
|
+
// What the 1.36.0 pin carried (plus FORGEDOCK_*): a key of these families not pinned now is named at submit (#4734).
|
|
57
|
+
const LEGACY_PIN = /^(CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*|FORGEDOCK_.*)$/;
|
|
58
|
+
const SESSION_SCOPED = /^(CLAUDE_PID|CLAUDE_CODE_(SESSION|MESSAGING|ENTRYPOINT|EXECPATH|SSE).*)$/;
|
|
59
|
+
// A secret never rides the allowlist: it resolves at lane start (env, keychain) unless the operator names it as a passthrough.
|
|
60
|
+
export const SECRET_ENV = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_?KEY)/i;
|
|
61
|
+
const SESSION_DIR = /(^|\/)(arg0|claude[^/]*)(\/|$)/;
|
|
51
62
|
|
|
52
|
-
/**
|
|
53
|
-
export function
|
|
54
|
-
|
|
63
|
+
/** A forge.yaml glob (`*` within a segment, `**` across segments, `?`) as an anchored RegExp. */
|
|
64
|
+
export function globToRegExp(glob) {
|
|
65
|
+
const re = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
|
|
66
|
+
return new RegExp(`^${re}$`);
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
/** `
|
|
69
|
+
/** `path` without non-existent dirs, per-session temp dirs (an `arg0`/`claude*` dir under a temp root) and `exclude` glob matches. */
|
|
70
|
+
export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots = ["/tmp", "/var/tmp", tmpdir(), process.env.TMPDIR] } = {}) {
|
|
71
|
+
const globs = exclude.map(globToRegExp);
|
|
72
|
+
const roots = tmpRoots.filter(Boolean).map((r) => r.replace(/\/+$/, ""));
|
|
73
|
+
const keep = (d) => d && exists(d) && !globs.some((g) => g.test(d.replace(/\/+$/, "")))
|
|
74
|
+
&& !(roots.some((r) => d === r || d.startsWith(`${r}/`)) && SESSION_DIR.test(d));
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
return String(path).split(":").filter((d) => keep(d) && !seen.has(d) && seen.add(d)).join(":");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
|
|
81
|
+
* the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
|
|
82
|
+
*/
|
|
83
|
+
export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
|
|
84
|
+
const extra = new Set(passthrough);
|
|
85
|
+
const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
|
|
86
|
+
if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Sorted names (never values) of `env` keys the pre-allowlist pin carried that `kept` does not: the lane gets the daemon's value or resolves it at lane start (#4734). */
|
|
91
|
+
export function droppedEnv(env = process.env, kept = {}) {
|
|
92
|
+
return Object.keys(env).filter((k) => typeof env[k] === "string" && LEGACY_PIN.test(k) && !SESSION_SCOPED.test(k) && !(k in kept)).sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `env` with secret-looking values redacted (`--show-env`). */
|
|
96
|
+
export function redactEnv(env) {
|
|
97
|
+
return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `base` without its allowlisted non-secret keys (secret-named ones, e.g. GH_TOKEN, stay from the daemon: never pinned), plus `kept`: an account selector the client did not set never leaks in from the daemon. */
|
|
58
101
|
export function mergeEnv(base, kept) {
|
|
59
|
-
const out = Object.fromEntries(Object.entries(base).filter(([k]) => !ENV_KEEP.test(k)));
|
|
102
|
+
const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
|
|
60
103
|
return { ...out, ...kept };
|
|
61
104
|
}
|
|
62
105
|
|
|
@@ -83,7 +126,7 @@ function takeLock(lock, pid, alive) {
|
|
|
83
126
|
*/
|
|
84
127
|
export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
|
|
85
128
|
root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
|
|
86
|
-
lockCheckMs = LOCK_CHECK_MS } = {}) {
|
|
129
|
+
lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS } = {}) {
|
|
87
130
|
const p = scheddPaths(dir);
|
|
88
131
|
mkdirSync(dir, { recursive: true });
|
|
89
132
|
const lock = takeLock(p.lock, pid, alive);
|
|
@@ -139,8 +182,20 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
139
182
|
if (req.op === "add") {
|
|
140
183
|
const b = openBatch(root, req.id);
|
|
141
184
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
142
|
-
const { added, skipped } = b.addIssues(req.issues ?? []);
|
|
143
|
-
|
|
185
|
+
const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
|
|
186
|
+
// #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
|
|
187
|
+
const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
|
|
188
|
+
if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
|
|
189
|
+
return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
190
|
+
}
|
|
191
|
+
if (req.op === "requeue") {
|
|
192
|
+
// #4550: the runner applies the entries on its next pass; a finished, unhosted batch is reopened (hosted as a resume).
|
|
193
|
+
const b = openBatch(root, req.id);
|
|
194
|
+
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
195
|
+
const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
|
|
196
|
+
const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
|
|
197
|
+
if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
|
|
198
|
+
return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
|
|
144
199
|
}
|
|
145
200
|
if (req.op === "hold" || req.op === "release") {
|
|
146
201
|
const b = openBatch(root, req.id);
|
|
@@ -152,7 +207,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
152
207
|
const b = openBatch(root, req.id);
|
|
153
208
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
154
209
|
if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
|
|
155
|
-
b.requestCancel({ reason: "operator" });
|
|
210
|
+
b.requestCancel({ reason: "operator", stopRemote: req.stopRemote === true });
|
|
156
211
|
return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
|
|
157
212
|
}
|
|
158
213
|
const sub = (m) => send(m, m.type === "done" || m.type === "error");
|
|
@@ -173,7 +228,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
173
228
|
const b = openBatch(root, req.id);
|
|
174
229
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
175
230
|
const rep = b.readReport();
|
|
176
|
-
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
|
|
231
|
+
if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
|
|
177
232
|
return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
|
|
178
233
|
}
|
|
179
234
|
const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
|
|
@@ -212,6 +267,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
212
267
|
if (closing) return closing;
|
|
213
268
|
clearInterval(idleTimer);
|
|
214
269
|
clearInterval(lockTimer);
|
|
270
|
+
if (dueTimer) clearInterval(dueTimer);
|
|
215
271
|
for (const c of conns) c.destroy();
|
|
216
272
|
const stopping = [...runs.keys()];
|
|
217
273
|
for (const ac of runs.values()) ac.abort();
|
|
@@ -241,6 +297,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
241
297
|
else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
|
|
242
298
|
}, lockCheckMs);
|
|
243
299
|
lockTimer.unref?.();
|
|
300
|
+
// #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
|
|
301
|
+
let dueRunning = false;
|
|
302
|
+
const dueTimer = due ? setInterval(async () => {
|
|
303
|
+
if (closing || dueRunning) return;
|
|
304
|
+
dueRunning = true;
|
|
305
|
+
try { await due(); } catch (e) { log(`schedd: due run failed: ${e?.message ?? e}`); } finally { dueRunning = false; }
|
|
306
|
+
}, dueMs) : null;
|
|
307
|
+
dueTimer?.unref?.();
|
|
244
308
|
|
|
245
309
|
// Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
|
|
246
310
|
if (resumeOnStart) {
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
10
|
import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
|
|
11
|
-
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
|
|
11
|
+
import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
|
|
12
12
|
import { schedDir } from "./sched.mjs";
|
|
13
13
|
import { formatTable, orchestrate } from "./orchestrate.mjs";
|
|
14
14
|
import { orchestrateFromCli } from "../engine-cli.mjs";
|
|
@@ -208,6 +208,32 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
208
208
|
} finally { await h.cleanup(); }
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
it("#4550: --requeue on a finished batch with a report reopens it through the daemon and runs the issue again; --add still skips it", async () => {
|
|
212
|
+
const h = host();
|
|
213
|
+
try {
|
|
214
|
+
await h.daemon();
|
|
215
|
+
const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
|
|
216
|
+
await withRoot(h, () => capture(async () => {
|
|
217
|
+
writeFileSync(h.release, "");
|
|
218
|
+
const first = await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
219
|
+
assert.deepEqual(first.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
|
|
220
|
+
const [id] = listBatches(h.root);
|
|
221
|
+
const b = openBatch(h.root, id);
|
|
222
|
+
// The lane ended needs-human and the operator decided it: the finished batch holds that terminal row.
|
|
223
|
+
b.store.save({ ...b.store.load(), results: { 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
|
|
224
|
+
const rep = b.readReport();
|
|
225
|
+
b.writeReport({ ...rep, rows: [{ issue: 1, outcome: "needs-human" }] });
|
|
226
|
+
assert.deepEqual((await orchestrateFromCli(["--add", id, "1"], client)).skipped.map((x) => x.reason), ["already in batch"]);
|
|
227
|
+
const res = await orchestrateFromCli(["--requeue", id, "1", "--reason", "decided"], client);
|
|
228
|
+
assert.deepEqual(res.requeued, [1]);
|
|
229
|
+
await until(() => h.launched().length === 2);
|
|
230
|
+
await until(() => b.store.load()?.results?.[1]?.outcome === "merged" && b.readReport()?.rows?.[0]?.outcome === "merged");
|
|
231
|
+
assert.deepEqual(listBatches(h.root), [id], "no second batch");
|
|
232
|
+
assert.equal(b.readReport().rows[0].requeued.reason, "decided");
|
|
233
|
+
}));
|
|
234
|
+
} finally { await h.cleanup(); }
|
|
235
|
+
});
|
|
236
|
+
|
|
211
237
|
it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
|
|
212
238
|
const h = host();
|
|
213
239
|
try {
|
|
@@ -481,9 +507,109 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
481
507
|
});
|
|
482
508
|
|
|
483
509
|
it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
|
|
484
|
-
const kept = batchEnv({ PATH: "/
|
|
485
|
-
assert.deepEqual(kept, { PATH: "/
|
|
510
|
+
const kept = batchEnv({ PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
|
|
511
|
+
assert.deepEqual(kept, { PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
|
|
486
512
|
const env = mergeEnv({ PATH: "/d", ANTHROPIC_API_KEY: "daemon-key", NODE_OPTIONS: "x" }, kept);
|
|
487
|
-
assert.
|
|
513
|
+
assert.equal(mergeEnv({ GH_TOKEN: "d" }, kept).GH_TOKEN, "d", "a secret-named daemon key (never pinned) stays for the lane");
|
|
514
|
+
assert.deepEqual(env, { NODE_OPTIONS: "x", ANTHROPIC_API_KEY: "daemon-key", PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
|
|
518
|
+
const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
|
|
519
|
+
FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
|
|
520
|
+
const kept = batchEnv(client);
|
|
521
|
+
assert.deepEqual(Object.keys(kept).sort(), ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CONFIG_DIR", "PATH"]);
|
|
522
|
+
assert.deepEqual(droppedEnv(client, kept), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN", "GH_TOKEN"]);
|
|
523
|
+
assert.deepEqual(droppedEnv(client, batchEnv(client, { passthrough: ["GH_TOKEN"] })), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN"]);
|
|
524
|
+
assert.deepEqual(droppedEnv({ PATH: "/", CLAUDE_CODE_SESSION_ID: "s" }, {}), [], "nothing to name: no warning");
|
|
525
|
+
assert.equal(mergeEnv({ ANTHROPIC_BASE_URL: "daemon", GH_TOKEN: "d" }, kept).ANTHROPIC_BASE_URL, "http://proxy");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("#4691: the pinned env drops session tokens and ids, session temp PATH dirs and path_exclude dirs", () => {
|
|
529
|
+
const work = tmpDir("schedd-env-");
|
|
530
|
+
const keep = join(work, "bin"), session = join(work, "claude-1", "arg0"), wrapper = join(work, "wrap", "shims");
|
|
531
|
+
for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
|
|
532
|
+
const session_env = { PATH: [keep, session, wrapper, join(work, "gone")].join(":"), HOME: "/h", CLAUDE_CODE_MESSAGING_TOKEN: "tok", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1",
|
|
533
|
+
CLAUDE_CODE_SESSION_ATTENDED: "1", ANTHROPIC_API_KEY: "k", FORGEDOCK_SANDBOX_TOKEN: "t", MY_FLAG: "1", MY_SECRET_PASS: "p", GH_HOST: "h" };
|
|
534
|
+
const pin = batchEnv(session_env, { passthrough: ["MY_FLAG", "MY_SECRET_PASS"], pathExclude: [`${work}/wrap/*`], tmpRoots: [work] });
|
|
535
|
+
assert.equal(pin.PATH, keep, "only the live, non-session, non-excluded dir stays");
|
|
536
|
+
assert.deepEqual(Object.keys(pin).sort(), ["GH_HOST", "HOME", "MY_FLAG", "MY_SECRET_PASS", "PATH"]);
|
|
537
|
+
assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: [work] }), keep);
|
|
538
|
+
assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: ["/nowhere"] }), `${session}:${keep}`, "an arg0 dir outside a temp root is not session-scoped");
|
|
539
|
+
assert.deepEqual(redactEnv({ A: "1", GH_TOKEN: "x" }), { A: "1", GH_TOKEN: "<redacted>" });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("#4691: plan.json holds none of the session token, temp PATH dir or excluded wrapper dir; --show/--set/--unset-env edit it", async () => {
|
|
543
|
+
const h = host();
|
|
544
|
+
const work = tmpDir("schedd-pin-");
|
|
545
|
+
const keep = join(work, "bin"), session = join(work, "claude-9", "arg0"), wrapper = join(work, "wrap", "shims");
|
|
546
|
+
for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
|
|
547
|
+
writeFileSync(join(work, "forge.yaml"), `orchestration:\n path_exclude: ${work}/wrap/*\n`);
|
|
548
|
+
const saved = { cwd: process.cwd(), path: process.env.PATH, tmp: process.env.TMPDIR };
|
|
549
|
+
let a;
|
|
550
|
+
try {
|
|
551
|
+
await h.daemon();
|
|
552
|
+
process.chdir(work);
|
|
553
|
+
process.env.PATH = [keep, session, wrapper].join(":");
|
|
554
|
+
process.env.TMPDIR = work;
|
|
555
|
+
process.env.CLAUDE_CODE_MESSAGING_TOKEN = "msg-token";
|
|
556
|
+
process.env.CLAUDE_CODE_SESSION_ID = "sess";
|
|
557
|
+
await withRoot(h, async () => {
|
|
558
|
+
a = capture(() => orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
|
|
559
|
+
await until(() => h.stubPids().length === 1 && h.lanePids().length === 1);
|
|
560
|
+
});
|
|
561
|
+
const [id] = listBatches(h.root);
|
|
562
|
+
const raw = readFileSync(join(h.root, id, "plan.json"), "utf8");
|
|
563
|
+
const env = JSON.parse(raw).env;
|
|
564
|
+
assert.equal(env.PATH, keep);
|
|
565
|
+
for (const bad of ["msg-token", "sess", "CLAUDE_CODE_", session, wrapper]) assert.equal(raw.includes(bad), false, `${bad} not pinned`);
|
|
566
|
+
await withRoot(h, async () => {
|
|
567
|
+
await capture(() => orchestrateFromCli(["--set-env", id, "MY_VAR=1"], h.client));
|
|
568
|
+
await capture(() => orchestrateFromCli(["--set-env", id, "GH_TOKEN=abc"], h.client));
|
|
569
|
+
assert.equal(openBatch(h.root, id).readPlan().env.MY_VAR, "1");
|
|
570
|
+
const { lines } = await capture(() => orchestrateFromCli(["--show-env", id], h.client));
|
|
571
|
+
assert.match(lines.join("\n"), /^GH_TOKEN=<redacted>$/m);
|
|
572
|
+
assert.match(lines.join("\n"), /^MY_VAR=1$/m);
|
|
573
|
+
assert.equal(lines.join("\n").includes("abc"), false);
|
|
574
|
+
await capture(() => orchestrateFromCli(["--unset-env", id, "MY_VAR"], h.client));
|
|
575
|
+
assert.equal("MY_VAR" in openBatch(h.root, id).readPlan().env, false);
|
|
576
|
+
await assert.rejects(() => orchestrateFromCli(["--set-env", id, "NOEQUALS"], h.client), /KEY=VAL/);
|
|
577
|
+
});
|
|
578
|
+
writeFileSync(h.release, "");
|
|
579
|
+
} finally {
|
|
580
|
+
process.chdir(saved.cwd); process.env.PATH = saved.path;
|
|
581
|
+
if (saved.tmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = saved.tmp;
|
|
582
|
+
delete process.env.CLAUDE_CODE_MESSAGING_TOKEN; delete process.env.CLAUDE_CODE_SESSION_ID;
|
|
583
|
+
await h.cleanup();
|
|
584
|
+
}
|
|
585
|
+
await a;
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
describe("schedd due timer (#4627)", () => {
|
|
590
|
+
const boot = async (opts) => startDaemon({ dir: tmpDir("schedd-due-"), root: tmpDir("schedd-due-b-"), runBatch: async () => ({}), resumeOnStart: false, log: () => {}, ...opts });
|
|
591
|
+
|
|
592
|
+
it("runs the due callback on its interval, never overlapped, and survives a throwing callback", async () => {
|
|
593
|
+
let calls = 0, active = 0, overlap = false;
|
|
594
|
+
const logs = [];
|
|
595
|
+
const d = await boot({ dueMs: 15, log: (m) => logs.push(m), due: async () => {
|
|
596
|
+
calls++; active++; if (active > 1) overlap = true;
|
|
597
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
598
|
+
active--; if (calls === 1) throw new Error("boom");
|
|
599
|
+
} });
|
|
600
|
+
try {
|
|
601
|
+
await until(() => calls >= 3);
|
|
602
|
+
assert.equal(overlap, false);
|
|
603
|
+
assert.ok(logs.some((m) => /due run failed: boom/.test(m)));
|
|
604
|
+
assert.ok(existsSync(d.sock));
|
|
605
|
+
} finally { await d.close(); }
|
|
606
|
+
const n = calls;
|
|
607
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
608
|
+
assert.equal(calls, n, "the timer is cleared on close");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("without a due callback there is no timer", async () => {
|
|
612
|
+
const d = await boot({ dueMs: 10 });
|
|
613
|
+
await d.close();
|
|
488
614
|
});
|
|
489
615
|
});
|
package/bin/lean/sequence.mjs
CHANGED
|
@@ -457,3 +457,27 @@ export async function generatorProblems({ git, base, ref = "HEAD" }) {
|
|
|
457
457
|
}
|
|
458
458
|
return problems;
|
|
459
459
|
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* #4661: journal `when` ordering. Newest-only migrators (drizzle `migrate()`) skip an entry stamped older than one already
|
|
463
|
+
* applied, so every entry the lane adds to a drizzle-style journal (`<dir>/meta/_journal.json`) must have `when` above the
|
|
464
|
+
* base journal's max, and `when` must increase within the lane journal. No journal or base journal: []. @returns {Promise<string[]>}
|
|
465
|
+
*/
|
|
466
|
+
export async function journalOrderProblems({ git, base, ref = "HEAD" }) {
|
|
467
|
+
const g = out(git), problems = [];
|
|
468
|
+
const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", `${base}...${ref}`])).filter((f) => /(?:^|\/)meta\/_journal\.json$/.test(f) && !SKIP_RE.test(f));
|
|
469
|
+
const entriesAt = async (r, jp) => { try { const e = JSON.parse(await g(["show", `${r}:${jp}`])).entries; return Array.isArray(e) ? e : null; } catch { return null; } };
|
|
470
|
+
for (const jp of changed) {
|
|
471
|
+
const now = await entriesAt(ref, jp);
|
|
472
|
+
if (!now) continue;
|
|
473
|
+
const was = (await entriesAt(base, jp)) ?? [], wasTags = new Set(was.map((e) => e?.tag));
|
|
474
|
+
const max = Math.max(-Infinity, ...was.map((e) => Number(e?.when)).filter(Number.isFinite));
|
|
475
|
+
for (let i = 0; i < now.length; i++) {
|
|
476
|
+
const e = now[i] ?? {}, w = Number(e.when);
|
|
477
|
+
if (wasTags.has(e.tag)) continue;
|
|
478
|
+
if (Number.isFinite(max) && !(w > max)) problems.push(`${jp}: ${e.tag} when ${e.when} is not above the base max when ${max} (stale stamp: a newest-only migrator skips it on a persistent database)`);
|
|
479
|
+
else if (i > 0 && Number.isFinite(Number(now[i - 1]?.when)) && !(w > Number(now[i - 1].when))) problems.push(`${jp}: ${e.tag} when ${e.when} is not after ${now[i - 1].tag} (${now[i - 1].when})`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return problems;
|
|
483
|
+
}
|
|
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
-
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
11
|
+
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
|
|
12
12
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
13
13
|
import { sequencePrompt, sequenceSyncNote, migrationGeneratorPrompt, builderImplementPrompt } from "./prompts.mjs";
|
|
14
14
|
|
|
@@ -492,3 +492,33 @@ describe("hand-named migrations (#4591)", () => {
|
|
|
492
492
|
assert.match(builderImplementPrompt("local"), /Never name or number migration files by hand/);
|
|
493
493
|
});
|
|
494
494
|
});
|
|
495
|
+
|
|
496
|
+
describe("journal when ordering across parallel milestone branches (#4661)", () => {
|
|
497
|
+
const J = (...es) => `${JSON.stringify({ version: "7", entries: es.map(([tag, when], idx) => ({ idx, tag, when })) }, null, 2)}\n`;
|
|
498
|
+
function repo(laneEntries) {
|
|
499
|
+
const d = tmpDir("when-");
|
|
500
|
+
sh(d, "init", "-q", "-b", "main");
|
|
501
|
+
put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000]) });
|
|
502
|
+
commit(d, "init");
|
|
503
|
+
sh(d, "checkout", "-qb", "lane");
|
|
504
|
+
if (laneEntries) { put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000], ...laneEntries) }); commit(d, "lane"); }
|
|
505
|
+
return d;
|
|
506
|
+
}
|
|
507
|
+
it("flags an added entry stamped at or below the base max when", async () => {
|
|
508
|
+
const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 7000]])), base: "main" });
|
|
509
|
+
assert.equal(p.length, 1);
|
|
510
|
+
assert.match(p[0], /db\/meta\/_journal\.json: 2_c when 7000 is not above the base max when 9000/);
|
|
511
|
+
});
|
|
512
|
+
it("passes entries above the base max, an unchanged journal, and no journal", async () => {
|
|
513
|
+
assert.deepEqual(await journalOrderProblems({ git: gitIn(repo([["2_c", 9001]])), base: "main" }), []);
|
|
514
|
+
assert.deepEqual(await journalOrderProblems({ git: gitIn(repo(null)), base: "main" }), []);
|
|
515
|
+
const d = tmpDir("when-none-");
|
|
516
|
+
sh(d, "init", "-q", "-b", "main"); put(d, { "x.txt": "x" }); commit(d, "i"); sh(d, "checkout", "-qb", "lane");
|
|
517
|
+
assert.deepEqual(await journalOrderProblems({ git: gitIn(d), base: "main" }), []);
|
|
518
|
+
});
|
|
519
|
+
it("flags non-increasing when inside the lane's added entries; the prompt asks for a re-stamp", async () => {
|
|
520
|
+
const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 9100], ["3_d", 9050]])), base: "main" });
|
|
521
|
+
assert.match(p[0], /3_d when 9050 is not after 2_c/);
|
|
522
|
+
assert.match(migrationGeneratorPrompt(p), /re-stamp/);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* DCO sign-off for a lane's own unpushed commits (#4651). A resumed lane keeps the commits of an earlier run; one from a
|
|
5
|
+
* pool agent that predates the identity fix is authored `*@localhost` with no `Signed-off-by`, and DCO fails on the release
|
|
6
|
+
* PR. Before a push, the commits the push would add (reachable from HEAD, not from origin/<branch> nor origin/<lane>) are
|
|
7
|
+
* checked, and the offenders are rewritten with the lane identity and --signoff. Commits already on either remote ref are
|
|
8
|
+
* never in the range, so they are never rewritten. Deterministic, 0 LLM calls.
|
|
9
|
+
*/
|
|
10
|
+
const SEP = "\x1f", END = "\x1e";
|
|
11
|
+
const PLACEHOLDER_RE = /@localhost$/i;
|
|
12
|
+
|
|
13
|
+
/** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
|
|
14
|
+
export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
|
|
15
|
+
if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;
|
|
16
|
+
const a = String(authorEmail ?? "").toLowerCase();
|
|
17
|
+
return !signedOff.some((s) => s.toLowerCase().includes(`<${a}>`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse `git log` output of FORMAT into the commits that need a rewrite, oldest first (the log is newest first). */
|
|
21
|
+
export function offendersOf(out) {
|
|
22
|
+
return String(out ?? "").split(END).map((r) => r.replace(/^\n/, "")).filter(Boolean).map((r) => {
|
|
23
|
+
const [sha, authorEmail, committerEmail, trailers = "", parents = ""] = r.split(SEP);
|
|
24
|
+
return { sha, authorEmail, committerEmail, signedOff: trailers.split("\n").map((l) => l.trim()).filter(Boolean), merge: parents.trim().split(/\s+/).filter(Boolean).length > 1 };
|
|
25
|
+
}).filter((c) => !c.merge && needsSignoff(c)).reverse();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FORMAT = `%H${SEP}%ae${SEP}%ce${SEP}%(trailers:key=Signed-off-by,valueonly,separator=%x0a)${SEP}%P${END}`;
|
|
29
|
+
|
|
30
|
+
// filter-branch rewrites only the commits in the range (the excluded remote refs stay as they are), and only the listed ones change.
|
|
31
|
+
const IS_BAD = 'case " $FD_BAD " in *" $GIT_COMMIT "*)';
|
|
32
|
+
const ENV_FILTER = `${IS_BAD} export GIT_AUTHOR_NAME="$FD_NAME" GIT_AUTHOR_EMAIL="$FD_EMAIL" GIT_COMMITTER_NAME="$FD_NAME" GIT_COMMITTER_EMAIL="$FD_EMAIL";; esac`;
|
|
33
|
+
const MSG_FILTER = `${IS_BAD} git interpret-trailers --trailer "Signed-off-by: $FD_NAME <$FD_EMAIL>";; *) cat;; esac`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} o
|
|
37
|
+
* @param {(args: string[], opts?: {env?: object}) => Promise<string>} o.git runs git in the lane worktree
|
|
38
|
+
* @param {string} o.lane the lane (base) branch
|
|
39
|
+
* @param {string} o.branch the lane's own branch
|
|
40
|
+
* @param {object} [o.env] where GIT_AUTHOR_NAME/EMAIL are read from (default process.env)
|
|
41
|
+
* @returns {Promise<{rewritten: number}>}
|
|
42
|
+
* @throws Error with `.laneCode` unset: callers type it. `.noIdentity` when a rewrite is needed but no usable identity exists.
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureSignedOff({ git, lane, branch, env = process.env }) {
|
|
45
|
+
const ok = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
|
|
46
|
+
try { await git(["fetch", "origin", branch]); } catch { /* not pushed yet, or offline: the tracking refs we have decide */ }
|
|
47
|
+
const exclude = [];
|
|
48
|
+
for (const ref of [`origin/${branch}`, `origin/${lane}`]) if (await ok(["rev-parse", "--verify", "-q", `refs/remotes/${ref}`])) exclude.push(`^${ref}`);
|
|
49
|
+
const log = await ok(["log", `--format=${FORMAT}`, "HEAD", ...exclude]);
|
|
50
|
+
const bad = offendersOf(log);
|
|
51
|
+
if (!bad.length) return { rewritten: 0 };
|
|
52
|
+
// the lane identity: GIT_AUTHOR_* (what the sandbox sets and what a commit would use), else the worktree config
|
|
53
|
+
let name = env.GIT_AUTHOR_NAME || await ok(["config", "user.name"]), email = env.GIT_AUTHOR_EMAIL || await ok(["config", "user.email"]);
|
|
54
|
+
if (!name || !email) { // the runner's own git env (what a commit here would use), e.g. GIT_AUTHOR_* set only on the git runner
|
|
55
|
+
const m = /^(.*?)\s*<([^>]*)>/.exec(await ok(["var", "GIT_AUTHOR_IDENT"]));
|
|
56
|
+
if (m) { name ||= m[1]; email ||= m[2]; }
|
|
57
|
+
}
|
|
58
|
+
if (!name || !email || PLACEHOLDER_RE.test(email)) {
|
|
59
|
+
throw Object.assign(new Error(`${bad.length} unpushed commit(s) fail DCO (placeholder author or no sign-off: ${bad.slice(0, 5).map((c) => c.sha.slice(0, 8)).join(", ")}) and the worktree has no usable git identity to rewrite them`), { noIdentity: true });
|
|
60
|
+
}
|
|
61
|
+
const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
|
|
62
|
+
try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
|
|
63
|
+
catch (e) { throw Object.assign(new Error(`could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`), { noIdentity: false }); }
|
|
64
|
+
await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
|
|
65
|
+
return { rewritten: bad.length };
|
|
66
|
+
}
|