forgedock 1.40.0 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -1
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { runSelftestLive, stripState, selftestFromCli, STEPS } from "./selftest-live.mjs";
|
|
9
|
+
|
|
10
|
+
/** A fake sandbox world. `d` toggles a defect: add4859 (--add skips), wipe4860 (body wiped), double (resume relaunches), orderBA, noRemoteAuth. */
|
|
11
|
+
function world(d = {}) {
|
|
12
|
+
const w = { issues: {}, closed: [], calls: [], killed: 0, pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
|
|
13
|
+
const gh = async (a) => {
|
|
14
|
+
w.calls.push(a.join(" "));
|
|
15
|
+
if (a[0] === "api" && a[1] === "-X" && a[2] === "PUT") return "{}";
|
|
16
|
+
if (a[0] === "issue" && a[1] === "create") {
|
|
17
|
+
const n = 10 + Object.keys(w.issues).length; w.issues[n] = { body: a[a.indexOf("--body") + 1], state: "OPEN", labels: [] }; return `https://github.com/o/r/issues/${n}\n`;
|
|
18
|
+
}
|
|
19
|
+
if (a[0] === "issue" && a[1] === "view") {
|
|
20
|
+
const n = a[2], i = w.issues[n];
|
|
21
|
+
if (a.includes("body")) return JSON.stringify({ body: d.wipe4860 && w.wiped ? "<!-- FORGE:STATE {} -->" : `${i.body}\n<!-- FORGE:STATE {"v":1} -->` });
|
|
22
|
+
return JSON.stringify({ state: i.state, labels: i.labels.map((name) => ({ name })) });
|
|
23
|
+
}
|
|
24
|
+
if (a[0] === "issue" && a[1] === "close") { w.closed.push(a[2]); return ""; }
|
|
25
|
+
if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ state: "MERGED", mergedAt: `2026-01-01T00:0${a[2] === "101" ? (d.orderBA ? 2 : 1) : (d.orderBA ? 1 : 2)}:00Z` });
|
|
26
|
+
if (a[0] === "pr" && a[1] === "list") return "";
|
|
27
|
+
if (a[0] === "api") { throw new Error("fail cleanup api"); }
|
|
28
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
29
|
+
};
|
|
30
|
+
const io = {
|
|
31
|
+
now: () => (w.clock += 1000), sleep: async () => { w.tick = (w.tick ?? 0) + 1; if (d.wipe4860 && w.tick >= 1) w.wiped = true; finish(); },
|
|
32
|
+
gh, cliVersion: async () => "1.39.0",
|
|
33
|
+
cli: async (a) => {
|
|
34
|
+
if (a[1] === "--add") { const n = Number(a[3]); if (d.add4859) return { code: 0, stdout: `#${n} skipped: unreadable`, stderr: "" }; w.order.push(n); w.deps[n] = [Number(Object.keys(w.issues)[0])]; return { code: 0, stdout: "added", stderr: "" }; }
|
|
35
|
+
throw new Error("cli " + a);
|
|
36
|
+
},
|
|
37
|
+
orchestrate: {
|
|
38
|
+
start: async (repo, issues) => { w.order.push(...issues); w.running[issues[0]] = { pid: ++w.pid }; w.a = issues[0]; return { id: "b1", kill: async () => { w.killed++; } }; },
|
|
39
|
+
resume: async (id) => { w.resumedAt = w.tick ?? 0; if (d.double) w.running[w.a] = { pid: ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
|
|
40
|
+
state: async () => ({ order: w.order, deps: w.deps, running: w.running, results: w.results }),
|
|
41
|
+
},
|
|
42
|
+
remote: w.pool ? { run: async ({ issue }) => { w.remoteIssue = issue; return { where: d.local ? "local" : "remote", authenticated: !d.noRemoteAuth, engineVersion: d.oldEngine ? "1.37.0" : "1.39.0" }; } } : null,
|
|
43
|
+
};
|
|
44
|
+
function finish() {
|
|
45
|
+
if (w.resumedAt !== undefined && !w.finished && (w.tick ?? 0) >= w.resumedAt + 2) {
|
|
46
|
+
w.finished = true;
|
|
47
|
+
const [a, b] = Object.keys(w.issues).map(Number);
|
|
48
|
+
w.results = { [a]: { outcome: "merged", pr: 101 }, [b]: { outcome: "merged", pr: 102 } };
|
|
49
|
+
for (const n of [a, b]) w.issues[n].labels.push("workflow:merged"), w.issues[n].state = "CLOSED";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { w, io };
|
|
53
|
+
}
|
|
54
|
+
const run = (d, extra = {}) => { const { w, io } = world(d); return runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000, ...extra }).then((r) => ({ w, r })); };
|
|
55
|
+
|
|
56
|
+
describe("selftest --live steps (#4865)", () => {
|
|
57
|
+
it("all steps pass and cleanup runs", async () => {
|
|
58
|
+
const { w, r } = await run({});
|
|
59
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
60
|
+
assert.deepEqual(w.closed, ["10", "11"]);
|
|
61
|
+
assert.match(r.notes.join(), /remote: skipped/);
|
|
62
|
+
});
|
|
63
|
+
it("fixtures: B depends on A and the issue bodies are the fixture text", async () => {
|
|
64
|
+
const { w } = await run({});
|
|
65
|
+
assert.match(w.issues[11].body, /\*\*Depends on\*\*: #10/);
|
|
66
|
+
});
|
|
67
|
+
it("the #4859 defect (--add skips the issue) fails the add step", async () => {
|
|
68
|
+
const { r, w } = await run({ add4859: true });
|
|
69
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add"); assert.match(r.message, /skipped #11/);
|
|
70
|
+
assert.deepEqual(w.closed, ["10", "11"], "cleanup ran after the failure");
|
|
71
|
+
});
|
|
72
|
+
it("the #4860 defect (body wiped to FORGE:STATE) fails the body step", async () => {
|
|
73
|
+
const { r } = await run({ wipe4860: true });
|
|
74
|
+
assert.equal(r.ok, false); assert.equal(r.step, "body"); assert.match(r.message, /lost its body text/);
|
|
75
|
+
});
|
|
76
|
+
it("a lane launched twice after --resume fails the resume step", async () => {
|
|
77
|
+
const { r } = await run({ double: true });
|
|
78
|
+
assert.equal(r.step, "resume"); assert.match(r.message, /launched again/);
|
|
79
|
+
});
|
|
80
|
+
it("B merging before A fails the merge step", async () => {
|
|
81
|
+
const { r } = await run({ orderBA: true });
|
|
82
|
+
assert.equal(r.step, "merge"); assert.match(r.message, /B merged before A/);
|
|
83
|
+
});
|
|
84
|
+
it("remote step: stale sandbox engine or no auth fails it; matching passes", async () => {
|
|
85
|
+
assert.equal((await run({ pool: true })).r.ok, true);
|
|
86
|
+
assert.match((await run({ pool: true, oldEngine: true })).r.message, /1\.37\.0 != CLI 1\.39\.0/);
|
|
87
|
+
assert.equal((await run({ pool: true, noRemoteAuth: true })).r.step, "remote");
|
|
88
|
+
assert.match((await run({ pool: true, local: true })).r.message, /not in the pool/);
|
|
89
|
+
const ok = await run({ pool: true });
|
|
90
|
+
assert.equal(ok.w.remoteIssue, 12); assert.deepEqual(ok.w.closed, ["10", "11", "12"]);
|
|
91
|
+
});
|
|
92
|
+
it("unknown fixture language fails at setup", async () => {
|
|
93
|
+
assert.equal((await runSelftestLive({ repo: "o/r", lang: "ruby", io: {} })).step, "setup");
|
|
94
|
+
});
|
|
95
|
+
it("stripState drops only the annotation", () => assert.equal(stripState("hi\n<!-- FORGE:STATE\n{}\n-->"), "hi"));
|
|
96
|
+
it("has the six ordered phases (cleanup runs after them)", () => assert.deepEqual(STEPS.map((s) => s.name), ["create", "add", "body", "resume", "merge", "remote"]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("selftestFromCli", () => {
|
|
100
|
+
it("requires --live or --static and a sandbox repo", async () => {
|
|
101
|
+
await assert.rejects(selftestFromCli([]), /usage/);
|
|
102
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
103
|
+
});
|
|
104
|
+
it("a failing step is named in the output, returned not-ok, and the marker is posted as fail", async () => {
|
|
105
|
+
const { io } = world({ add4859: true }); const out = [], posted = [];
|
|
106
|
+
const gh = io.gh;
|
|
107
|
+
io.gh = async (a) => {
|
|
108
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) return "a".repeat(40);
|
|
109
|
+
if (a[0] === "repo") return JSON.stringify({ nameWithOwner: "o/r" });
|
|
110
|
+
if (a[0] === "pr" && a[1] === "comment") { posted.push(a.at(-1)); return ""; }
|
|
111
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
112
|
+
return gh(a);
|
|
113
|
+
};
|
|
114
|
+
const r = await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9"], { cwd: "/nonexistent", print: (l) => out.push(l), io, timing: { pollMs: 1 } });
|
|
115
|
+
assert.equal(r.ok, false); assert.equal(r.step, "add");
|
|
116
|
+
assert.ok(out.some((l) => /FAILED at step add/.test(l)));
|
|
117
|
+
assert.match(posted[0], /FORGE:SELFTEST result=fail sha=a{40} step=add/);
|
|
118
|
+
});
|
|
119
|
+
it("the PR head is read from the release repo before the run", async () => {
|
|
120
|
+
const { io } = world({}); const order = [];
|
|
121
|
+
const gh = io.gh;
|
|
122
|
+
io.gh = async (a) => {
|
|
123
|
+
if (a[0] === "pr" && a[1] === "view" && a.includes("headRefOid")) { order.push(`head ${a[a.indexOf("-R") + 1]}`); return "b".repeat(40); }
|
|
124
|
+
if (a[0] === "pr" && a[1] === "comment") { order.push("post"); return ""; }
|
|
125
|
+
if (a[1] === "--help" || a[2] === "--help") return "JSON FIELDS\n body, number, title\n";
|
|
126
|
+
if (a[0] === "issue" && a[1] === "create" && !order.includes("run")) order.push("run");
|
|
127
|
+
return gh(a);
|
|
128
|
+
};
|
|
129
|
+
await selftestFromCli(["--live", "--repo", "o/sandbox", "--pr", "9", "--release-repo", "o/rel"], { cwd: "/nonexistent", print: () => {}, io, timing: { pollMs: 1 } });
|
|
130
|
+
assert.deepEqual(order.slice(0, 2), ["head o/rel", "run"]);
|
|
131
|
+
});
|
|
132
|
+
it("refuses the project repo or the release repo as the sandbox", async () => {
|
|
133
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "project:\n owner: o\n repo: R\n");
|
|
134
|
+
const io = { gh: async (a) => (a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : "") };
|
|
135
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/r"], { cwd, print: () => {}, io }), /this project's repo/);
|
|
136
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/x", "--pr", "3", "--release-repo", "o/x"], { cwd: "/nonexistent", print: () => {}, io }), /release repo/);
|
|
137
|
+
});
|
|
138
|
+
it("a flag without a value throws instead of being skipped", async () => {
|
|
139
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "o/s", "--pr"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--pr needs a value/);
|
|
140
|
+
await assert.rejects(selftestFromCli(["--live", "--repo", "--pr", "4"], { cwd: "/nonexistent", print: () => {}, io: { gh: async () => "" } }), /--repo needs a value/);
|
|
141
|
+
});
|
|
142
|
+
it("forge.yaml is read per section: another section's mode or sandbox_repo is not picked up", async () => {
|
|
143
|
+
const cwd = tmpDir("st-"); writeFileSync(join(cwd, "forge.yaml"), "selftest:\n fixture: node\nother:\n sandbox_repo: x/y\n");
|
|
144
|
+
await assert.rejects(selftestFromCli(["--live"], { cwd, print: () => {}, io: { gh: async () => "" } }), /no sandbox repo/);
|
|
145
|
+
});
|
|
146
|
+
it("--nightly-report files a failure that happens before the harness starts (no sandbox repo)", async () => {
|
|
147
|
+
const calls = [];
|
|
148
|
+
const gh = async (a) => { calls.push(a); if (a[1] === "list") return "[]"; if (a[1] === "create") return "https://github.com/o/r/issues/5\n"; return a[1] === "--help" || a[2] === "--help" ? "JSON FIELDS\n body\n" : ""; };
|
|
149
|
+
const r = await selftestFromCli(["--live", "--nightly-report", "o/r"], { cwd: "/nonexistent", print: () => {}, io: { gh } });
|
|
150
|
+
assert.equal(r.ok, false); assert.equal(r.step, "config");
|
|
151
|
+
const create = calls.find((a) => a[0] === "issue" && a[1] === "create");
|
|
152
|
+
assert.ok(create && create.includes("o/r")); assert.match(create.join(" "), /step config/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Opt-in nightly `selftest --live` (#4894), started from schedd's due callback on the host (never GitHub Actions). Off unless
|
|
4
|
+
// forge.yaml `selftest.nightly_interval_hours` is a positive number. The daemon only decides it is due and launches a
|
|
5
|
+
// detached `forgedock selftest --live --nightly-report <repo>`: the harness itself talks to schedd (orchestrate --add), so
|
|
6
|
+
// running it inside the daemon's event loop would deadlock it, and a 45-minute run would hold the due pass. A failing run
|
|
7
|
+
// (or one that cannot start, e.g. no sandbox repo) files ONE issue naming the failed step; the next failure edits it.
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { schedDir } from "./sched.mjs";
|
|
12
|
+
|
|
13
|
+
export const NIGHTLY_MARKER = "<!-- FORGE:SELFTEST_NIGHTLY -->";
|
|
14
|
+
const neutralize = (s) => String(s ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
15
|
+
/** One last-run file per repo: two repos on one host never share (and so never skip) each other's schedule. */
|
|
16
|
+
const stateFile = (repo) => `selftest-nightly-${String(repo).replace(/[^\w.-]+/g, "_")}.json`;
|
|
17
|
+
|
|
18
|
+
/** Interval in hours from the forge.yaml text, or null when the key is unset, zero or not a number (= nothing scheduled). */
|
|
19
|
+
export function nightlyConfig(raw) {
|
|
20
|
+
const sec = String(raw ?? "").split(/^selftest:/m)[1]?.split(/^\S/m)[0] ?? "";
|
|
21
|
+
const h = Number(/^\s+nightly_interval_hours:\s*["']?([\d.]+)/m.exec(sec)?.[1]);
|
|
22
|
+
return h > 0 ? h : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readLast(dir, repo) {
|
|
26
|
+
try { return Number(JSON.parse(readFileSync(join(dir, stateFile(repo)), "utf8")).lastRun) || 0; } catch { return 0; }
|
|
27
|
+
}
|
|
28
|
+
function writeLast(dir, repo, ts) {
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, stateFile(repo)), JSON.stringify({ lastRun: ts }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start `node <cli> selftest --live --nightly-report <repo>` detached, from the daemon. A spawn failure (EAGAIN, EMFILE,
|
|
35
|
+
* a vanished cwd) arrives as an async 'error' event: without a listener it is an uncaught exception, which stops schedd.
|
|
36
|
+
* It is swallowed here; that night's run is lost, never the daemon.
|
|
37
|
+
*/
|
|
38
|
+
export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
|
|
39
|
+
try {
|
|
40
|
+
const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
|
|
41
|
+
c.on("error", () => {});
|
|
42
|
+
c.unref();
|
|
43
|
+
return c;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** File (or edit) the one nightly failure issue in `repo`. `result` is {step, message}. @returns {Promise<{issue:?number, action:"create"|"edit"}>} */
|
|
48
|
+
export async function reportNightly({ repo, io, now = () => Date.now(), result }) {
|
|
49
|
+
const step = result?.step ?? "unknown";
|
|
50
|
+
const title = `selftest --live failed at step ${step} (nightly)`;
|
|
51
|
+
const body = `The nightly \`forgedock selftest --live\` failed at step **${neutralize(step)}**.\n\n${neutralize(String(result?.message ?? "").slice(0, 2000))}\n\nLast failure: ${new Date(now()).toISOString()}\n\n${NIGHTLY_MARKER}\n`;
|
|
52
|
+
const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body"])))
|
|
53
|
+
.find((i) => String(i.body ?? "").includes(NIGHTLY_MARKER));
|
|
54
|
+
if (open) {
|
|
55
|
+
await io.gh(["issue", "edit", String(open.number), "-R", repo, "--title", title, "--body", body]);
|
|
56
|
+
return { issue: open.number, action: "edit" };
|
|
57
|
+
}
|
|
58
|
+
const out = await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]);
|
|
59
|
+
return { issue: Number(/(\d+)\s*$/.exec(String(out).trim())?.[1]) || null, action: "create" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One schedd pass. With `launch` (the daemon) it only starts the detached run and returns {ran, launched}; with `runLive`
|
|
64
|
+
* (tests, or an in-process caller) it awaits {ok, step, message} and files the failure. A harness that throws is a
|
|
65
|
+
* failure at step "harness", never silent. Never throws.
|
|
66
|
+
* @returns {Promise<{ran:boolean, launched?:boolean, ok?:boolean, issue?:?number, action?:"create"|"edit"}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function nightlyDue({ repo, cwd = process.cwd(), io, now = () => Date.now(), runLive, launch, stateDir = schedDir() } = {}) {
|
|
69
|
+
try {
|
|
70
|
+
const file = join(cwd, "forge.yaml");
|
|
71
|
+
const hours = nightlyConfig(existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
72
|
+
if (!hours || !repo) return { ran: false };
|
|
73
|
+
if (now() - readLast(stateDir, repo) < hours * 3600_000) return { ran: false };
|
|
74
|
+
writeLast(stateDir, repo, now()); // before the run: a crashing harness must not re-run every 15 min
|
|
75
|
+
if (launch) { launch(); return { ran: true, launched: true }; }
|
|
76
|
+
let r;
|
|
77
|
+
try { r = await runLive(); } catch (e) { r = { ok: false, step: "harness", message: String(e?.message ?? e) }; }
|
|
78
|
+
if (r.ok) return { ran: true, ok: true };
|
|
79
|
+
return { ran: true, ok: false, ...(await reportNightly({ repo, io, now, result: r })) };
|
|
80
|
+
} catch { return { ran: false }; }
|
|
81
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { nightlyConfig, nightlyDue, launchNightly, NIGHTLY_MARKER } from "./selftest-nightly.mjs";
|
|
8
|
+
|
|
9
|
+
const setup = (yaml) => { const cwd = tmpDir("nt-"), stateDir = tmpDir("ns-"); if (yaml) writeFileSync(join(cwd, "forge.yaml"), yaml); return { cwd, stateDir }; };
|
|
10
|
+
const YAML = "selftest:\n sandbox_repo: a/b\n nightly_interval_hours: 24\n";
|
|
11
|
+
const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
|
|
12
|
+
|
|
13
|
+
test("nightlyConfig: unset, zero and other sections are off", () => {
|
|
14
|
+
assert.equal(nightlyConfig(""), null);
|
|
15
|
+
assert.equal(nightlyConfig("selftest:\n sandbox_repo: a/b\n"), null);
|
|
16
|
+
assert.equal(nightlyConfig("selftest:\n nightly_interval_hours: 0\n"), null);
|
|
17
|
+
assert.equal(nightlyConfig("other:\n nightly_interval_hours: 5\n"), null);
|
|
18
|
+
assert.equal(nightlyConfig(YAML), 24);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("key unset: nothing is scheduled", async () => {
|
|
22
|
+
const s = setup("selftest:\n sandbox_repo: a/b\n"); let n = 0;
|
|
23
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io: fakeIo(), runLive: async () => { n++; return { ok: true }; } });
|
|
24
|
+
assert.equal(r.ran, false); assert.equal(n, 0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("key set: runs when due, not again within the interval", async () => {
|
|
28
|
+
const s = setup(YAML); let n = 0, t = 1e13;
|
|
29
|
+
const run = () => nightlyDue({ ...s, repo: "o/r", io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
|
|
30
|
+
assert.equal((await run()).ran, true);
|
|
31
|
+
t += 3600_000; assert.equal((await run()).ran, false);
|
|
32
|
+
t += 24 * 3600_000; assert.equal((await run()).ran, true);
|
|
33
|
+
assert.equal(n, 2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("failure files one issue naming the step; the second failure edits it", async () => {
|
|
37
|
+
const s = setup(YAML); let t = 1e13;
|
|
38
|
+
const io1 = fakeIo();
|
|
39
|
+
const r1 = await nightlyDue({ ...s, repo: "o/r", io: io1, now: () => t, runLive: async () => ({ ok: false, step: "body", message: "lost <!-- x --> body" }) });
|
|
40
|
+
assert.deepEqual([r1.action, r1.issue], ["create", 77]);
|
|
41
|
+
const create = io1.calls.find((a) => a[1] === "create");
|
|
42
|
+
assert.match(create.join(" "), /step body/); assert.ok(create.join(" ").includes(NIGHTLY_MARKER));
|
|
43
|
+
assert.ok(!create[create.indexOf("--body") + 1].includes("<!-- x"));
|
|
44
|
+
t += 25 * 3600_000;
|
|
45
|
+
const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}` }]);
|
|
46
|
+
const r2 = await nightlyDue({ ...s, repo: "o/r", io: io2, now: () => t, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
47
|
+
assert.deepEqual([r2.action, r2.issue], ["edit", 77]);
|
|
48
|
+
assert.ok(!io2.calls.some((a) => a[1] === "create"));
|
|
49
|
+
assert.match(io2.calls.find((a) => a[1] === "edit").join(" "), /step add/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a passing run writes no issue; a throwing harness is reported, never silent", async () => {
|
|
53
|
+
const s = setup(YAML), io = fakeIo();
|
|
54
|
+
await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: true }) });
|
|
55
|
+
assert.equal(io.calls.length, 0);
|
|
56
|
+
const s2 = setup(YAML), io2 = fakeIo();
|
|
57
|
+
const r = await nightlyDue({ ...s2, repo: "o/r", io: io2, runLive: async () => { throw new Error("boom"); } });
|
|
58
|
+
assert.deepEqual([r.ran, r.ok, r.action], [true, false, "create"]);
|
|
59
|
+
assert.match(io2.calls.find((a) => a[1] === "create").join(" "), /step harness/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("with launch (the daemon) the run is only started, detached: nothing is awaited or filed in-process", async () => {
|
|
63
|
+
const s = setup(YAML), io = fakeIo(); let launched = 0;
|
|
64
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; }, runLive: async () => { throw new Error("must not run"); } });
|
|
65
|
+
assert.deepEqual(r, { ran: true, launched: true }); assert.equal(launched, 1); assert.equal(io.calls.length, 0);
|
|
66
|
+
assert.equal((await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; } })).ran, false, "not again within the interval");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the schedule is per repo: one repo's run never skips another's", async () => {
|
|
70
|
+
const s = setup(YAML); let n = 0; const t = 1e13;
|
|
71
|
+
const run = (repo) => nightlyDue({ ...s, repo, io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
|
|
72
|
+
assert.equal((await run("o/a")).ran, true); assert.equal((await run("o/b")).ran, true); assert.equal(n, 2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("launchNightly: detached, unref'd, and an async spawn error never escapes (it would stop schedd)", async () => {
|
|
76
|
+
let args = null, unrefd = false;
|
|
77
|
+
const child = new EventEmitter(); child.unref = () => { unrefd = true; };
|
|
78
|
+
const c = launchNightly({ cli: "/x/forgedock.mjs", repo: "o/r", cwd: "/tmp", spawnFn: (...a) => { args = a; return child; } });
|
|
79
|
+
assert.equal(c, child); assert.ok(unrefd);
|
|
80
|
+
assert.deepEqual(args[1], ["/x/forgedock.mjs", "selftest", "--live", "--nightly-report", "o/r"]);
|
|
81
|
+
assert.equal(args[2].detached, true); assert.equal(args[2].stdio, "ignore");
|
|
82
|
+
assert.doesNotThrow(() => child.emit("error", Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" })));
|
|
83
|
+
assert.equal(launchNightly({ cli: "c", repo: "o/r", spawnFn: () => { throw new Error("EMFILE"); } }), null);
|
|
84
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Static half of `forgedock selftest` (#4865): every `gh issue|pr view|list --json a,b` field list in bin/ must be a
|
|
4
|
+
// subset of the fields `gh <cmd> --help` advertises on this host (#4859: `authorAssociation` is not a gh field).
|
|
5
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/** Non-test .mjs files under `dir`, recursively (node_modules skipped). */
|
|
10
|
+
export function listSources(dir) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
13
|
+
if (e.name === "node_modules") continue;
|
|
14
|
+
const p = join(dir, e.name);
|
|
15
|
+
if (e.isDirectory()) out.push(...listSources(p));
|
|
16
|
+
else if (e.name.endsWith(".mjs") && !/\.test\.mjs$/.test(e.name)) out.push(p);
|
|
17
|
+
}
|
|
18
|
+
return out.sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {{cmd:string, fields:string[], line:number}[]} the literal gh `--json` field lists of `text` (cmd e.g. "issue view"). */
|
|
22
|
+
export function extractJsonFields(text) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const re = /\[([^[\]]*?)["'`]--json["'`]\s*,\s*["'`]([A-Za-z0-9_,]+)["'`]/g;
|
|
25
|
+
for (let m; (m = re.exec(text));) {
|
|
26
|
+
const words = [...m[1].matchAll(/["'`]([a-z-]+)["'`]/g)].map((x) => x[1]);
|
|
27
|
+
const i = words.findIndex((w, k) => (w === "issue" || w === "pr") && (words[k + 1] === "view" || words[k + 1] === "list"));
|
|
28
|
+
if (i < 0) continue;
|
|
29
|
+
out.push({ cmd: `${words[i]} ${words[i + 1]}`, fields: m[2].split(",").filter(Boolean), line: text.slice(0, m.index).split("\n").length });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The field names of the `JSON FIELDS` section of `gh <cmd> --help`. */
|
|
35
|
+
export function parseHelpFields(help) {
|
|
36
|
+
const lines = String(help).split("\n");
|
|
37
|
+
const at = lines.findIndex((l) => /^\s*JSON FIELDS\s*$/i.test(l));
|
|
38
|
+
if (at < 0) return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const l of lines.slice(at + 1)) {
|
|
41
|
+
if (!l.trim()) { if (out.length) break; continue; }
|
|
42
|
+
if (!/^\s/.test(l)) break;
|
|
43
|
+
out.push(...l.split(",").map((s) => s.trim()).filter(Boolean));
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{root:string, io:{gh:(args:string[])=>string, read?:(p:string)=>string, list?:(d:string)=>string[]}}} a
|
|
50
|
+
* @returns {{ok:boolean, checked:number, missing:{file:string,line:number,cmd:string,field:string}[], errors:string[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function staticCheck({ root, io }) {
|
|
53
|
+
const read = io.read ?? ((p) => readFileSync(p, "utf8")), list = io.list ?? listSources;
|
|
54
|
+
const known = new Map(), errors = [], missing = [];
|
|
55
|
+
let checked = 0;
|
|
56
|
+
for (const file of list(root)) {
|
|
57
|
+
for (const u of extractJsonFields(read(file))) {
|
|
58
|
+
if (!known.has(u.cmd)) {
|
|
59
|
+
let fields = [];
|
|
60
|
+
try { fields = parseHelpFields(io.gh([...u.cmd.split(" "), "--help"])); } catch (e) { errors.push(`gh ${u.cmd} --help failed: ${e.message}`); }
|
|
61
|
+
if (!fields.length && !errors.length) errors.push(`gh ${u.cmd} --help lists no JSON fields`);
|
|
62
|
+
known.set(u.cmd, new Set(fields));
|
|
63
|
+
}
|
|
64
|
+
const set = known.get(u.cmd);
|
|
65
|
+
if (!set.size) continue;
|
|
66
|
+
checked++;
|
|
67
|
+
for (const field of u.fields) if (!set.has(field)) missing.push({ file, line: u.line, cmd: u.cmd, field });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ok: !missing.length && !errors.length, checked, missing, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const realGh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
74
|
+
|
|
75
|
+
export function formatStatic(r) {
|
|
76
|
+
return [
|
|
77
|
+
...r.missing.map((m) => `${m.file}:${m.line}: gh ${m.cmd} --json field "${m.field}" is not offered by this gh`),
|
|
78
|
+
...r.errors,
|
|
79
|
+
`static check: ${r.ok ? "ok" : "FAILED"} (${r.checked} field lists checked)`,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { extractJsonFields, parseHelpFields, staticCheck, formatStatic } from "./selftest-static.mjs";
|
|
6
|
+
|
|
7
|
+
const HELP = "Usage\n\nJSON FIELDS\n author, body, number,\n title\n\nARGUMENTS\n none\n";
|
|
8
|
+
const gh = (args) => { if (args[0] === "issue" || args[0] === "pr") return HELP; throw new Error("no"); };
|
|
9
|
+
|
|
10
|
+
describe("selftest static check (#4865)", () => {
|
|
11
|
+
it("extracts only gh issue|pr view|list field lists", () => {
|
|
12
|
+
const t = `x\nawait io.gh(["issue", "view", String(n), "--json", "body,title"]);\nrun(["repo", "view", "--json", "nameWithOwner"]);\ngh(["pr", "list", "--json", "number"])`;
|
|
13
|
+
assert.deepEqual(extractJsonFields(t), [{ cmd: "issue view", fields: ["body", "title"], line: 2 }, { cmd: "pr list", fields: ["number"], line: 4 }]);
|
|
14
|
+
});
|
|
15
|
+
it("parses the JSON FIELDS section of the help", () => {
|
|
16
|
+
assert.deepEqual(parseHelpFields(HELP), ["author", "body", "number", "title"]);
|
|
17
|
+
});
|
|
18
|
+
it("fails on a bogus field (#4859 authorAssociation) and names file:line", () => {
|
|
19
|
+
const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["issue", "list", "--json", "number,authorAssociation"])' } });
|
|
20
|
+
assert.equal(r.ok, false);
|
|
21
|
+
assert.deepEqual(r.missing, [{ file: "/bin/a.mjs", line: 1, cmd: "issue list", field: "authorAssociation" }]);
|
|
22
|
+
assert.match(formatStatic(r), /a\.mjs:1: gh issue list --json field "authorAssociation"/);
|
|
23
|
+
});
|
|
24
|
+
it("passes when every field is offered", () => {
|
|
25
|
+
const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["pr", "view", "1", "--json", "title,body"])' } });
|
|
26
|
+
assert.equal(r.ok, true); assert.equal(r.checked, 1);
|
|
27
|
+
});
|
|
28
|
+
it("fails closed when gh help cannot be read", () => {
|
|
29
|
+
const r = staticCheck({ root: "/bin", io: { gh: () => { throw new Error("no gh"); }, list: () => ["/a.mjs"], read: () => 'x(["pr", "view", "--json", "title"])' } });
|
|
30
|
+
assert.equal(r.ok, false);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/bin/pool/agent.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { cpus, hostname } from "node:os";
|
|
|
10
10
|
import { bearerOk, call, readJson, route, sameSecret, send } from "./http.mjs";
|
|
11
11
|
import { repoRoot, versionOf, laneImageTag, laneBuildArgs } from "./version.mjs";
|
|
12
12
|
import { jsonStore } from "./store.mjs";
|
|
13
|
+
import { makeWarm } from "./warm.mjs";
|
|
13
14
|
|
|
14
15
|
const LABEL = "computeshare=1";
|
|
15
16
|
const LOG_CHUNK = 256 * 1024;
|
|
@@ -60,7 +61,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
60
61
|
const store = jsonStore(join(stateDir, "state.json"), { sandboxes: {} });
|
|
61
62
|
const sb = () => store.data.sandboxes;
|
|
62
63
|
// Sandbox ids come from requests: read own keys only, so "__proto__" or "constructor" never resolves to an inherited member (#4566).
|
|
63
|
-
const own = (id) => (typeof id === "string" && Object.hasOwn(sb(), id) ? sb()[id] :
|
|
64
|
+
const own = (id) => (typeof id === "string" && Object.hasOwn(sb(), id) ? sb()[id] : warm.byAlias(id)); // #4912: a claimed warm sandbox also answers to its lane id
|
|
64
65
|
const leaseTtlMs = (cfg.lease_ttl_s ?? 600) * 1000;
|
|
65
66
|
const maxLifeMs = (cfg.max_lifetime_s ?? 6 * 3600) * 1000;
|
|
66
67
|
const keepExitedMs = (cfg.keep_exited_s ?? 2 * 3600) * 1000;
|
|
@@ -99,6 +100,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
99
100
|
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id) || !Array.isArray(spec.command) || !spec.command.length) throw Object.assign(new Error("spec.id ([A-Za-z0-9._-], max 128) and spec.command are required"), { status: 400 });
|
|
100
101
|
const existing = own(spec.id);
|
|
101
102
|
if (existing) { existing.lease = now(); store.save(); return { status: 200, body: view(existing) }; }
|
|
103
|
+
if (Number.isInteger(spec.warm)) warm.configure({ spec, cpus, mem_gb, limit, n: spec.warm }); // #4912: the lane's execution.warm_sandboxes sizes the repo's pool
|
|
102
104
|
const c = capacity();
|
|
103
105
|
if (c.paused || c.cpus_free < cpus || c.mem_free_gb < mem_gb) return { status: 503, body: { error: "no capacity", capacity: c }, retryAfter: 60 };
|
|
104
106
|
// Burst model: reservations are small, so also refuse when the VM is genuinely short of memory or overloaded right now.
|
|
@@ -108,6 +110,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
108
110
|
const dir = join(stateDir, "sandboxes", name);
|
|
109
111
|
mkdirSync(join(dir, "result"), { recursive: true });
|
|
110
112
|
mkdirSync(join(dir, "out"), { recursive: true });
|
|
113
|
+
if (spec.claim_dir) mkdirSync(join(dir, "claim"), { recursive: true });
|
|
111
114
|
const envFile = join(dir, "env");
|
|
112
115
|
const credToken = randomBytes(24).toString("hex");
|
|
113
116
|
const withDocker = Boolean(spec.docker);
|
|
@@ -147,6 +150,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
147
150
|
// --memory-swap above --memory lets a rare spike page out instead of being OOM-killed (swap deal).
|
|
148
151
|
"--cpus", String(laneCpus), "--memory", mb(laneMem), "--memory-swap", mb(laneMem + swapFor(limit?.lane_swap_gb)), "--pids-limit", "4096",
|
|
149
152
|
"--env-file", envFile, ...laneNet, ...work,
|
|
153
|
+
...(spec.claim_dir ? ["-v", `${join(dir, "claim")}:/claim`] : []), // #4912: warm sandboxes receive their lane here
|
|
150
154
|
"-v", `cs-cache-${cacheKey}:/cache`, "-v", `${join(dir, "result")}:/result`, "-v", `${join(dir, "out")}:/csout`,
|
|
151
155
|
spec.image ?? image, ...spec.command.map(String),
|
|
152
156
|
]);
|
|
@@ -165,6 +169,8 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
165
169
|
|
|
166
170
|
const view = (r) => ({ id: r.id, state: r.state, issue: r.issue, cpus: r.cpus, mem_gb: r.mem_gb, started_at: new Date(r.startedAt).toISOString(), ...(r.error ? { error: r.error } : {}) });
|
|
167
171
|
|
|
172
|
+
const warm = makeWarm({ store, sb, own, now, launch, destroy: (id, why) => destroy(id, why), view, log });
|
|
173
|
+
|
|
168
174
|
async function refresh(r) {
|
|
169
175
|
if (r.state === "exited") return r;
|
|
170
176
|
const s = await docker.inspect(r.name);
|
|
@@ -200,7 +206,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
200
206
|
if (r.docker) { await docker.remove(dindName(r.name)); await docker.removeVolume(workVolume(r.name)); }
|
|
201
207
|
try { await docker.wipe(r.dir, image); } catch (e) { log.warn?.(`wipe ${r.name}: ${String(e.stderr || e.message).slice(0, 200)}`); }
|
|
202
208
|
rmSync(r.dir, { recursive: true, force: true });
|
|
203
|
-
delete sb()[id];
|
|
209
|
+
delete sb()[r.id];
|
|
204
210
|
store.save();
|
|
205
211
|
// Written once, after the sandbox is really gone (a teardown that throws and is retried must not log it twice).
|
|
206
212
|
if (history) { try { appendFileSync(join(stateDir, "usage-history.jsonl"), history + "\n"); } catch { /* best effort */ } }
|
|
@@ -218,6 +224,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
218
224
|
const known = new Set(Object.values(sb()).flatMap((r) => [r.name, dindName(r.name)]));
|
|
219
225
|
for (const name of await docker.listLabelled(LABEL)) if (!known.has(name)) { await docker.remove(name); log.info?.(`removed orphan container ${name}`); }
|
|
220
226
|
await sampleUsage();
|
|
227
|
+
await warm.maintain(maxLifeMs);
|
|
221
228
|
if (pendingResize && capacity().active === 0) {
|
|
222
229
|
const r = await resize(pendingResize);
|
|
223
230
|
log.info?.(`pending resize applied: HTTP ${r.status} ${JSON.stringify(r.body)}`);
|
|
@@ -250,7 +257,8 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
250
257
|
const r = own(id);
|
|
251
258
|
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
252
259
|
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
253
|
-
const
|
|
260
|
+
const lane = r.alias ?? r.id; // #4935: a claimed warm sandbox posts under its warm id, but the router queue and the dispatcher use the lane id
|
|
261
|
+
const path = `/agent/relay/${encodeURIComponent(lane)}${rid ? `/${encodeURIComponent(rid)}` : ""}`;
|
|
254
262
|
const x = rid ? await call(cfg.router_url, "GET", path, { token: cfg.token, timeoutMs: 20000 })
|
|
255
263
|
: await call(cfg.router_url, "POST", path, { token: cfg.token, timeoutMs: 20000, body: { argv: (await readJson(req, 32 * 1024))?.argv } });
|
|
256
264
|
return { status: x.status, body: x.data };
|
|
@@ -366,7 +374,10 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
366
374
|
const routes = [
|
|
367
375
|
[route("GET", "/health"), async () => ({ status: 200, body: { ok: true, ...capacity() } })],
|
|
368
376
|
[route("GET", "/capacity"), async () => ({ status: 200, body: capacity() })],
|
|
369
|
-
[route("POST", "/sandboxes"), async (_p, body) => launch(body ?? {})],
|
|
377
|
+
[route("POST", "/sandboxes"), async (_p, body) => { const r = await launch(body ?? {}); if (Number.isInteger(body?.spec?.warm)) warm.replenish().catch((e) => log.warn?.(`warm replenish: ${e.message}`)); return r; }],
|
|
378
|
+
[route("GET", "/warm"), async () => ({ status: 200, body: { warm: warm.list() } })],
|
|
379
|
+
[route("POST", "/warm"), async (_p, body) => { const r = warm.configure({ ...(body ?? {}), n: body?.n }); await warm.replenish(); return { status: 200, body: { ...r, warm: warm.list() } }; }],
|
|
380
|
+
[route("POST", "/warm/claim"), async (_p, body) => warm.claim(body ?? {})],
|
|
370
381
|
[route("GET", "/sandboxes/:id"), async ({ id }) => { const r = own(id); if (!r) return { status: 404, body: { state: "gone" } }; await refresh(r); store.save(); return { status: 200, body: view(r) }; }],
|
|
371
382
|
[route("GET", "/sandboxes/:id/logs"), async ({ id }, _b, url) => { const r = own(id); return r ? { status: 200, body: readLog(r, url.searchParams.get("cursor")) } : { status: 404, body: { text: "", cursor: 0 } }; }],
|
|
372
383
|
[route("GET", "/sandboxes/:id/result"), async ({ id }) => { const r = own(id); const x = r && readResult(r); return x ? { status: 200, body: x } : { status: 404, body: { error: "no result" } }; }],
|
|
@@ -411,7 +422,7 @@ export function createAgent(cfg, { docker, now = () => Date.now(), log = console
|
|
|
411
422
|
await docker.build(join(cfg.code_root ?? repoRoot, "pool", "image"), image, laneBuildArgs(running.version));
|
|
412
423
|
}
|
|
413
424
|
|
|
414
|
-
return { handle, launch, reap, destroy, capacity, refresh, store, prepullImages, ensureImage };
|
|
425
|
+
return { handle, launch, warm, reap, destroy, capacity, refresh, store, prepullImages, ensureImage };
|
|
415
426
|
}
|
|
416
427
|
|
|
417
428
|
/**
|
package/bin/pool/router.mjs
CHANGED
|
@@ -167,6 +167,21 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
167
167
|
return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** #4912: hand the lane to an agent's ready warm sandbox; 404 when no agent has one (the caller then launches cold). */
|
|
171
|
+
async function claim(spec) {
|
|
172
|
+
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id)) return { status: 400, body: { error: "spec.id required ([A-Za-z0-9._-], max 128)" } };
|
|
173
|
+
if (own(sbx(), spec.id)) return launch(spec); // already placed: the same idempotent path
|
|
174
|
+
const size = sizeFor(spec);
|
|
175
|
+
for (const c of (await capacities()).filter((x) => x.ok && !x.paused)) {
|
|
176
|
+
const r = await agentCall(own(agents(), c.name), "POST", "/warm/claim", { spec: withForgeYaml(spec), ...size }, 30000);
|
|
177
|
+
if (!r.ok) continue;
|
|
178
|
+
sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() };
|
|
179
|
+
store.save();
|
|
180
|
+
return { status: 200, body: { ...(r.data ?? {}), agent: c.name } };
|
|
181
|
+
}
|
|
182
|
+
return { status: 404, body: { error: "no warm sandbox ready" } };
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
async function forward(id, method, path) {
|
|
171
186
|
const s = own(sbx(), id);
|
|
172
187
|
const a = s && own(agents(), s.agent);
|
|
@@ -253,6 +268,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
253
268
|
const routes = [
|
|
254
269
|
[route("GET", "/health"), { fn: async () => ({ status: 200, body: { ok: true, agents: Object.keys(agents()).length, sandboxes: Object.keys(sbx()).length } }), auth: "none" }],
|
|
255
270
|
[route("POST", "/sandboxes"), fd(async (_p, body) => launch(body))],
|
|
271
|
+
[route("POST", "/sandboxes/claim"), fd(async (_p, body) => claim(body))],
|
|
256
272
|
[route("GET", "/sandboxes/:id"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}`))],
|
|
257
273
|
[route("GET", "/sandboxes/:id/logs"), fd(async ({ id }, _b, url) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/logs?cursor=${encodeURIComponent(url.searchParams.get("cursor") ?? "0")}`))],
|
|
258
274
|
[route("GET", "/sandboxes/:id/result"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/result`))],
|
|
@@ -325,7 +341,7 @@ export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date
|
|
|
325
341
|
}
|
|
326
342
|
}
|
|
327
343
|
|
|
328
|
-
return { handle, launch, tick, enroll, issueCode, capacities, status, store };
|
|
344
|
+
return { handle, launch, claim, tick, enroll, issueCode, capacities, status, store };
|
|
329
345
|
}
|
|
330
346
|
|
|
331
347
|
export function startRouter(cfg, deps) {
|