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.
Files changed (62) hide show
  1. package/bin/engine/outcome-codes.mjs +6 -3
  2. package/bin/engine-cli.mjs +40 -4
  3. package/bin/forgedock.mjs +22 -1
  4. package/bin/lean/batch.test.mjs +33 -1
  5. package/bin/lean/capwait.mjs +36 -0
  6. package/bin/lean/capwait.test.mjs +48 -0
  7. package/bin/lean/conformance.mjs +150 -2
  8. package/bin/lean/conformance.test.mjs +126 -0
  9. package/bin/lean/depcache.mjs +10 -2
  10. package/bin/lean/depcache.test.mjs +22 -17
  11. package/bin/lean/execution.mjs +21 -5
  12. package/bin/lean/execution.test.mjs +7 -1
  13. package/bin/lean/heavy.mjs +22 -1
  14. package/bin/lean/lanecheck.mjs +129 -0
  15. package/bin/lean/lanecheck.test.mjs +118 -0
  16. package/bin/lean/lanelock.mjs +31 -0
  17. package/bin/lean/lean.mjs +102 -30
  18. package/bin/lean/lean.test.mjs +173 -3
  19. package/bin/lean/orchestrate.mjs +42 -7
  20. package/bin/lean/phaseledger.mjs +116 -0
  21. package/bin/lean/phaseledger.test.mjs +72 -0
  22. package/bin/lean/prodwatch.mjs +12 -1
  23. package/bin/lean/prodwatch.test.mjs +32 -3
  24. package/bin/lean/prompts.mjs +19 -0
  25. package/bin/lean/render.mjs +10 -2
  26. package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
  27. package/bin/lean/sandbox-remote.mjs +8 -1
  28. package/bin/lean/schedd.mjs +5 -1
  29. package/bin/lean/selftest-gate.mjs +46 -0
  30. package/bin/lean/selftest-gate.test.mjs +40 -0
  31. package/bin/lean/selftest-live.mjs +248 -0
  32. package/bin/lean/selftest-live.test.mjs +154 -0
  33. package/bin/lean/selftest-nightly.mjs +81 -0
  34. package/bin/lean/selftest-nightly.test.mjs +84 -0
  35. package/bin/lean/selftest-static.mjs +81 -0
  36. package/bin/lean/selftest-static.test.mjs +32 -0
  37. package/bin/pool/agent.mjs +16 -5
  38. package/bin/pool/router.mjs +17 -1
  39. package/bin/pool/warm.mjs +80 -0
  40. package/bin/review-staging.mjs +28 -6
  41. package/bin/stats/aggregate.mjs +2 -1
  42. package/bin/stats/efficiency.mjs +3 -1
  43. package/bin/stats/efficiency.test.mjs +4 -1
  44. package/bin/stats/github-source.mjs +2 -1
  45. package/bin/stats/local-source.mjs +1 -0
  46. package/bin/stats/render.mjs +2 -0
  47. package/commands/deploy-pr.md +3 -3
  48. package/commands/review-pr-staging.md +2 -2
  49. package/commands/work-on/build.md +2 -2
  50. package/docs/CONFIG.md +33 -0
  51. package/docs/OUTCOME-CODES.md +1 -1
  52. package/docs/PROD-WATCH.md +1 -1
  53. package/docs/RELEASE-NOTES.md +10 -0
  54. package/docs/SELFTEST.md +34 -0
  55. package/docs/eval/conformance-labels-v1.json +13 -0
  56. package/package.json +1 -1
  57. package/pool/image/Dockerfile +2 -1
  58. package/pool/image/entrypoint.sh +44 -11
  59. package/pool/image/warm-refresh.sh +14 -0
  60. package/scripts/conformance-calibrate.mjs +132 -0
  61. package/scripts/file-deferred-followups.sh +21 -8
  62. 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, "&lt;").replace(/>/g, "&gt;");
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
+ });
@@ -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] : undefined);
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 path = `/agent/relay/${encodeURIComponent(id)}${rid ? `/${encodeURIComponent(rid)}` : ""}`;
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
  /**
@@ -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) {