forgedock 1.32.1 → 1.33.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 +7 -3
- package/bin/engine/phases.mjs +3 -3
- package/bin/engine-cli.mjs +56 -11
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.test.mjs +103 -2
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.mjs +102 -12
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +49 -21
- package/bin/lean/lean.test.mjs +133 -7
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +63 -17
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export async function readJson(req, limit = 1 << 20) {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let size = 0;
|
|
8
|
+
for await (const c of req) {
|
|
9
|
+
size += c.length;
|
|
10
|
+
if (size > limit) throw Object.assign(new Error("body too large"), { status: 413 });
|
|
11
|
+
chunks.push(c);
|
|
12
|
+
}
|
|
13
|
+
if (!size) return null;
|
|
14
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw Object.assign(new Error("invalid json"), { status: 400 }); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function send(res, status, data, headers = {}) {
|
|
18
|
+
const body = data === undefined ? "" : typeof data === "string" ? data : JSON.stringify(data);
|
|
19
|
+
res.writeHead(status, { "content-type": typeof data === "string" ? "text/plain; charset=utf-8" : "application/json", ...headers });
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sameSecret(a, b) {
|
|
24
|
+
const x = Buffer.from(String(a ?? "")), y = Buffer.from(String(b ?? ""));
|
|
25
|
+
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the request carries `Authorization: Bearer <token>`. */
|
|
29
|
+
export function bearerOk(req, token) {
|
|
30
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
31
|
+
return Boolean(m) && sameSecret(m[1].trim(), token);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** JSON call to another computeshare process; never throws on HTTP status, only on transport failure. */
|
|
35
|
+
export async function call(base, method, path, { body, token, timeoutMs = 15000, fetchFn = globalThis.fetch } = {}) {
|
|
36
|
+
const res = await fetchFn(`${String(base).replace(/\/+$/, "")}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
39
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
40
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
41
|
+
});
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
let data = null;
|
|
44
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
45
|
+
return { status: res.status, ok: res.ok, data, retryAfter: res.headers.get("retry-after") };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function route(method, pattern) {
|
|
49
|
+
const re = new RegExp(`^${pattern.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`);
|
|
50
|
+
return (req, url) => {
|
|
51
|
+
if (req.method !== method) return null;
|
|
52
|
+
const m = re.exec(url.pathname);
|
|
53
|
+
return m ? Object.fromEntries(Object.entries(m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)])) : null;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { bearerOk, call, readJson, route, sameSecret, send } from "./http.mjs";
|
|
11
|
+
import { jsonStore } from "./store.mjs";
|
|
12
|
+
import { versionOf } from "./version.mjs";
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Router: the endpoint ForgeDock's remote provider talks to (POST /sandboxes, GET /sandboxes/:id[/logs|/result|/usage],
|
|
18
|
+
* DELETE /sandboxes/:id). It places each sandbox on the enrolled agent with the most free reserved CPU, forwards every
|
|
19
|
+
* later call to that agent, renews the agent lease while ForgeDock keeps polling, and destroys sandboxes ForgeDock
|
|
20
|
+
* has abandoned. It also serves the agent installer and enrollment.
|
|
21
|
+
*/
|
|
22
|
+
export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
|
|
23
|
+
const store = jsonStore(join(cfg.state_dir, "router-state.json"), { agents: {}, sandboxes: {} });
|
|
24
|
+
const agents = () => store.data.agents;
|
|
25
|
+
const sbx = () => store.data.sandboxes;
|
|
26
|
+
const lane = { cpus: cfg.lane?.cpus ?? 2, mem_gb: cfg.lane?.mem_gb ?? 3 };
|
|
27
|
+
const abandonMs = (cfg.abandon_after_s ?? 1800) * 1000;
|
|
28
|
+
const codes = () => (store.data.codes ??= {});
|
|
29
|
+
// State maps are keyed by caller input (install codes, agent names, sandbox ids): read own keys only, so a key like
|
|
30
|
+
// "__proto__" or "constructor" never resolves to an inherited member.
|
|
31
|
+
const own = (map, key) => (typeof key === "string" && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
32
|
+
const codeTtlMs = (cfg.code_ttl_s ?? 1800) * 1000;
|
|
33
|
+
/** The long-lived enroll token, or an unexpired one-time install code (consumed by a successful enrollment). */
|
|
34
|
+
function enrollOk(token, consume = false) {
|
|
35
|
+
if (sameSecret(token, cfg.enroll_token)) return true;
|
|
36
|
+
const c = own(codes(), token);
|
|
37
|
+
if (!c || c.used || c.expires <= now()) return false;
|
|
38
|
+
if (consume) { c.used = true; store.save(); }
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function issueCode() {
|
|
42
|
+
const code = randomBytes(9).toString("base64url");
|
|
43
|
+
codes()[code] = { expires: now() + codeTtlMs, used: false };
|
|
44
|
+
store.save();
|
|
45
|
+
return { code, expires_at: new Date(now() + codeTtlMs).toISOString() };
|
|
46
|
+
}
|
|
47
|
+
/** The enrolled agent whose token the request carries, or null. */
|
|
48
|
+
function agentFor(req) {
|
|
49
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
return Object.entries(agents()).find(([, a]) => sameSecret(m[1].trim(), a.token))?.[0] ?? null;
|
|
52
|
+
}
|
|
53
|
+
// Fresh GitHub token for sandboxes, read from this host's gh (kept fresh by its own refresh timer).
|
|
54
|
+
let gh = { token: null, at: 0 };
|
|
55
|
+
async function githubToken() {
|
|
56
|
+
if (!gh.token || now() - gh.at > 60000) {
|
|
57
|
+
const { stdout } = await run(cfg.gh_bin ?? "gh", ["auth", "token"], { timeout: 20000 });
|
|
58
|
+
gh = { token: stdout.trim(), at: now() };
|
|
59
|
+
}
|
|
60
|
+
return gh.token;
|
|
61
|
+
}
|
|
62
|
+
// Claude access token for sandboxes: the logged-in account on this host with the most validity left. Only the
|
|
63
|
+
// short-lived access token is shared; refresh tokens stay here, so this host keeps refreshing its own sessions.
|
|
64
|
+
function claudeToken() {
|
|
65
|
+
const dirs = cfg.claude_config_dirs ?? readdirSync(homedir()).filter((d) => /^\.claude(-\d+)?$/.test(d)).map((d) => join(homedir(), d));
|
|
66
|
+
let best = null;
|
|
67
|
+
for (const d of dirs) {
|
|
68
|
+
try {
|
|
69
|
+
const o = JSON.parse(readFileSync(join(d, ".credentials.json"), "utf8")).claudeAiOauth;
|
|
70
|
+
if (o?.accessToken && o.expiresAt > now() + 30 * 60000 && (!best || o.expiresAt > best.expiresAt)) best = o;
|
|
71
|
+
} catch { /* no login in this dir */ }
|
|
72
|
+
}
|
|
73
|
+
if (!best) throw Object.assign(new Error("no Claude login on the router host with 30+ minutes left"), { status: 503 });
|
|
74
|
+
return { token: best.accessToken, expires_at: new Date(best.expiresAt).toISOString() };
|
|
75
|
+
}
|
|
76
|
+
const agentCall = (a, method, path, body, timeoutMs) => call(a.url, method, path, { body, token: a.token, timeoutMs, fetchFn });
|
|
77
|
+
|
|
78
|
+
async function capacities() {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const [name, a] of Object.entries(agents())) {
|
|
81
|
+
try {
|
|
82
|
+
const r = await agentCall(a, "GET", "/capacity", undefined, 5000);
|
|
83
|
+
out.push(r.ok ? { name, ok: true, ...r.data } : { name, ok: false, error: `HTTP ${r.status}` });
|
|
84
|
+
} catch (e) { out.push({ name, ok: false, error: String(e.message ?? e) }); }
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Agent liveness for the scheduler (#4547): an agent is online when it answers and is not paused. */
|
|
90
|
+
async function status() {
|
|
91
|
+
const caps = await capacities();
|
|
92
|
+
const agentsView = caps.map((c) => ({ name: c.name, ok: c.ok, paused: Boolean(c.paused) }));
|
|
93
|
+
return { online: agentsView.filter((a) => a.ok && !a.paused).length, total: agentsView.length, agents: agentsView };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Per-repo extras: the gitignored forge.yaml, and a private Docker daemon (with a bigger reservation) when enabled. */
|
|
97
|
+
const repoCfg = (spec) => cfg.repos?.[spec?.repo?.slug] ?? {};
|
|
98
|
+
// Placement counts the (small) reservation; `limit` is each container's hard burst cap (burst model, when configured).
|
|
99
|
+
const sizeFor = (spec) => {
|
|
100
|
+
const docker = Boolean(repoCfg(spec).docker), dind = cfg.dind ?? { cpus: 1, mem_gb: 2 };
|
|
101
|
+
const size = docker ? { cpus: lane.cpus + dind.cpus, mem_gb: lane.mem_gb + dind.mem_gb } : { cpus: lane.cpus, mem_gb: lane.mem_gb };
|
|
102
|
+
if (cfg.lane?.limit) {
|
|
103
|
+
size.limit = { lane_cpus: cfg.lane.limit.cpus, lane_mem_gb: cfg.lane.limit.mem_gb, lane_swap_gb: cfg.lane.limit.swap_gb ?? 0,
|
|
104
|
+
...(docker ? { dind_cpus: dind.limit?.cpus ?? 2, dind_mem_gb: dind.limit?.mem_gb ?? 3, dind_swap_gb: dind.limit?.swap_gb ?? 0 } : {}) };
|
|
105
|
+
}
|
|
106
|
+
return size;
|
|
107
|
+
};
|
|
108
|
+
function withForgeYaml(spec0) {
|
|
109
|
+
const spec = repoCfg(spec0).docker ? { ...spec0, docker: true } : spec0;
|
|
110
|
+
const path = repoCfg(spec).forge_yaml;
|
|
111
|
+
if (!path) return spec;
|
|
112
|
+
try { return { ...spec, env: { ...(spec.env ?? {}), CS_FORGE_YAML_B64: readFileSync(path).toString("base64") } }; }
|
|
113
|
+
catch (e) { log.warn?.(`forge.yaml for ${spec.repo.slug} unreadable: ${e.message}`); return spec; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function launch(spec) {
|
|
117
|
+
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)" } };
|
|
118
|
+
const known = own(sbx(), spec.id);
|
|
119
|
+
if (known && own(agents(), known.agent)) {
|
|
120
|
+
known.touched = now(); store.save();
|
|
121
|
+
const r = await agentCall(own(agents(), known.agent), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...sizeFor(spec) });
|
|
122
|
+
return { status: r.status, body: r.data, retryAfter: r.retryAfter };
|
|
123
|
+
}
|
|
124
|
+
const size = sizeFor(spec);
|
|
125
|
+
const fits = (await capacities()).filter((c) => c.ok && !c.paused && c.cpus_free >= size.cpus && c.mem_free_gb >= size.mem_gb)
|
|
126
|
+
.sort((a, b) => b.cpus_free - a.cpus_free || b.mem_free_gb - a.mem_free_gb);
|
|
127
|
+
for (const c of fits) {
|
|
128
|
+
const r = await agentCall(own(agents(), c.name), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...size }, 120000);
|
|
129
|
+
if (r.status === 503) continue; // raced with another launch; try the next host
|
|
130
|
+
if (r.ok) { sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() }; store.save(); }
|
|
131
|
+
return { status: r.status, body: { ...(r.data ?? {}), agent: c.name } };
|
|
132
|
+
}
|
|
133
|
+
return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function forward(id, method, path) {
|
|
137
|
+
const s = own(sbx(), id);
|
|
138
|
+
const a = s && own(agents(), s.agent);
|
|
139
|
+
if (!a) return { status: 404, body: { state: "gone" } };
|
|
140
|
+
s.touched = now(); store.save();
|
|
141
|
+
try {
|
|
142
|
+
const r = await agentCall(a, method, path);
|
|
143
|
+
if (method === "DELETE" && (r.ok || r.status === 404)) { delete sbx()[id]; store.save(); }
|
|
144
|
+
return { status: r.status, body: r.data };
|
|
145
|
+
} catch (e) { return { status: 502, body: { error: `agent ${s.agent} unreachable: ${e.message}` } }; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Renew leases for sandboxes ForgeDock still polls; destroy the ones it abandoned. */
|
|
149
|
+
async function tick() {
|
|
150
|
+
for (const [k, c] of Object.entries(codes())) if (c.expires <= now()) delete codes()[k];
|
|
151
|
+
for (const [id, s] of Object.entries(sbx())) {
|
|
152
|
+
const a = own(agents(), s.agent);
|
|
153
|
+
if (!a) { delete sbx()[id]; continue; }
|
|
154
|
+
try {
|
|
155
|
+
if (now() - s.touched > abandonMs) {
|
|
156
|
+
const r = await agentCall(a, "DELETE", `/sandboxes/${encodeURIComponent(id)}`);
|
|
157
|
+
if (r.ok || r.status === 404) { delete sbx()[id]; log.info?.(`abandoned ${id} destroyed on ${s.agent}`); }
|
|
158
|
+
} else {
|
|
159
|
+
const r = await agentCall(a, "POST", `/sandboxes/${encodeURIComponent(id)}/lease`);
|
|
160
|
+
if (r.status === 404) { delete sbx()[id]; log.info?.(`${id} gone on ${s.agent}`); }
|
|
161
|
+
}
|
|
162
|
+
} catch (e) { log.warn?.(`lease ${id} on ${s.agent}: ${e.message}`); }
|
|
163
|
+
}
|
|
164
|
+
store.save();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function enroll(body) {
|
|
168
|
+
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token" } };
|
|
169
|
+
const name = String(body.name ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
170
|
+
let url;
|
|
171
|
+
try { url = new URL(String(body.url)); } catch { return { status: 400, body: { error: "url required" } }; }
|
|
172
|
+
if (!name) return { status: 400, body: { error: "name required" } };
|
|
173
|
+
enrollOk(body.token, true);
|
|
174
|
+
const token = randomBytes(32).toString("hex");
|
|
175
|
+
agents()[name] = { url: url.origin, token, enrolledAt: new Date(now()).toISOString() };
|
|
176
|
+
store.save();
|
|
177
|
+
log.info?.(`enrolled agent ${name} at ${url.origin}`);
|
|
178
|
+
return { status: 200, body: { name, agent_token: token } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bootstrapScript(token, url) {
|
|
182
|
+
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
183
|
+
const int = (k) => { const v = Number(url?.searchParams.get(k)); return Number.isInteger(v) && v > 0 ? v : 0; };
|
|
184
|
+
const sizing = `${int("cpus") ? ` -AllowCpus ${int("cpus")}` : ""}${int("mem") ? ` -AllowMemGb ${int("mem")}` : ""}`;
|
|
185
|
+
return [
|
|
186
|
+
"$ErrorActionPreference = 'Stop'",
|
|
187
|
+
`$RouterUrl = ${q(cfg.public_url)}`,
|
|
188
|
+
`$EnrollToken = ${q(token)}`,
|
|
189
|
+
"$tmp = Join-Path $env:TEMP 'computeshare-install'",
|
|
190
|
+
"Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue",
|
|
191
|
+
"New-Item -ItemType Directory -Path $tmp | Out-Null",
|
|
192
|
+
"Invoke-WebRequest -UseBasicParsing \"$RouterUrl/bootstrap/bundle.tar.gz?token=$EnrollToken\" -OutFile (Join-Path $tmp 'bundle.tar.gz')",
|
|
193
|
+
"tar.exe -xzf (Join-Path $tmp 'bundle.tar.gz') -C $tmp",
|
|
194
|
+
`& (Join-Path $tmp 'pool\\install\\install-agent.ps1') -RouterUrl $RouterUrl -EnrollToken $EnrollToken -BundlePath (Join-Path $tmp 'bundle.tar.gz')${sizing}`,
|
|
195
|
+
"",
|
|
196
|
+
].join("\r\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stream an allow-listed image (docker save) to an agent whose daemon cannot reach the registry itself. */
|
|
200
|
+
async function sendImage(res, ref) {
|
|
201
|
+
const allowed = cfg.agent_images ?? ["docker:27.5.1-dind"];
|
|
202
|
+
if (!allowed.includes(ref)) return send(res, 404, { error: "image not offered" });
|
|
203
|
+
try { await run("docker", ["image", "inspect", ref], { timeout: 20000 }); }
|
|
204
|
+
catch { await run("docker", ["pull", "-q", ref], { timeout: 30 * 60000 }); }
|
|
205
|
+
const save = spawn("docker", ["save", ref]);
|
|
206
|
+
res.writeHead(200, { "content-type": "application/x-tar" });
|
|
207
|
+
save.stdout.pipe(res);
|
|
208
|
+
save.on("error", () => res.destroy());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sendBundle(res) {
|
|
212
|
+
const tar = spawn("tar", ["-czf", "-", "-C", cfg.repo_root, "package.json", "bin/pool", "pool"]);
|
|
213
|
+
res.writeHead(200, { "content-type": "application/gzip" });
|
|
214
|
+
tar.stdout.pipe(res);
|
|
215
|
+
tar.on("error", () => res.destroy());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fd = (fn) => ({ fn, auth: "forgedock" });
|
|
219
|
+
const routes = [
|
|
220
|
+
[route("GET", "/health"), { fn: async () => ({ status: 200, body: { ok: true, agents: Object.keys(agents()).length, sandboxes: Object.keys(sbx()).length } }), auth: "none" }],
|
|
221
|
+
[route("POST", "/sandboxes"), fd(async (_p, body) => launch(body))],
|
|
222
|
+
[route("GET", "/sandboxes/:id"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}`))],
|
|
223
|
+
[route("GET", "/sandboxes/:id/logs"), fd(async ({ id }, _b, url) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/logs?cursor=${encodeURIComponent(url.searchParams.get("cursor") ?? "0")}`))],
|
|
224
|
+
[route("GET", "/sandboxes/:id/result"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/result`))],
|
|
225
|
+
[route("GET", "/sandboxes/:id/usage"), fd(async ({ id }) => { // #4549: the placing agent rides along for per-agent minutes
|
|
226
|
+
const agent = own(sbx(), id)?.agent, r = await forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/usage`);
|
|
227
|
+
return agent && r.status === 200 && r.body && typeof r.body === "object" ? { ...r, body: { ...r.body, agent } } : r;
|
|
228
|
+
})],
|
|
229
|
+
[route("DELETE", "/sandboxes/:id"), fd(async ({ id }) => forward(id, "DELETE", `/sandboxes/${encodeURIComponent(id)}`))],
|
|
230
|
+
[route("GET", "/status"), fd(async () => ({ status: 200, body: await status() }))],
|
|
231
|
+
[route("GET", "/agents"), fd(async () => ({ status: 200, body: { agents: await capacities(), sandboxes: sbx() } }))],
|
|
232
|
+
[route("POST", "/codes"), fd(async () => ({ status: 200, body: issueCode() }))],
|
|
233
|
+
[route("GET", "/agent/creds/github"), { fn: async () => ({ status: 200, body: { token: await githubToken() } }), auth: "agent" }],
|
|
234
|
+
[route("GET", "/agent/creds/claude"), { fn: async () => ({ status: 200, body: claudeToken() }), auth: "agent" }],
|
|
235
|
+
[route("GET", "/agent/bundle"), { fn: async () => ({ bundle: true }), auth: "agent" }],
|
|
236
|
+
[route("GET", "/agent/images/:ref"), { fn: async ({ ref }) => ({ image: ref }), auth: "agent" }],
|
|
237
|
+
[route("POST", "/agents/:name/upgrade"), fd(async ({ name }) => {
|
|
238
|
+
const a = own(agents(), name);
|
|
239
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
240
|
+
const r = await agentCall(a, "POST", "/admin/update", {});
|
|
241
|
+
return { status: r.status, body: { ...(r.data ?? {}), target: versionOf(cfg.repo_root) } };
|
|
242
|
+
})],
|
|
243
|
+
[route("DELETE", "/agents/:name"), fd(async ({ name }) => {
|
|
244
|
+
if (!own(agents(), name)) return { status: 404, body: { error: "unknown agent" } };
|
|
245
|
+
for (const [id, x] of Object.entries(sbx())) if (x.agent === name) delete sbx()[id];
|
|
246
|
+
delete agents()[name]; store.save();
|
|
247
|
+
return { status: 200, body: { ok: true } };
|
|
248
|
+
})],
|
|
249
|
+
[route("POST", "/agents/:name/resize"), fd(async ({ name }, body) => {
|
|
250
|
+
const a = own(agents(), name);
|
|
251
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
252
|
+
const r = await agentCall(a, "POST", "/admin/resize", body ?? {});
|
|
253
|
+
return { status: r.status, body: r.data };
|
|
254
|
+
})],
|
|
255
|
+
[route("GET", "/agents/:name/usage"), fd(async ({ name }) => {
|
|
256
|
+
const a = own(agents(), name);
|
|
257
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
258
|
+
const r = await agentCall(a, "GET", "/usage-history");
|
|
259
|
+
return { status: r.status, body: r.data };
|
|
260
|
+
})],
|
|
261
|
+
[route("GET", "/version"), fd(async () => ({ status: 200, body: versionOf(cfg.repo_root) }))],
|
|
262
|
+
[route("POST", "/enroll"), { fn: async (_p, body) => enroll(body), auth: "none" }],
|
|
263
|
+
[route("GET", "/bootstrap/install.ps1"), { fn: async (_p, _b, url) => ({ status: 200, body: bootstrapScript(url.searchParams.get("token"), url) }), auth: "enroll" }],
|
|
264
|
+
[route("GET", "/bootstrap/bundle.tar.gz"), { fn: async () => ({ bundle: true }), auth: "enroll" }],
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
async function handle(req, res) {
|
|
268
|
+
const url = new URL(req.url, "http://router");
|
|
269
|
+
if (!url.pathname.startsWith("/health")) log.info?.(`${req.socket.remoteAddress} ${req.method} ${url.pathname}`); // query (tokens) never logged
|
|
270
|
+
try {
|
|
271
|
+
for (const [match, r] of routes) {
|
|
272
|
+
const p = match(req, url);
|
|
273
|
+
if (!p) continue;
|
|
274
|
+
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized" });
|
|
275
|
+
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized" });
|
|
276
|
+
if (r.auth === "enroll" && !enrollOk(url.searchParams.get("token"))) return send(res, 401, "unauthorized\n");
|
|
277
|
+
const body = req.method === "POST" ? await readJson(req) : null;
|
|
278
|
+
const out = await r.fn(p, body, url);
|
|
279
|
+
if (out.bundle) return sendBundle(res);
|
|
280
|
+
if (out.image) return sendImage(res, out.image);
|
|
281
|
+
return send(res, out.status, out.body, out.retryAfter ? { "retry-after": String(out.retryAfter) } : {});
|
|
282
|
+
}
|
|
283
|
+
send(res, 404, { error: "not found" });
|
|
284
|
+
} catch (e) {
|
|
285
|
+
log.error?.(e);
|
|
286
|
+
send(res, e.status ?? 500, { error: e.status ? e.message : "internal error" });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { handle, launch, tick, enroll, issueCode, capacities, status, store };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function startRouter(cfg, deps) {
|
|
294
|
+
const router = createRouter(cfg, deps);
|
|
295
|
+
const server = createServer(router.handle);
|
|
296
|
+
server.listen(cfg.listen.port, cfg.listen.host);
|
|
297
|
+
const timer = setInterval(() => router.tick().catch((e) => console.error("tick failed", e)), (cfg.lease_interval_s ?? 60) * 1000);
|
|
298
|
+
return { router, server, close: () => { clearInterval(timer); server.close(); } };
|
|
299
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Small JSON state file written atomically (temp file + rename), so a crash never leaves it half-written. */
|
|
7
|
+
export function jsonStore(path, initial) {
|
|
8
|
+
let data;
|
|
9
|
+
try { data = JSON.parse(readFileSync(path, "utf8")); } catch { data = structuredClone(initial); }
|
|
10
|
+
return {
|
|
11
|
+
get data() { return data; },
|
|
12
|
+
save() {
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
14
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
15
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
16
|
+
renameSync(tmp, path);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readSecret(path) {
|
|
22
|
+
const v = readFileSync(path, "utf8").trim();
|
|
23
|
+
if (!v) throw new Error(`empty secret file ${path}`);
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
|
|
11
|
+
/** Content hash of a directory's files (sorted, non-recursive is enough for pool/image/ and bin/pool/). */
|
|
12
|
+
function dirHash(dir) {
|
|
13
|
+
const h = createHash("sha256");
|
|
14
|
+
for (const f of readdirSync(dir).sort()) {
|
|
15
|
+
try { h.update(f); h.update(readFileSync(join(dir, f))); } catch { /* subdirectory */ }
|
|
16
|
+
}
|
|
17
|
+
return h.digest("hex").slice(0, 12);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What an agent is running: package version, code hash and lane-image recipe hash. Agents and router compare these. */
|
|
21
|
+
export function versionOf(root = repoRoot) {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
23
|
+
return { version: pkg.version, code: dirHash(join(root, "bin", "pool")), image: dirHash(join(root, "pool", "image")) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The lane image is tagged and built from the ForgeDock package version (no separate pin). */
|
|
27
|
+
export const laneImageTag = (version) => `forgedock-lane:${version}`;
|
|
28
|
+
export const laneBuildArgs = (version) => ({ FORGEDOCK_VERSION: version });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `forgedock update` hook: push the new code and lane image to every enrolled agent. A no-op on hosts without a
|
|
32
|
+
* pool router config; never throws (a failed push must not fail the update). Returns "skipped" | "upgraded" | "failed".
|
|
33
|
+
*/
|
|
34
|
+
export async function upgradeAgentsAfterUpdate({ configPath = process.env.COMPUTESHARE_CONFIG ?? join(homedir(), ".config/computeshare/router.json"), runPoolCli, warn = console.warn } = {}) {
|
|
35
|
+
if (!existsSync(configPath)) return "skipped";
|
|
36
|
+
try {
|
|
37
|
+
const run = runPoolCli ?? (await import("./cli.mjs")).runPoolCli;
|
|
38
|
+
await run(["upgrade", "--config", configPath]);
|
|
39
|
+
return "upgraded";
|
|
40
|
+
} catch (e) {
|
|
41
|
+
warn(`pool upgrade after update failed: ${e.message}`);
|
|
42
|
+
return "failed";
|
|
43
|
+
}
|
|
44
|
+
}
|
package/bin/runner.mjs
CHANGED
|
@@ -1809,6 +1809,7 @@ export function runCliBackend({
|
|
|
1809
1809
|
` Invocation: ${bin} ${argvSummary} (cwd: ${cwd})`,
|
|
1810
1810
|
);
|
|
1811
1811
|
err.code = "CLI_BACKEND_FAILED";
|
|
1812
|
+
err.status = result.status ?? null; err.signal = result.signal ?? null; // #4518: lets bin/lean/hostkill.mjs tell an external kill
|
|
1812
1813
|
// forge#2241: attach the CLI's reported session-limit reset time (when
|
|
1813
1814
|
// present) so callers (bin/engine.mjs) can surface *when* a
|
|
1814
1815
|
// quota-exhaustion failure will clear without reading raw logs. Only
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -295,6 +295,12 @@ export function computeAlerts(lanes = [], opts = {}) {
|
|
|
295
295
|
const ex = failed.flatMap((l) => l.deps).find((d) => (d.eco ?? "unknown") === eco && d.status === "failed");
|
|
296
296
|
add(`DEPS_PREPARED failed (${eco})`, failed.length, used.length, c.depsFailedPct / 100, ex?.reason ?? null);
|
|
297
297
|
}
|
|
298
|
+
// #4522: a broken snapshot falls back to a fresh install; the lane has dependencies, but the cache still needs a look.
|
|
299
|
+
const fell = used.filter((l) => l.deps.some((d) => (d.eco ?? "unknown") === eco && d.status === "materialize-fallback"));
|
|
300
|
+
if (fell.length / used.length > c.depsFailedPct / 100) {
|
|
301
|
+
const ex = fell.flatMap((l) => l.deps).find((d) => (d.eco ?? "unknown") === eco && d.status === "materialize-fallback");
|
|
302
|
+
add(`DEPS_PREPARED materialize-fallback (${eco})`, fell.length, used.length, c.depsFailedPct / 100, ex?.reason ?? null);
|
|
303
|
+
}
|
|
298
304
|
}
|
|
299
305
|
const deferred = win.filter((l) => l.deferred.length);
|
|
300
306
|
if (deferred.length / win.length > c.deferredPct / 100) add("deferred-to-ci", deferred.length, win.length, c.deferredPct / 100, deferred[deferred.length - 1].deferred[0]);
|
package/bin/stats/index.mjs
CHANGED
|
@@ -16,12 +16,14 @@ import { parseForgeYaml } from "../forge-utils.mjs";
|
|
|
16
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
17
|
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
18
|
import { batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows } from "./efficiency.mjs";
|
|
19
|
+
import { poolUtilisation, formatPoolLines } from "./pool.mjs";
|
|
19
20
|
import { batchRoot } from "../lean/batch.mjs";
|
|
20
21
|
|
|
21
22
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
|
|
23
24
|
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
24
25
|
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
26
|
+
forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
25
27
|
forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
26
28
|
|
|
27
29
|
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
@@ -56,6 +58,9 @@ Explorer A/B (forge#4418; local run logs only, every run of each log, no GitHub
|
|
|
56
58
|
--explore-ab builder turns, builder cache-read, merge and conformance rates per size (S, M, L) and
|
|
57
59
|
lean.explore mode (on, off), plus the same issues run in both modes; --json for JSON
|
|
58
60
|
|
|
61
|
+
Pool (forge#4549; local batch reports, no gh):
|
|
62
|
+
--pool remote lane minutes, lanes and share per pool agent over --window (default 7d,30d)
|
|
63
|
+
|
|
59
64
|
Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues' bodies, labels and probes):
|
|
60
65
|
--efficiency $/merge, turns/merge, context/turn per size and model, retries/merge by cause, no-action
|
|
61
66
|
lanes, issue quality vs cost and the acceptance pass rate 7 days after promotion
|
|
@@ -73,7 +78,7 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
|
|
|
73
78
|
`;
|
|
74
79
|
|
|
75
80
|
export function parseArgs(argv) {
|
|
76
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
81
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
77
82
|
const val = (i, name) => {
|
|
78
83
|
const v = argv[i + 1];
|
|
79
84
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -95,6 +100,7 @@ export function parseArgs(argv) {
|
|
|
95
100
|
else if (name === "--lane-profile") o.laneProfile = true;
|
|
96
101
|
else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
|
|
97
102
|
else if (name === "--efficiency") o.efficiency = true;
|
|
103
|
+
else if (name === "--pool") o.pool = true;
|
|
98
104
|
else if (name === "--batch") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error(`invalid --batch id: ${v}`); o.batch = v; }
|
|
99
105
|
else if (name === "--window") o.windows = parseWindows(take());
|
|
100
106
|
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
@@ -106,7 +112,8 @@ export function parseArgs(argv) {
|
|
|
106
112
|
else if (name === "--help" || name === "-h") o.help = true;
|
|
107
113
|
else throw new Error(`unknown argument: ${a}`);
|
|
108
114
|
}
|
|
109
|
-
if (
|
|
115
|
+
if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
116
|
+
if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
|
|
110
117
|
return o;
|
|
111
118
|
}
|
|
112
119
|
|
|
@@ -215,6 +222,10 @@ export async function buildReport(opts, deps = {}) {
|
|
|
215
222
|
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
216
223
|
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
217
224
|
else releaseTimes = gitTagTimes();
|
|
225
|
+
if (opts.pool) { // forge#4549: local batch reports only
|
|
226
|
+
const pool = poolUtilisation({ batchRoot: deps.batchRoot ?? batchRoot(env), repo, windows: opts.windows ?? parseWindows("7d,30d"), now: deps.now?.() ?? Date.now() });
|
|
227
|
+
return { repo, query: { pool } };
|
|
228
|
+
}
|
|
218
229
|
if (opts.efficiency) { // forge#4402: local batch dirs + run logs; gh only for merged issues
|
|
219
230
|
const root = deps.batchRoot ?? batchRoot(env), rd = deps.runsDir ?? runsDir(env), now = deps.now?.() ?? Date.now(), gh = deps.gh ?? defaultGh;
|
|
220
231
|
const efficiency = {};
|
|
@@ -276,6 +287,7 @@ export async function runStats(argv, deps = {}) {
|
|
|
276
287
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
277
288
|
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
278
289
|
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
290
|
+
if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
|
|
279
291
|
if (report.query?.efficiency && !opts.json) {
|
|
280
292
|
const e = report.query.efficiency;
|
|
281
293
|
const lines = [...(e.batch ? formatEfficiencyLines(e.batch, `batch ${e.batch.batch ?? opts.batch}`) : []),
|
|
@@ -58,7 +58,7 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
58
58
|
const usage = ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE");
|
|
59
59
|
const builderCalls = usage.filter((e) => e.phase === "implement");
|
|
60
60
|
const reviewCalls = usage.filter((e) => e.phase === "review").length;
|
|
61
|
-
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null, ab: (term?.context_ab ?? cp?.context_ab) === true };
|
|
61
|
+
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null, ab: (term?.context_ab ?? cp?.context_ab) === true, light: term?.light === true };
|
|
62
62
|
const startTs = start.ts ?? null;
|
|
63
63
|
const endTs = finished ? ev[t].ts ?? null : null;
|
|
64
64
|
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4549: pool utilisation for `forgedock stats --pool`: remote lane minutes, lanes and share per pool agent, from the
|
|
5
|
+
* repo's local batch report.json rows (`sandbox.by_agent`, written by the orchestrator). No network, no model.
|
|
6
|
+
* Live capacity stays in `forgedock pool status`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { listBatches } from "../lean/batch.mjs";
|
|
11
|
+
import { windowLabel } from "./efficiency.mjs";
|
|
12
|
+
|
|
13
|
+
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
|
|
14
|
+
const round1 = (x) => Math.round(x * 10) / 10;
|
|
15
|
+
|
|
16
|
+
/** Per window: the repo's batches created in it; an issue in several batches counts once (latest row). */
|
|
17
|
+
export function poolUtilisation({ batchRoot, repo = null, windows, now = Date.now() }) {
|
|
18
|
+
const batches = (existsSync(batchRoot) ? listBatches(batchRoot) : []).map((id) => {
|
|
19
|
+
const dir = join(batchRoot, id), plan = readJson(join(dir, "plan.json")) ?? {};
|
|
20
|
+
return { dir, plan, created: Date.parse(plan.created ?? "") };
|
|
21
|
+
}).filter((b) => Number.isFinite(b.created) && (!repo || b.plan.repo === repo)).sort((a, b) => a.created - b.created);
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const w of windows) {
|
|
24
|
+
const inWin = batches.filter((b) => b.created <= now && now - b.created <= w);
|
|
25
|
+
const latest = new Map();
|
|
26
|
+
for (const b of inWin) for (const row of readJson(join(b.dir, "report.json"))?.rows ?? []) if (Number.isSafeInteger(row?.issue)) latest.set(row.issue, row);
|
|
27
|
+
const agents = {};
|
|
28
|
+
let remoteLanes = 0, minutes = 0;
|
|
29
|
+
for (const row of latest.values()) {
|
|
30
|
+
if (!row.sandbox) continue;
|
|
31
|
+
remoteLanes++; minutes += Number(row.sandbox.minutes) || 0;
|
|
32
|
+
for (const [a, m] of Object.entries(row.sandbox.by_agent ?? {})) {
|
|
33
|
+
const e = (agents[a] ??= { minutes: 0, lanes: 0 });
|
|
34
|
+
e.minutes += Number(m) || 0; e.lanes++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const attributed = Object.values(agents).reduce((s, e) => s + e.minutes, 0);
|
|
38
|
+
for (const e of Object.values(agents)) { e.share = attributed > 0 ? Math.round((e.minutes / attributed) * 1000) / 1000 : null; e.minutes = round1(e.minutes); }
|
|
39
|
+
out[windowLabel(w)] = { batches: inWin.length, remote_lanes: remoteLanes, minutes: round1(minutes), agents };
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatPoolLines(windows) {
|
|
45
|
+
return Object.entries(windows).flatMap(([w, m]) => {
|
|
46
|
+
const head = `pool ${w}: ${m.batches} batch${m.batches === 1 ? "" : "es"}, ${m.remote_lanes} remote lane${m.remote_lanes === 1 ? "" : "s"}, ${m.minutes.toFixed(1)} min`;
|
|
47
|
+
const rows = Object.entries(m.agents).sort((a, b) => b[1].minutes - a[1].minutes || a[0].localeCompare(b[0]))
|
|
48
|
+
.map(([a, e]) => ` ${a}: ${e.minutes.toFixed(1)} min, ${e.lanes} lane${e.lanes === 1 ? "" : "s"}${e.share == null ? "" : `, ${Math.round(e.share * 100)}% of pool`}`);
|
|
49
|
+
return [head, ...(rows.length ? rows : [" per-agent minutes: n/a"])];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { poolUtilisation, formatPoolLines } from "./pool.mjs";
|
|
9
|
+
import { parseArgs } from "./index.mjs";
|
|
10
|
+
|
|
11
|
+
const NOW = Date.parse("2026-10-24T00:00:00Z"), DAY = 86400000;
|
|
12
|
+
function batch(root, id, created, rows, repo = "o/r") {
|
|
13
|
+
const d = join(root, id); mkdirSync(d, { recursive: true });
|
|
14
|
+
writeFileSync(join(d, "plan.json"), JSON.stringify({ repo, created: new Date(created).toISOString() }));
|
|
15
|
+
writeFileSync(join(d, "report.json"), JSON.stringify({ rows }));
|
|
16
|
+
}
|
|
17
|
+
const sbx = (issue, by) => ({ issue, outcome: "merged", sandbox: { minutes: Object.values(by).reduce((a, b) => a + b, 0), by_agent: by } });
|
|
18
|
+
|
|
19
|
+
test("poolUtilisation sums minutes, lanes and share per agent per window", () => {
|
|
20
|
+
const root = tmpDir("pool-stats-");
|
|
21
|
+
batch(root, "b1", NOW - 2 * DAY, [sbx(1, { legion: 30 }), sbx(2, { legion: 10, box2: 10 }), { issue: 3, outcome: "merged" }]);
|
|
22
|
+
batch(root, "b2", NOW - 20 * DAY, [sbx(4, { box2: 50 })]);
|
|
23
|
+
batch(root, "b3", NOW - DAY, [sbx(9, { other: 99 })], "x/y");
|
|
24
|
+
const w = poolUtilisation({ batchRoot: root, repo: "o/r", windows: [7 * DAY, 30 * DAY], now: NOW });
|
|
25
|
+
assert.equal(w["7d"].remote_lanes, 2);
|
|
26
|
+
assert.deepEqual(w["7d"].agents.legion, { minutes: 40, lanes: 2, share: 0.8 });
|
|
27
|
+
assert.equal(w["30d"].agents.box2.minutes, 60);
|
|
28
|
+
assert.equal(w["30d"].agents.other, undefined);
|
|
29
|
+
assert.match(formatPoolLines(w).join("\n"), /legion: 40\.0 min, 2 lanes, 80% of pool/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("poolUtilisation with no agent data prints n/a; --window works with --pool", () => {
|
|
33
|
+
const root = tmpDir("pool-stats-");
|
|
34
|
+
batch(root, "b1", NOW - DAY, [{ issue: 1, outcome: "merged", sandbox: { minutes: 3 } }]);
|
|
35
|
+
const w = poolUtilisation({ batchRoot: root, repo: "o/r", windows: [7 * DAY], now: NOW });
|
|
36
|
+
assert.match(formatPoolLines(w).join("\n"), /per-agent minutes: n\/a/);
|
|
37
|
+
assert.equal(poolUtilisation({ batchRoot: join(root, "none"), windows: [DAY], now: NOW })["1d"].batches, 0);
|
|
38
|
+
assert.equal(parseArgs(["--pool", "--window", "7d"]).pool, true);
|
|
39
|
+
});
|
package/bin/stats/query.mjs
CHANGED
|
@@ -74,7 +74,7 @@ export function verdictMix(records, items = [], milestone = null) {
|
|
|
74
74
|
/**
|
|
75
75
|
* forge#4275: the context-pack A/B over local run summaries (local-source.mjs summariseLog), per lane type (rerun,
|
|
76
76
|
* child, first) and mode (on, off, shadow), never one blended average. First-pass yield: merged with one plan + one
|
|
77
|
-
* implement builder call and at most one review. Runs without a recorded lane type (older logs) are left out.
|
|
77
|
+
* implement builder call and at most one review. Runs without a recorded lane type (older logs) and light lanes (#4502: they skip the pack) are left out.
|
|
78
78
|
*/
|
|
79
79
|
export const CONTEXT_LANE_TYPES = Object.freeze(["rerun", "child", "first"]);
|
|
80
80
|
export function contextPackAb(runs) {
|
|
@@ -83,7 +83,7 @@ export function contextPackAb(runs) {
|
|
|
83
83
|
for (const lt of CONTEXT_LANE_TYPES) {
|
|
84
84
|
out[lt] = {};
|
|
85
85
|
for (const mode of ["on", "off", "shadow"]) {
|
|
86
|
-
const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
|
|
86
|
+
const rs = runs.filter((r) => r?.finished && !r.contextPack?.light && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
|
|
87
87
|
out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
|
|
88
88
|
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)), ...perMerge(rs, lt) };
|
|
89
89
|
}
|