forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Lane resource ledger (#4179): `~/.forge/ledger/<batch>/<lane>.json` lists every resource a lane owns (worktree, branch,
5
+ * run tmp, spawned pids, containers/volumes the builder reported) with owner, batch, lane, issue, created and expires.
6
+ * Each resource is registered BEFORE it is created and released only after its teardown succeeded, so a lane that dies
7
+ * leaves an exact record behind. A `sandbox` entry (#4253, execution.mjs) carries `{provider, handle}` (never a secret);
8
+ * a file listing only sandboxes is a remote lane, not a lane on this host. Teardown acts only on what the ledger lists; this module itself only reads and writes
9
+ * the ledger file (no git, gh, docker or kill calls).
10
+ */
11
+ import * as nodeFs from "node:fs";
12
+ import { homedir, hostname } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export const ledgerRoot = () => join(homedir(), ".forge", "ledger");
16
+ export const LEDGER_TTL_MS = 6 * 3600 * 1000;
17
+ export const LEDGER_KINDS = Object.freeze(["worktree", "branch", "tmp", "pid", "container", "volume", "sandbox"]);
18
+ /** A path segment that cannot escape the root: [A-Za-z0-9._-] only, never "." or "..". */
19
+ export const safeSegment = (s) => { const v = String(s ?? "").replace(/[^A-Za-z0-9._-]/g, "_"); return !v || /^\.+$/.test(v) ? "_" : v; };
20
+
21
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
22
+
23
+ /**
24
+ * Live lanes on this host (#4182): lane files under `<root>/<batch>/` whose host is `host`, whose pid is alive and whose
25
+ * expiry is in the future. A file listing only `sandbox` resources (#4253) runs off this host and is not counted. Read-only: never writes or deletes; unreadable or malformed files are skipped.
26
+ */
27
+ export function countLiveLanes({ root = ledgerRoot(), host = hostname(), now = () => Date.now(), alive = pidAlive, fs = nodeFs } = {}) {
28
+ let n = 0, batches = [];
29
+ try { batches = fs.readdirSync(root); } catch { return 0; }
30
+ for (const b of batches) {
31
+ let names = [];
32
+ try { names = fs.readdirSync(join(root, b)); } catch { continue; }
33
+ for (const f of names) {
34
+ if (!f.endsWith(".json")) continue;
35
+ try {
36
+ const j = JSON.parse(fs.readFileSync(join(root, b, f), "utf8"));
37
+ const remote = Array.isArray(j?.resources) && j.resources.length > 0 && j.resources.every((r) => r?.kind === "sandbox");
38
+ if (j && !remote && j.host === host && Number.isSafeInteger(j.pid) && j.pid > 0 && Number(j.expires) > now() && alive(j.pid)) n++;
39
+ } catch { /* not a lane file */ }
40
+ }
41
+ }
42
+ return n;
43
+ }
44
+
45
+ /** Docker ownership labels (#4181): owner, batch, lane, issue, expires (epoch ms, as the ledger), values [A-Za-z0-9_.-] only. */
46
+ export function dockerLabels({ batch, lane, issue, expires } = {}) {
47
+ const v = (x) => String(x ?? "").replace(/[^A-Za-z0-9_.-]/g, "-") || "unknown";
48
+ return [`forgedock.owner=1`, `forgedock.batch=${v(batch)}`, `forgedock.lane=${v(lane)}`, `forgedock.issue=${v(issue)}`, `forgedock.expires=${v(expires)}`];
49
+ }
50
+
51
+ export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner, issue, host = hostname(), pid = process.pid, now = () => Date.now(), ttlMs = LEDGER_TTL_MS, fs = nodeFs } = {}) {
52
+ const dir = join(root, safeSegment(batch));
53
+ const path = join(dir, `${safeSegment(lane ?? `lane-${issue}`)}.json`);
54
+ const read = () => { try { const j = JSON.parse(fs.readFileSync(path, "utf8")); return j && typeof j === "object" && Array.isArray(j.resources) ? j : null; } catch { return null; } };
55
+ const write = (j) => {
56
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
57
+ const tmp = `${path}.tmp-${process.pid}`;
58
+ fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n");
59
+ fs.renameSync(tmp, path);
60
+ };
61
+ const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, host, pid, created: c, expires: c + ttlMs, resources: [] }; };
62
+ const same = (r, kind, value) => r.kind === kind && String(r.value) === String(value);
63
+ return {
64
+ path,
65
+ read,
66
+ /** Idempotent; call BEFORE the resource is created. `extra` (e.g. {marker}) is kept on the entry. */
67
+ register(kind, value, extra = {}) {
68
+ if (!LEDGER_KINDS.includes(kind) || value == null || value === "") return false;
69
+ const j = read() ?? fresh();
70
+ Object.assign(j, { owner, host, pid, expires: now() + ttlMs });
71
+ if (!j.resources.some((r) => same(r, kind, value))) j.resources.push({ kind, value, ...extra, registered: now() });
72
+ write(j);
73
+ return true;
74
+ },
75
+ release(kind, value) {
76
+ const j = read();
77
+ if (!j) return false;
78
+ const n = j.resources.length;
79
+ j.resources = j.resources.filter((r) => !same(r, kind, value));
80
+ if (j.resources.length !== n) write(j);
81
+ return j.resources.length !== n;
82
+ },
83
+ entries(kind) { return (read()?.resources ?? []).filter((r) => !kind || r.kind === kind); },
84
+ /** Delete the file only when it lists nothing; a non-empty ledger stays for the next run or the reaper. */
85
+ remove() {
86
+ const j = read();
87
+ if (j && j.resources.length) return false;
88
+ try { fs.rmSync(path, { force: true }); } catch { return false; }
89
+ return true;
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,100 @@
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 { existsSync, readFileSync, readdirSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { makeLedger, countLiveLanes, safeSegment, LEDGER_TTL_MS, dockerLabels } from "./ledger.mjs";
9
+
10
+ describe("lane resource ledger (#4179)", () => {
11
+ it("register/release round trip carries owner, batch, lane, issue, created and expires", () => {
12
+ const root = tmpDir("ledger-");
13
+ let t = 1000;
14
+ const l = makeLedger({ root, batch: "b20261019-1", lane: "lean-7", owner: "core_1", issue: 7, host: "h", pid: 11, now: () => t });
15
+ assert.equal(l.path, join(root, "b20261019-1", "lean-7.json"));
16
+ assert.equal(l.read(), null);
17
+ assert.equal(l.register("worktree", "/wt/lean-7"), true);
18
+ l.register("branch", "feat/issue-7-lean");
19
+ l.register("pid", 4242, { marker: "claude" });
20
+ l.register("worktree", "/wt/lean-7"); // idempotent
21
+ t = 2000;
22
+ l.register("container", "lean7-pg");
23
+ const j = JSON.parse(readFileSync(l.path, "utf8"));
24
+ assert.deepEqual({ owner: j.owner, batch: j.batch, lane: j.lane, issue: j.issue, host: j.host, pid: j.pid, created: j.created }, { owner: "core_1", batch: "b20261019-1", lane: "lean-7", issue: 7, host: "h", pid: 11, created: 1000 });
25
+ assert.equal(j.expires, 2000 + LEDGER_TTL_MS, "each registration extends expiry");
26
+ assert.deepEqual(j.resources.map((r) => `${r.kind}:${r.value}`), ["worktree:/wt/lean-7", "branch:feat/issue-7-lean", "pid:4242", "container:lean7-pg"]);
27
+ assert.equal(l.entries("pid")[0].marker, "claude");
28
+ assert.equal(l.register("nonsense", "x"), false, "unknown kinds are refused");
29
+ assert.equal(l.release("pid", "4242"), true);
30
+ assert.equal(l.release("pid", 4242), false);
31
+ assert.equal(l.remove(), false, "a ledger that still lists resources is kept");
32
+ for (const r of l.entries()) l.release(r.kind, r.value);
33
+ assert.equal(l.remove(), true);
34
+ assert.equal(existsSync(l.path), false);
35
+ });
36
+
37
+ it("writes are atomic (no tmp file left behind) and names cannot escape the root", () => {
38
+ const root = tmpDir("ledger-");
39
+ const l = makeLedger({ root, batch: "../../etc", lane: "../passwd", issue: 1 });
40
+ l.register("tmp", "/x");
41
+ assert.equal(dirname(dirname(l.path)), root);
42
+ assert.deepEqual(readdirSync(dirname(l.path)), [l.path.split("/").at(-1)]);
43
+ for (const [i, o] of [["..", "_"], [".", "_"], ["", "_"], ["a/b", "a_b"], ["milestone/x", "milestone_x"], ["b1.2", "b1.2"]]) assert.equal(safeSegment(i), o);
44
+ });
45
+
46
+ it("a simulated crash (no teardown) leaves every registered resource readable by a fresh process", () => {
47
+ const root = tmpDir("ledger-");
48
+ const l = makeLedger({ root, batch: "b1", lane: "lean-9", owner: "core_9", issue: 9 });
49
+ for (const [k, v] of [["tmp", "/home/x/.forge/tmp/runs/9-1"], ["branch", "feat/issue-9-lean"], ["worktree", "/wt/lean-9"], ["pid", 77]]) l.register(k, v);
50
+ // the process dies here: nothing is released
51
+ const after = makeLedger({ root, batch: "b1", lane: "lean-9", issue: 9 }).read();
52
+ assert.equal(after.owner, "core_9");
53
+ assert.deepEqual(after.resources.map((r) => r.kind), ["tmp", "branch", "worktree", "pid"]);
54
+ });
55
+ });
56
+
57
+ describe("live lane count (#4182)", () => {
58
+ it("counts only live, same-host, unexpired lane files across batches, and deletes nothing", async () => {
59
+ const { mkdirSync, writeFileSync } = await import("node:fs");
60
+ const root = tmpDir("ledger-count-");
61
+ const t = 10_000;
62
+ const lane = (batch, name, o) => { mkdirSync(join(root, batch), { recursive: true }); writeFileSync(join(root, batch, `${name}.json`), typeof o === "string" ? o : JSON.stringify({ v: 1, host: "h", pid: 11, expires: t + 1, resources: [], ...o })); };
63
+ lane("b1", "lean-1", {});
64
+ lane("b1", "lean-2", { pid: 12 });
65
+ lane("adhoc", "lean-3", { pid: 13 });
66
+ lane("b1", "dead", { pid: 99 });
67
+ lane("b1", "other-host", { host: "zzz" });
68
+ lane("b1", "expired", { expires: t });
69
+ lane("b1", "malformed", "{not json");
70
+ lane("b1", "no-pid", { pid: null });
71
+ writeFileSync(join(root, "b1", "lean-1.json.tmp-5"), "{}");
72
+ writeFileSync(join(root, "stray-file"), "x");
73
+ const before = readdirSync(join(root, "b1")).sort();
74
+ const seen = [];
75
+ const n = countLiveLanes({ root, host: "h", now: () => t, alive: (pid) => { seen.push(pid); return pid !== 99; } });
76
+ assert.equal(n, 3);
77
+ assert.ok(!seen.includes(null));
78
+ assert.deepEqual(readdirSync(join(root, "b1")).sort(), before, "read-only");
79
+ assert.equal(countLiveLanes({ root: join(root, "missing"), host: "h" }), 0);
80
+ });
81
+
82
+ it("a registered lane counts itself by default (own pid alive)", () => {
83
+ const root = tmpDir("ledger-self-");
84
+ const l = makeLedger({ root, batch: "adhoc", lane: "lean-9", owner: "core_1", issue: 9, host: "h" });
85
+ assert.equal(countLiveLanes({ root, host: "h" }), 0, "nothing registered yet: no file");
86
+ l.register("tmp", "/x/runs/9-1");
87
+ assert.equal(countLiveLanes({ root, host: "h" }), 1);
88
+ assert.equal(countLiveLanes({ root, host: "other" }), 0);
89
+ });
90
+ });
91
+
92
+ describe("docker ownership labels (#4181)", () => {
93
+ it("owner, batch, lane, issue and expires, in order; values cannot inject", () => {
94
+ assert.deepEqual(dockerLabels({ batch: "b1", lane: "lean-7", issue: 7, expires: 123 }),
95
+ ["forgedock.owner=1", "forgedock.batch=b1", "forgedock.lane=lean-7", "forgedock.issue=7", "forgedock.expires=123"]);
96
+ const l = dockerLabels({ batch: "a b;rm -rf", lane: "", issue: null, expires: undefined });
97
+ assert.equal(l[1], "forgedock.batch=a-b-rm--rf"); assert.equal(l[2], "forgedock.lane=unknown"); assert.equal(l[3], "forgedock.issue=unknown");
98
+ for (const e of l) assert.match(e, /^forgedock\.[a-z]+=[A-Za-z0-9_.-]+$/);
99
+ });
100
+ });
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Account-scoped usage-limit holds (#4190). A usage limit belongs to the account a lane runs on, not to a batch: holds
5
+ * live in one host-level file, `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`), keyed by `backendIdentity`, so two
6
+ * batches on the same account share one hold and a lane on another account is admitted. `forgedock limits show|clear`
7
+ * inspects and clears them. Every write is a fresh read-modify-write through `writeAtomic`; a missing or corrupt file
8
+ * reads as no holds (fail-open: a lost hold costs one more limited lane, a stuck one stalls every batch).
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { backendIdentity } from "../engine.mjs";
14
+ import { writeAtomic } from "./batch.mjs";
15
+
16
+ /** Identity of the account a core lane runs on: the lane always uses the claude CLI with the dispatcher's env. */
17
+ export function laneBackendId(env = process.env) {
18
+ return backendIdentity({ backend: "cli", env });
19
+ }
20
+
21
+ export function limitsFile(env = process.env) {
22
+ return env.FORGEDOCK_LIMITS_FILE || join(homedir(), ".forge", "limits.json");
23
+ }
24
+
25
+ /** Handle on the hold store. @returns {{holdUntil, set, clear, clearAll, list}} */
26
+ export function openLimits(file = limitsFile(), { now = () => Date.now() } = {}) {
27
+ const read = () => {
28
+ let o = null;
29
+ try { o = JSON.parse(readFileSync(file, "utf8")); } catch { o = null; }
30
+ const holds = o && typeof o.holds === "object" && o.holds && !Array.isArray(o.holds) ? o.holds : {};
31
+ const t = now(), live = {};
32
+ for (const [id, h] of Object.entries(holds)) if (Number.isFinite(h?.until) && h.until > t) live[id] = h;
33
+ return live;
34
+ };
35
+ const write = (holds) => writeAtomic(file, JSON.stringify({ v: 1, holds }, null, 2) + "\n");
36
+ return {
37
+ file,
38
+ /** Epoch ms the account `id` is held until, or null. */
39
+ holdUntil: (id) => read()[id]?.until ?? null,
40
+ set: (id, untilMs, source = null) => {
41
+ const holds = read();
42
+ const prev = holds[id];
43
+ if (prev && prev.until >= untilMs) return prev.until;
44
+ holds[id] = { until: untilMs, source, setAt: now() };
45
+ write(holds);
46
+ return untilMs;
47
+ },
48
+ clear: (id) => {
49
+ const holds = read();
50
+ if (!holds[id]) return false;
51
+ delete holds[id];
52
+ write(holds);
53
+ return true;
54
+ },
55
+ clearAll: () => { const n = Object.keys(read()).length; write({}); return n; },
56
+ list: () => Object.entries(read()).map(([id, h]) => ({ id, until: h.until, source: h.source ?? null, setAt: h.setAt ?? null })),
57
+ };
58
+ }
@@ -0,0 +1,150 @@
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 { readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { openLimits, laneBackendId, limitsFile } from "./limits.mjs";
9
+ import { orchestrate } from "./orchestrate.mjs";
10
+ import { limitsFromCli } from "../engine-cli.mjs";
11
+
12
+ const fakeClock = () => { const c = { t: 1_000_000 }; return { c, now: () => c.t, sleep: async (ms) => { c.t += ms; } }; };
13
+ const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
14
+ const storeFile = () => join(tmpDir("limits-"), "limits.json");
15
+ const limited = (id, until) => ({ terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: until, backendId: id });
16
+
17
+ describe("account-scoped usage-limit holds (#4190)", () => {
18
+ it("store: set keeps the later reset, expiry drops a hold, clear and clearAll remove holds", () => {
19
+ const { c, now } = fakeClock();
20
+ const file = storeFile();
21
+ const a = openLimits(file, { now });
22
+ assert.equal(a.holdUntil("A"), null);
23
+ a.set("A", c.t + 1000, "batch b1 #1");
24
+ a.set("A", c.t + 500, "batch b1 #2"); // an earlier reset never shortens the hold
25
+ assert.equal(a.holdUntil("A"), c.t + 1000);
26
+ assert.deepEqual(a.list().map((h) => [h.id, h.source]), [["A", "batch b1 #1"]]);
27
+ // a second handle on the same file (another batch) sees the hold
28
+ const b = openLimits(file, { now });
29
+ assert.equal(b.holdUntil("A"), c.t + 1000);
30
+ b.set("B", c.t + 5000, "batch b2 #9");
31
+ assert.equal(a.holdUntil("B"), c.t + 5000);
32
+ assert.equal(a.clear("B"), true);
33
+ assert.equal(a.clear("B"), false);
34
+ c.t += 1000; // expiry
35
+ assert.equal(a.holdUntil("A"), null);
36
+ a.set("C", c.t + 10, "x");
37
+ assert.deepEqual(Object.keys(JSON.parse(readFileSync(file, "utf8")).holds), ["C"], "expired holds are pruned on write");
38
+ assert.equal(a.clearAll(), 1);
39
+ assert.deepEqual(a.list(), []);
40
+ });
41
+
42
+ it("store: a missing or corrupt file reads as no holds", () => {
43
+ const file = storeFile();
44
+ assert.deepEqual(openLimits(file).list(), []);
45
+ writeFileSync(file, "{not json");
46
+ assert.equal(openLimits(file).holdUntil("A"), null);
47
+ openLimits(file).set("A", Date.now() + 60_000, "s");
48
+ assert.ok(openLimits(file).holdUntil("A") > Date.now());
49
+ });
50
+
51
+ it("identity follows the account (CLAUDE_CONFIG_DIR) and the store path honours FORGEDOCK_LIMITS_FILE", () => {
52
+ assert.notEqual(laneBackendId({ CLAUDE_CONFIG_DIR: "/acct/one" }), laneBackendId({ CLAUDE_CONFIG_DIR: "/acct/two" }));
53
+ assert.equal(laneBackendId({ CLAUDE_CONFIG_DIR: "/acct/one" }), laneBackendId({ CLAUDE_CONFIG_DIR: "/acct/one" }));
54
+ assert.equal(limitsFile({ FORGEDOCK_LIMITS_FILE: "/x/l.json" }), "/x/l.json");
55
+ assert.match(limitsFile({}), /\.forge[/\\]limits\.json$/);
56
+ });
57
+
58
+ it("a hold is tied to the account: after an account switch the limited issue and the rest run at once", async () => {
59
+ const { c, now, sleep } = fakeClock();
60
+ const t0 = c.t;
61
+ const limits = openLimits(storeFile(), { now });
62
+ let acct = "A";
63
+ const starts = {};
64
+ let heldAtRetry;
65
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", limits, identity: () => acct, batchId: "b1",
66
+ runOne: async (n) => {
67
+ (starts[n] ??= []).push(now());
68
+ if (n === 1 && starts[1].length === 1) { acct = "B"; return limited("A", t0 + 600_000); } // the operator switches accounts
69
+ heldAtRetry ??= { A: limits.holdUntil("A"), B: limits.holdUntil("B"), source: limits.list()[0]?.source };
70
+ return { terminalReason: "merged" };
71
+ } });
72
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
73
+ assert.equal(starts[2][0], t0, "#2 runs on account B without waiting for A's reset");
74
+ assert.equal(starts[1][1], t0, "#1 is retried on account B at once");
75
+ assert.deepEqual(heldAtRetry, { A: t0 + 600_000, B: null, source: "batch b1 #1" }, "A stays held in the host store; B is not");
76
+ });
77
+
78
+ it("a hold on the same account still waits for the reset", async () => {
79
+ const { c, now, sleep } = fakeClock();
80
+ const t0 = c.t;
81
+ const limits = openLimits(storeFile(), { now });
82
+ const starts = [];
83
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A",
84
+ runOne: async () => { starts.push(now()); return starts.length === 1 ? limited("A", t0 + 600_000) : { terminalReason: "merged" }; } });
85
+ assert.deepEqual(starts, [t0, t0 + 600_000]);
86
+ });
87
+
88
+ it("two batches on the same account share one hold; a batch on another account is not held", async () => {
89
+ // Each batch is its own dispatcher with its own clock and its own handle on the one host file.
90
+ const file = storeFile();
91
+ const k1 = fakeClock(), t0 = k1.c.t;
92
+ const b1 = [];
93
+ await orchestrate({ issues: [1], now: k1.now, sleep: k1.sleep, readBody: async () => "", limits: openLimits(file, { now: k1.now }), identity: () => "A", batchId: "b1",
94
+ runOne: async () => { b1.push(k1.now()); return b1.length === 1 ? limited("A", t0 + 600_000) : { terminalReason: "merged" }; } });
95
+ const k2 = fakeClock(), same = [];
96
+ await orchestrate({ issues: [7], now: k2.now, sleep: k2.sleep, readBody: async () => "", limits: openLimits(file, { now: k2.now }), identity: () => "A", batchId: "b2",
97
+ runOne: async () => { same.push(k2.now()); return { terminalReason: "merged" }; } });
98
+ assert.equal(same[0], t0 + 600_000, "batch 2 on account A waited for batch 1's hold");
99
+ const k3 = fakeClock(), other = [];
100
+ await orchestrate({ issues: [8], now: k3.now, sleep: k3.sleep, readBody: async () => "", limits: openLimits(file, { now: k3.now }), identity: () => "B", batchId: "b3",
101
+ runOne: async () => { other.push(k3.now()); return { terminalReason: "merged" }; } });
102
+ assert.equal(other[0], t0, "batch 3 on account B is not held");
103
+ });
104
+
105
+ it("clearing a hold lets a held batch admit on its next pass", async () => {
106
+ const { c, now } = fakeClock();
107
+ const t0 = c.t;
108
+ const limits = openLimits(storeFile(), { now });
109
+ limits.set("A", t0 + 3_600_000, "batch b0 #1");
110
+ const starts = [];
111
+ const sleep = async (ms) => { limits.clear("A"); c.t += Math.min(ms, 5000); };
112
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A", getParallel: () => 1, pollMs: 5000,
113
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
114
+ assert.equal(starts[0], t0 + 5000, "admitted on the poll after the clear, not at the hour-long reset");
115
+ });
116
+
117
+ it("a legacy batch-local holdUntil is ignored on resume and no longer persisted", async () => {
118
+ const { c, now, sleep } = fakeClock();
119
+ let state = { v: 1, order: [1], pending: [1], holdUntil: c.t + 3_600_000 };
120
+ const store = { load: () => state, save: (s) => { state = s; } };
121
+ const starts = [];
122
+ const rows = await orchestrate({ issues: [], now, sleep, readBody: async () => "", store, limits: openLimits(storeFile(), { now }), identity: () => "A",
123
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
124
+ assert.equal(rows[0].outcome, "merged");
125
+ assert.equal(starts[0], 1_000_000);
126
+ assert.equal("holdUntil" in state, false);
127
+ });
128
+
129
+ it("forgedock limits show lists holds and marks this account; clear <id> and clear --all drop them", async () => {
130
+ const limits = openLimits(storeFile());
131
+ const until = Date.now() + 600_000;
132
+ limits.set("aaaa", until, "batch b1 #1");
133
+ limits.set("bbbb", until, "batch b2 #2");
134
+ const lines = [];
135
+ const l = console.log; console.log = (s) => lines.push(s);
136
+ let shown;
137
+ try { shown = await limitsFromCli(["show"], { limits, identity: () => "aaaa" }); } finally { console.log = l; }
138
+ assert.equal(shown.current, "aaaa");
139
+ assert.equal(shown.holds.length, 2);
140
+ assert.match(lines.join("\n"), new RegExp(`^\\* aaaa until ${new Date(until).toISOString()} source batch b1 #1$`, "m"));
141
+ assert.match(lines.join("\n"), /^ {2}bbbb .*source batch b2 #2$/m);
142
+ await quiet(() => limitsFromCli(["clear", "bbbb"], { limits, identity: () => "aaaa" }));
143
+ assert.equal(limits.holdUntil("bbbb"), null);
144
+ await assert.rejects(() => quiet(() => limitsFromCli(["clear", "bbbb"], { limits })), /no active usage-limit hold/);
145
+ await assert.rejects(() => quiet(() => limitsFromCli(["clear"], { limits })), /usage: forgedock limits/);
146
+ await assert.rejects(() => quiet(() => limitsFromCli(["nope"], { limits })), /usage: forgedock limits/);
147
+ assert.deepEqual(await quiet(() => limitsFromCli(["clear", "--all"], { limits })), { cleared: 1 });
148
+ assert.deepEqual(limits.list(), []);
149
+ });
150
+ });