forgedock 1.32.1 → 1.34.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 +46 -10
- package/bin/lean/conformance.test.mjs +50 -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 +19 -9
- 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/review-staging.mjs +14 -2
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/efficiency.mjs +103 -17
- package/bin/stats/efficiency.test.mjs +103 -4
- package/bin/stats/index.mjs +40 -6
- 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/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +1 -1
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +36 -1
- 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
- package/scripts/review-finalize.sh +17 -1
- package/scripts/review-finalize.test.sh +41 -0
- package/scripts/review-pr-domain-map.test.sh +2 -2
- package/scripts/review-pr-staging-scope.test.sh +4 -2
- package/scripts/review-prepare.sh +62 -27
- package/scripts/review-prepare.test.sh +53 -0
- package/scripts/review-staging-pre.sh +29 -9
- package/scripts/review-staging-pre.test.sh +18 -0
|
@@ -8,7 +8,8 @@ import { execFileSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
-
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
|
|
11
|
+
import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
|
|
12
|
+
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
12
13
|
import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
|
|
13
14
|
|
|
14
15
|
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
@@ -301,6 +302,87 @@ describe("deterministic renumber (#4386)", () => {
|
|
|
301
302
|
// both add the same numbered name in a directory without a series
|
|
302
303
|
await conflict({ "src/a.ts": "1\n" }, { "src/500_error.html": "LANE\n" }, { "src/500_error.html": "BASE\n" }, "src/500_error.html");
|
|
303
304
|
});
|
|
305
|
+
|
|
306
|
+
it("#4526: two lanes renumbering at once against the same base get distinct numbers (host reservation), and free them at the end", async () => {
|
|
307
|
+
const io = makeSequenceLeaseIo({ dir: tmpDir("seqlease-"), pickDir: tmpDir("seqpick-"), pollMs: 20 });
|
|
308
|
+
const reserveFor = (issue) => (x) => io.reserve({ repo: "o/r", lane: "main", issue, ...x });
|
|
309
|
+
const lanes = [drizzle(), drizzle()];
|
|
310
|
+
const rs = await Promise.all(lanes.map((d, i) => renumberLane({ git: gitIn(d), base: "main", cwd: d, uuid: () => `u${i}`, reserve: reserveFor(i + 1) })));
|
|
311
|
+
for (const r of rs) assert.ok(r.ok, r.unsupported);
|
|
312
|
+
assert.deepEqual(rs.map((r) => r.moves[0].to).sort(), ["db/0052_nclt.sql", "db/0053_nclt.sql"]);
|
|
313
|
+
for (const d of lanes) {
|
|
314
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
315
|
+
assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
|
|
316
|
+
}
|
|
317
|
+
await io.unreserve({ repo: "o/r", lane: "main", issue: 1 });
|
|
318
|
+
await io.unreserve({ repo: "o/r", lane: "main", issue: 2 });
|
|
319
|
+
const d = drizzle();
|
|
320
|
+
assert.deepEqual((await renumberLane({ git: gitIn(d), base: "main", cwd: d, reserve: reserveFor(3) })).moves, [{ from: "db/0049_nclt.sql", to: "db/0052_nclt.sql" }]);
|
|
321
|
+
const failing = drizzle(); // a broken reservation keeps the base-derived number (fail-open)
|
|
322
|
+
assert.deepEqual((await renumberLane({ git: gitIn(failing), base: "main", cwd: failing, reserve: async () => { throw new Error("x"); } })).moves[0].to, "db/0052_nclt.sql");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("#4526: a renumber that changes only the header line's tag passes verifyRenumber; any other change does not", async () => {
|
|
326
|
+
const lane = (header, body = "INSERT INTO t VALUES (1);\n") => {
|
|
327
|
+
const d = repo({ "db/0047_a.sql": "a;\n", "db/0048_init.sql": "b;\n" }, { "db/0049_x.sql": "-- Migration 0049_x\nINSERT INTO t VALUES (1);\n" }, { "db/0049_a.sql": "c;\n" });
|
|
328
|
+
return { d, header, body };
|
|
329
|
+
};
|
|
330
|
+
for (const [header, body, ok] of [["-- Migration 0050_brave_hulk", undefined, true], ["-- Migration 0050_brave_hulk (edited)", undefined, false], ["-- Migration 0050_brave_hulk", "INSERT INTO t VALUES (2);\n", false]]) {
|
|
331
|
+
const { d } = lane(header);
|
|
332
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
333
|
+
sh(d, "merge", "--no-edit", "main");
|
|
334
|
+
sh(d, "mv", "db/0049_x.sql", "db/0050_brave_hulk.sql");
|
|
335
|
+
put(d, { "db/0050_brave_hulk.sql": `${header}\n${body ?? "INSERT INTO t VALUES (1);\n"}` });
|
|
336
|
+
commit(d, "renumber");
|
|
337
|
+
const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
|
|
338
|
+
assert.equal(v.ok, ok, `${header} / ${body}: ${v.problems.join("; ")}`);
|
|
339
|
+
if (!ok) assert.match(v.problems.join("\n"), /db\/0049_x\.sql: content changed/);
|
|
340
|
+
}
|
|
341
|
+
assert.equal(headerOnlyChange("# 0029. Title\nbody\n", "# 0030. Title\nbody\n", "docs/adr/0029-title.md", "docs/adr/0030-title.md"), true);
|
|
342
|
+
assert.equal(headerOnlyChange("# 29. Title\nbody\n", "# 30. Title\nbody\n", "docs/adr/0029-title.md", "docs/adr/0030-title.md"), true);
|
|
343
|
+
assert.equal(headerOnlyChange("-- 0067_x\nSELECT 1;\n", "-- 0068_x\nSELECT 1;\n", "db/0067_x.sql", "db/0068_x.sql"), true);
|
|
344
|
+
assert.equal(headerOnlyChange("-- 0067_x\nSELECT 1;\n", "-- 0068_x\nSELECT 2;\n", "db/0067_x.sql", "db/0068_x.sql"), false);
|
|
345
|
+
assert.equal(headerOnlyChange("-- 0067_x 10067\nSELECT 1;\n", "-- 0068_x 10068\nSELECT 1;\n", "db/0067_x.sql", "db/0068_x.sql"), false, "another number containing the tag is not the tag");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("#4526: a hand-written migration with no schema diff after a schema-changing one gets the generator's --custom snapshot and keeps its SQL byte-for-byte", async () => {
|
|
349
|
+
const FN_SQL = "-- hand-written: no schema diff\nCREATE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE sql;\n";
|
|
350
|
+
const d = repo(
|
|
351
|
+
{ "db/0048_init.sql": "CREATE TABLE t (id int);\n", "db/meta/_journal.json": journal("0048_init"), "db/meta/0048_snapshot.json": snap("s48", "s47") },
|
|
352
|
+
{ "db/0049_s.sql": "CREATE TABLE s (id int);\n", "db/0050_fn.sql": FN_SQL, "db/meta/_journal.json": journal("0048_init", "0049_s", "0050_fn"),
|
|
353
|
+
"db/meta/0049_snapshot.json": snap("lane49", "s48", { t: 1, s: 1 }), "db/meta/0050_snapshot.json": snap("lane50", "lane49", { t: 1, s: 1 }) },
|
|
354
|
+
{
|
|
355
|
+
"db/0049_a.sql": "a;\n", "db/0050_b.sql": "b;\n", "db/0051_c.sql": "CREATE TABLE u (id int);\n",
|
|
356
|
+
"db/meta/_journal.json": journal("0048_init", "0049_a", "0050_b", "0051_c"),
|
|
357
|
+
"db/meta/0049_snapshot.json": snap("s49", "s48"), "db/meta/0050_snapshot.json": snap("s50", "s49"), "db/meta/0051_snapshot.json": snap("s51", "s50", { t: 1, u: 1 }),
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
const before = await snapshotLane({ git: gitIn(d), base: "main" });
|
|
361
|
+
const calls = [];
|
|
362
|
+
/** A drizzle-kit stand-in: the regular run writes only the schema-changing snapshot; --custom adds an empty migration at the journal's next number. */
|
|
363
|
+
const run = async (cmd, { cwd }) => {
|
|
364
|
+
calls.push(cmd);
|
|
365
|
+
if (!cmd.endsWith("--custom")) { writeFileSync(join(cwd, "db/meta/0052_snapshot.json"), snap("gen52", "s51", { t: 1, u: 1, s: 1 })); return; }
|
|
366
|
+
assert.ok(!existsSync(join(cwd, "db/0053_fn.sql")), "the lane's SQL is withheld while the generator runs");
|
|
367
|
+
const j = JSON.parse(readFileSync(join(cwd, "db/meta/_journal.json"), "utf8"));
|
|
368
|
+
const n = String(Number(j.entries.at(-1).tag.slice(0, 4)) + 1).padStart(4, "0");
|
|
369
|
+
writeFileSync(join(cwd, `db/${n}_wise_owl.sql`), "-- Custom SQL migration file, put your code below! --\n");
|
|
370
|
+
writeFileSync(join(cwd, `db/meta/${n}_snapshot.json`), snap("custom", "gen52", { t: 1, u: 1, s: 1 }));
|
|
371
|
+
j.entries.push({ idx: j.entries.length, tag: `${n}_wise_owl`, when: 9 });
|
|
372
|
+
writeFileSync(join(cwd, "db/meta/_journal.json"), JSON.stringify(j));
|
|
373
|
+
};
|
|
374
|
+
const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, regenerate: "pnpm db:generate", run });
|
|
375
|
+
assert.ok(r.ok, r.unsupported);
|
|
376
|
+
assert.deepEqual(r.moves, [{ from: "db/0049_s.sql", to: "db/0052_s.sql" }, { from: "db/0050_fn.sql", to: "db/0053_fn.sql" }]);
|
|
377
|
+
assert.deepEqual(calls, ["pnpm db:generate", "pnpm db:generate --custom"]);
|
|
378
|
+
assert.equal(show(d, "db/0053_fn.sql"), FN_SQL, "hand-written SQL byte-identical");
|
|
379
|
+
assert.ok(!existsSync(join(d, "db/0053_wise_owl.sql")), "the generator's empty migration is dropped");
|
|
380
|
+
assert.equal(JSON.parse(show(d, "db/meta/0053_snapshot.json")).id, "custom");
|
|
381
|
+
assert.deepEqual(JSON.parse(show(d, "db/meta/_journal.json")).entries.map((e) => e.tag), ["0048_init", "0049_a", "0050_b", "0051_c", "0052_s", "0053_fn"]);
|
|
382
|
+
assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
|
|
383
|
+
assert.equal(sh(d, "status", "--porcelain").trim(), "");
|
|
384
|
+
assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before, moves: r.moves }), { ok: true, problems: [] });
|
|
385
|
+
});
|
|
304
386
|
});
|
|
305
387
|
|
|
306
388
|
describe("sequence-numbered documents: two siblings each add an ADR (#4385)", () => {
|
|
@@ -0,0 +1,435 @@
|
|
|
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 { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
import { cpus, hostname } from "node:os";
|
|
10
|
+
import { bearerOk, call, readJson, route, sameSecret, send } from "./http.mjs";
|
|
11
|
+
import { repoRoot, versionOf, laneImageTag, laneBuildArgs } from "./version.mjs";
|
|
12
|
+
import { jsonStore } from "./store.mjs";
|
|
13
|
+
|
|
14
|
+
const LABEL = "computeshare=1";
|
|
15
|
+
const LOG_CHUNK = 256 * 1024;
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
/** What this machine/VM actually has (on WSL: the VM caps from .wslconfig). */
|
|
19
|
+
export function vmSize() {
|
|
20
|
+
let mem = 0, swap = 0;
|
|
21
|
+
try {
|
|
22
|
+
const mi = readFileSync("/proc/meminfo", "utf8");
|
|
23
|
+
mem = Number(/MemTotal:\s+(\d+)/.exec(mi)[1]) / 1048576;
|
|
24
|
+
swap = Number(/SwapTotal:\s+(\d+)/.exec(mi)?.[1] ?? 0) / 1048576;
|
|
25
|
+
} catch { /* not linux */ }
|
|
26
|
+
return { cpus: cpus().length, mem_gb: Math.round(mem * 10) / 10, swap_gb: Math.round(swap * 10) / 10 };
|
|
27
|
+
}
|
|
28
|
+
/** Swap a container may use beyond its RAM cap: never more than the VM actually has. */
|
|
29
|
+
const swapFor = (gb) => { const want = Number(gb) > 0 ? Number(gb) : 0; const have = vmSize().swap_gb; return have ? Math.min(want, have) : 0; };
|
|
30
|
+
const mb = (gb) => `${Math.max(64, Math.round(gb * 1024))}m`;
|
|
31
|
+
/** A burst cap can never exceed the machine/VM itself. */
|
|
32
|
+
function clampToVm(cpusWanted, memWanted) {
|
|
33
|
+
const vm = vmSize();
|
|
34
|
+
return { cpus: vm.cpus ? Math.min(cpusWanted, vm.cpus) : cpusWanted, mem: vm.mem_gb ? Math.min(memWanted, Math.max(1, vm.mem_gb - 1)) : memWanted };
|
|
35
|
+
}
|
|
36
|
+
/** Live headroom: MemAvailable and 1-min load per core, so overlapping bursts slow down instead of OOM-killing lanes. */
|
|
37
|
+
export function liveHeadroom(cfg = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const mi = readFileSync("/proc/meminfo", "utf8");
|
|
40
|
+
const total = Number(/MemTotal:\s+(\d+)/.exec(mi)[1]) / 1048576, avail = Number(/MemAvailable:\s+(\d+)/.exec(mi)[1]) / 1048576;
|
|
41
|
+
const load1 = Number(readFileSync("/proc/loadavg", "utf8").split(" ")[0]), cores = cpus().length || 1;
|
|
42
|
+
const minFree = cfg.min_free_mem_gb ?? Math.max(2, total * 0.1), maxLoad = cfg.max_load_per_core ?? 2;
|
|
43
|
+
const r = { mem_available_gb: Math.round(avail * 10) / 10, load_per_core: Math.round((load1 / cores) * 100) / 100 };
|
|
44
|
+
if (avail < minFree) return { ok: false, reason: `only ${r.mem_available_gb} GB available (< ${minFree.toFixed(1)})`, ...r };
|
|
45
|
+
if (load1 / cores > maxLoad) return { ok: false, reason: `load ${r.load_per_core}/core (> ${maxLoad})`, ...r };
|
|
46
|
+
return { ok: true, ...r };
|
|
47
|
+
} catch { return { ok: true }; }
|
|
48
|
+
}
|
|
49
|
+
export const dindName = (name) => `${name}-dind`;
|
|
50
|
+
export const workVolume = (name) => `cs-work-${name.replace(/^cs-/, "")}`;
|
|
51
|
+
export const safeName = (id) => `cs-${String(id).toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 100)}`;
|
|
52
|
+
const envLine = (k, v) => `${k}=${String(v).replace(/\r?\n/g, " ")}`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Host agent: runs each sandbox as one docker container inside a fixed reservation (allow_cpus / allow_mem_gb).
|
|
56
|
+
* A sandbox lives only while its lease is renewed; the reaper removes expired, over-age and orphaned containers.
|
|
57
|
+
*/
|
|
58
|
+
export function createAgent(cfg, { docker, now = () => Date.now(), log = console, live = liveHeadroom } = {}) {
|
|
59
|
+
const stateDir = cfg.state_dir;
|
|
60
|
+
const store = jsonStore(join(stateDir, "state.json"), { sandboxes: {} });
|
|
61
|
+
const sb = () => store.data.sandboxes;
|
|
62
|
+
// 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 leaseTtlMs = (cfg.lease_ttl_s ?? 600) * 1000;
|
|
65
|
+
const maxLifeMs = (cfg.max_lifetime_s ?? 6 * 3600) * 1000;
|
|
66
|
+
const keepExitedMs = (cfg.keep_exited_s ?? 2 * 3600) * 1000;
|
|
67
|
+
const running = versionOf(cfg.code_root ?? repoRoot);
|
|
68
|
+
// The lane image tracks the installed ForgeDock version; only an explicit non-legacy cfg.image overrides it.
|
|
69
|
+
const image = cfg.image && cfg.image !== "computeshare-lane:1" ? cfg.image : laneImageTag(running.version);
|
|
70
|
+
const upgrade = { state: "idle", error: null };
|
|
71
|
+
const prepull = { error: null };
|
|
72
|
+
const dindImage = cfg.dind_image ?? "docker:27.5.1-dind";
|
|
73
|
+
/** Images a sandbox may need are pulled ahead of time, never inside a launch request (which has a short timeout). */
|
|
74
|
+
async function prepullImages() {
|
|
75
|
+
if (await docker.hasImage?.(dindImage)) { prepull.error = null; return; }
|
|
76
|
+
try { await docker.pull(dindImage); prepull.error = null; return; }
|
|
77
|
+
catch (e) { log.warn?.(`pull ${dindImage} failed (${String(e.stderr || e.message).slice(0, 160)}); fetching it from the router`); }
|
|
78
|
+
// Fallback: the router host pulls the image and streams it (some hosts' daemons cannot reach the registry).
|
|
79
|
+
const file = join(stateDir, "image.tar");
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`${String(cfg.router_url).replace(/\/+$/, "")}/agent/images/${encodeURIComponent(dindImage)}`, { headers: { authorization: `Bearer ${cfg.token}` }, signal: AbortSignal.timeout(30 * 60000) });
|
|
82
|
+
if (!res.ok) throw new Error(`router image: HTTP ${res.status}`);
|
|
83
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
84
|
+
await docker.load(file);
|
|
85
|
+
prepull.error = null;
|
|
86
|
+
log.info?.(`loaded ${dindImage} from the router`);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
prepull.error = String(e.stderr || e.message).slice(0, 300);
|
|
89
|
+
log.warn?.(`prepull ${dindImage}: ${prepull.error}`);
|
|
90
|
+
} finally { rmSync(file, { force: true }); }
|
|
91
|
+
}
|
|
92
|
+
const used = () => Object.values(sb()).filter((s) => s.state !== "exited").reduce((a, s) => ({ cpus: a.cpus + s.cpus, mem: a.mem + s.mem_gb }), { cpus: 0, mem: 0 });
|
|
93
|
+
function capacity() {
|
|
94
|
+
const u = used();
|
|
95
|
+
return { host: cfg.name ?? hostname(), ...running, vm: vmSize(), ...(pendingResize ? { pending_resize: { cpus: pendingResize.cpus, mem_gb: pendingResize.mem_gb } } : {}), upgrading: upgrade.state, ...(upgrade.error ? { upgrade_error: upgrade.error } : {}), ...(prepull.error ? { prepull_error: prepull.error } : {}), cpus_total: cfg.allow_cpus, mem_total_gb: cfg.allow_mem_gb, cpus_free: cfg.allow_cpus - u.cpus, mem_free_gb: cfg.allow_mem_gb - u.mem, active: Object.values(sb()).filter((s) => s.state !== "exited").length, paused: Boolean(cfg.paused), live: live(cfg), usage: Object.fromEntries(Object.values(sb()).filter((r) => r.usage?.samples).map((r) => [r.issue ?? r.id, { ...usageView(r), state: r.state }])) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function launch({ spec, cpus, mem_gb, limit = null }) {
|
|
99
|
+
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
|
+
const existing = own(spec.id);
|
|
101
|
+
if (existing) { existing.lease = now(); store.save(); return { status: 200, body: view(existing) }; }
|
|
102
|
+
const c = capacity();
|
|
103
|
+
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
|
+
// Burst model: reservations are small, so also refuse when the VM is genuinely short of memory or overloaded right now.
|
|
105
|
+
const h = live(cfg);
|
|
106
|
+
if (!h.ok) return { status: 503, body: { error: `host busy: ${h.reason}`, live: h }, retryAfter: 60 };
|
|
107
|
+
const name = safeName(spec.id);
|
|
108
|
+
const dir = join(stateDir, "sandboxes", name);
|
|
109
|
+
mkdirSync(join(dir, "result"), { recursive: true });
|
|
110
|
+
mkdirSync(join(dir, "out"), { recursive: true });
|
|
111
|
+
const envFile = join(dir, "env");
|
|
112
|
+
const credToken = randomBytes(24).toString("hex");
|
|
113
|
+
const withDocker = Boolean(spec.docker);
|
|
114
|
+
const env = { ...(spec.env ?? {}), ...(withDocker ? { DOCKER_HOST: "tcp://127.0.0.1:2375" } : {}), CS_CRED_BASE: `http://host.docker.internal:${cfg.listen.port}/sandboxes/${encodeURIComponent(spec.id)}/creds`, CS_CRED_TOKEN: credToken, CS_SANDBOX_ID: spec.id, CS_REPO_SLUG: spec.repo?.slug ?? "", CS_REPO_URL: spec.repo?.url ?? "", CS_REPO_REF: spec.repo?.ref ?? "", CS_RESULT_FILE: spec.result_file ?? "" };
|
|
115
|
+
writeFileSync(envFile, Object.entries(env).map(([k, v]) => envLine(k, v)).join("\n") + "\n", { mode: 0o600 });
|
|
116
|
+
const rec = { id: spec.id, name, dir, issue: spec.issue ?? null, batch: spec.batch ?? null, cpus, mem_gb, result_file: spec.result_file ?? null, state: "starting", startedAt: now(), finishedAt: null, lease: now(), credToken, docker: withDocker };
|
|
117
|
+
sb()[spec.id] = rec;
|
|
118
|
+
store.save();
|
|
119
|
+
const cacheKey = String(spec.cache?.key ?? spec.repo?.slug ?? "default").toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
|
|
120
|
+
// Public resolvers: host resolvers like Tailscale MagicDNS are not reachable from the container network.
|
|
121
|
+
const netArgs = ["--add-host", "host.docker.internal:host-gateway", ...(cfg.dns ?? ["1.1.1.1", "8.8.8.8"]).flatMap((d) => ["--dns", String(d)])];
|
|
122
|
+
let laneCpus = cpus, laneMem = mem_gb, laneNet = netArgs, work = [];
|
|
123
|
+
try {
|
|
124
|
+
if (withDocker) {
|
|
125
|
+
// Private Docker daemon per sandbox (never the host socket). The lane joins its network namespace, so ports the
|
|
126
|
+
// project's compose stack publishes are on the lane's localhost, and the API listens on loopback only. Both share
|
|
127
|
+
// /work, so compose bind mounts of the checkout resolve inside the daemon too.
|
|
128
|
+
let dCpus, dMem;
|
|
129
|
+
if (limit) { dCpus = limit.dind_cpus; dMem = limit.dind_mem_gb; laneCpus = limit.lane_cpus; laneMem = limit.lane_mem_gb; }
|
|
130
|
+
else { dCpus = Math.min(cfg.dind_cpus ?? 1, cpus - 1); dMem = Math.min(cfg.dind_mem_gb ?? 2, mem_gb - 1); laneCpus = cpus - dCpus; laneMem = mem_gb - dMem; }
|
|
131
|
+
({ cpus: dCpus, mem: dMem } = clampToVm(dCpus, dMem));
|
|
132
|
+
work = ["-v", `${workVolume(name)}:/work`];
|
|
133
|
+
await docker.run([
|
|
134
|
+
"--name", dindName(name), "--label", LABEL, "--label", `computeshare.id=${spec.id}`, "--label", "computeshare.role=dind",
|
|
135
|
+
"--privileged", "--cpus", String(dCpus), "--memory", mb(dMem), "--memory-swap", mb(dMem + swapFor(limit?.dind_swap_gb)),
|
|
136
|
+
...netArgs, ...work,
|
|
137
|
+
// Bypass dockerd-entrypoint: it adds tcp://0.0.0.0:2375 (reachable from other sandboxes on the bridge).
|
|
138
|
+
"--entrypoint", "dind", dindImage, "dockerd", "--host=tcp://127.0.0.1:2375", "--tls=false",
|
|
139
|
+
]);
|
|
140
|
+
laneNet = ["--network", `container:${dindName(name)}`];
|
|
141
|
+
}
|
|
142
|
+
if (limit && !withDocker) { laneCpus = limit.lane_cpus; laneMem = limit.lane_mem_gb; }
|
|
143
|
+
({ cpus: laneCpus, mem: laneMem } = clampToVm(laneCpus, laneMem));
|
|
144
|
+
await docker.run([
|
|
145
|
+
"--name", name, "--label", LABEL, "--label", `computeshare.id=${spec.id}`,
|
|
146
|
+
// Hard burst cap per container; the (smaller) reservation is only what placement counts.
|
|
147
|
+
// --memory-swap above --memory lets a rare spike page out instead of being OOM-killed (swap deal).
|
|
148
|
+
"--cpus", String(laneCpus), "--memory", mb(laneMem), "--memory-swap", mb(laneMem + swapFor(limit?.lane_swap_gb)), "--pids-limit", "4096",
|
|
149
|
+
"--env-file", envFile, ...laneNet, ...work,
|
|
150
|
+
"-v", `cs-cache-${cacheKey}:/cache`, "-v", `${join(dir, "result")}:/result`, "-v", `${join(dir, "out")}:/csout`,
|
|
151
|
+
spec.image ?? image, ...spec.command.map(String),
|
|
152
|
+
]);
|
|
153
|
+
rec.state = "running";
|
|
154
|
+
} catch (e) {
|
|
155
|
+
rec.state = "exited";
|
|
156
|
+
rec.finishedAt = now();
|
|
157
|
+
rec.error = String(e.stderr || e.message).slice(0, 500);
|
|
158
|
+
log.error?.(`launch ${spec.id} failed: ${rec.error}`);
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(envFile, { force: true }); // secrets live only in the container's env
|
|
161
|
+
store.save();
|
|
162
|
+
}
|
|
163
|
+
return { status: 201, body: view(rec) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
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
|
+
|
|
168
|
+
async function refresh(r) {
|
|
169
|
+
if (r.state === "exited") return r;
|
|
170
|
+
const s = await docker.inspect(r.name);
|
|
171
|
+
if (!s) { r.state = "exited"; r.finishedAt ??= now(); }
|
|
172
|
+
else if (!s.running && r.state !== "starting") { r.state = "exited"; r.exitCode = s.exitCode; r.finishedAt = Date.parse(s.finishedAt) || now(); }
|
|
173
|
+
else if (s.running) r.state = "running";
|
|
174
|
+
return r;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readLog(r, cursor) {
|
|
178
|
+
const file = join(r.dir, "out", "lane.log");
|
|
179
|
+
if (!existsSync(file)) return { text: "", cursor };
|
|
180
|
+
const size = statSync(file).size;
|
|
181
|
+
const from = Math.max(0, Math.min(Number(cursor) || 0, size));
|
|
182
|
+
const len = Math.min(LOG_CHUNK, size - from);
|
|
183
|
+
if (len <= 0) return { text: "", cursor: from };
|
|
184
|
+
const buf = Buffer.alloc(len);
|
|
185
|
+
const fd = openSync(file, "r");
|
|
186
|
+
try { readSync(fd, buf, 0, len, from); } finally { closeSync(fd); }
|
|
187
|
+
return { text: buf.toString("utf8"), cursor: from + len };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readResult(r) {
|
|
191
|
+
if (!r.result_file) return null;
|
|
192
|
+
try { return JSON.parse(readFileSync(join(r.dir, "result", basename(r.result_file)), "utf8")); } catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function destroy(id, why) {
|
|
196
|
+
const r = own(id);
|
|
197
|
+
if (!r) return false;
|
|
198
|
+
const history = r.usage?.samples ? JSON.stringify({ at: new Date(now()).toISOString(), id: r.id, issue: r.issue, docker: Boolean(r.docker), minutes: Math.round((((r.finishedAt ?? now()) - r.startedAt) / 60000) * 10) / 10, ...usageView(r) }) : null;
|
|
199
|
+
await docker.remove(r.name);
|
|
200
|
+
if (r.docker) { await docker.remove(dindName(r.name)); await docker.removeVolume(workVolume(r.name)); }
|
|
201
|
+
try { await docker.wipe(r.dir, image); } catch (e) { log.warn?.(`wipe ${r.name}: ${String(e.stderr || e.message).slice(0, 200)}`); }
|
|
202
|
+
rmSync(r.dir, { recursive: true, force: true });
|
|
203
|
+
delete sb()[id];
|
|
204
|
+
store.save();
|
|
205
|
+
// Written once, after the sandbox is really gone (a teardown that throws and is retried must not log it twice).
|
|
206
|
+
if (history) { try { appendFileSync(join(stateDir, "usage-history.jsonl"), history + "\n"); } catch { /* best effort */ } }
|
|
207
|
+
log.info?.(`destroyed ${id} (${why})`);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function reap() {
|
|
212
|
+
const t = now();
|
|
213
|
+
for (const r of Object.values(sb())) {
|
|
214
|
+
try { await refresh(r); } catch { /* docker hiccup: judge by lease only */ }
|
|
215
|
+
const why = t - r.lease > leaseTtlMs ? "lease expired" : t - r.startedAt > maxLifeMs ? "max lifetime" : r.state === "exited" && r.finishedAt && t - r.finishedAt > keepExitedMs ? "exited retention" : null;
|
|
216
|
+
if (why) await destroy(r.id, why);
|
|
217
|
+
}
|
|
218
|
+
const known = new Set(Object.values(sb()).flatMap((r) => [r.name, dindName(r.name)]));
|
|
219
|
+
for (const name of await docker.listLabelled(LABEL)) if (!known.has(name)) { await docker.remove(name); log.info?.(`removed orphan container ${name}`); }
|
|
220
|
+
await sampleUsage();
|
|
221
|
+
if (pendingResize && capacity().active === 0) {
|
|
222
|
+
const r = await resize(pendingResize);
|
|
223
|
+
log.info?.(`pending resize applied: HTTP ${r.status} ${JSON.stringify(r.body)}`);
|
|
224
|
+
}
|
|
225
|
+
const knownVols = new Set(Object.values(sb()).map((r) => workVolume(r.name)));
|
|
226
|
+
for (const v of await docker.listVolumes("cs-work-")) if (!knownVols.has(v)) { await docker.removeVolume(v); log.info?.(`removed orphan volume ${v}`); }
|
|
227
|
+
store.save();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// GitHub credentials for a running sandbox: fetched from the router on demand (installation tokens expire hourly).
|
|
231
|
+
const credCache = {};
|
|
232
|
+
const CRED_TTL = { github: 120000, claude: 300000 };
|
|
233
|
+
async function sandboxCred(id, kind, req) {
|
|
234
|
+
const r = own(id);
|
|
235
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
236
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
237
|
+
if (!CRED_TTL[kind]) return { status: 404, body: { error: "unknown credential" } };
|
|
238
|
+
const c = credCache[kind];
|
|
239
|
+
if (!c || now() - c.at > CRED_TTL[kind]) {
|
|
240
|
+
const x = await call(cfg.router_url, "GET", `/agent/creds/${kind}`, { token: cfg.token, timeoutMs: 20000 });
|
|
241
|
+
if (!x.ok || !x.data?.token) return { status: 502, body: { error: `router credentials (${kind}): HTTP ${x.status}` } };
|
|
242
|
+
credCache[kind] = { token: x.data.token, at: now() };
|
|
243
|
+
}
|
|
244
|
+
return { status: 200, body: { token: credCache[kind].token } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Pull the current bundle from the router, rebuild the lane image if its recipe changed, then restart (systemd). */
|
|
248
|
+
async function selfUpdate() {
|
|
249
|
+
if (upgrade.state === "running") return;
|
|
250
|
+
upgrade.state = "running"; upgrade.error = null;
|
|
251
|
+
const root = cfg.code_root ?? repoRoot;
|
|
252
|
+
const next = `${root}.new`, tgz = join(stateDir, "bundle.tar.gz");
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(`${String(cfg.router_url).replace(/\/+$/, "")}/agent/bundle`, { headers: { authorization: `Bearer ${cfg.token}` }, signal: AbortSignal.timeout(300000) });
|
|
255
|
+
if (!res.ok) throw new Error(`bundle download: HTTP ${res.status}`);
|
|
256
|
+
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
|
|
257
|
+
rmSync(next, { recursive: true, force: true }); mkdirSync(next, { recursive: true });
|
|
258
|
+
await run("tar", ["-xzf", tgz, "-C", next]);
|
|
259
|
+
const want = versionOf(next);
|
|
260
|
+
if (want.image !== running.image || want.version !== running.version) {
|
|
261
|
+
const nextImage = cfg.image && cfg.image !== "computeshare-lane:1" ? cfg.image : laneImageTag(want.version);
|
|
262
|
+
log.info?.(`rebuilding ${nextImage} (${running.version}/${running.image} -> ${want.version}/${want.image})`);
|
|
263
|
+
await docker.build(join(next, "pool", "image"), nextImage, laneBuildArgs(want.version));
|
|
264
|
+
}
|
|
265
|
+
await prepullImages();
|
|
266
|
+
rmSync(`${root}.old`, { recursive: true, force: true });
|
|
267
|
+
await run("mv", [root, `${root}.old`]);
|
|
268
|
+
await run("mv", [next, root]);
|
|
269
|
+
log.info?.(`updated to ${JSON.stringify(want)}; restarting`);
|
|
270
|
+
upgrade.state = "restarting";
|
|
271
|
+
setTimeout(() => process.exit(0), 500);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
upgrade.state = "failed"; upgrade.error = String(e.stderr || e.message).slice(0, 500);
|
|
274
|
+
log.error?.(`self-update failed: ${upgrade.error}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Change this host's lane allowance. On WSL also rewrite the Windows-side .wslconfig VM caps (processors/memory) and
|
|
280
|
+
* restart WSL (the keep-alive task brings the distro back); only while no sandbox is active.
|
|
281
|
+
*/
|
|
282
|
+
/** Peak and average CPU (cores) and memory (GB) per sandbox, lane + docker sidecar, against what it reserved. */
|
|
283
|
+
async function sampleUsage() {
|
|
284
|
+
let st;
|
|
285
|
+
try { st = await docker.stats?.(LABEL); } catch { return; }
|
|
286
|
+
if (!st) return;
|
|
287
|
+
for (const r of Object.values(sb())) {
|
|
288
|
+
if (r.state === "exited") continue;
|
|
289
|
+
const parts = [st[r.name], r.docker ? st[dindName(r.name)] : null].filter(Boolean);
|
|
290
|
+
if (!parts.length) continue;
|
|
291
|
+
const cpu = parts.reduce((a, x) => a + x.cpu, 0), mem = parts.reduce((a, x) => a + x.mem_gb, 0);
|
|
292
|
+
const u = (r.usage ??= { samples: 0, cpu_sum: 0, cpu_peak: 0, mem_sum: 0, mem_peak: 0 });
|
|
293
|
+
u.samples++; u.cpu_sum += cpu; u.mem_sum += mem; u.cpu_peak = Math.max(u.cpu_peak, cpu); u.mem_peak = Math.max(u.mem_peak, mem);
|
|
294
|
+
}
|
|
295
|
+
store.save();
|
|
296
|
+
}
|
|
297
|
+
const usageView = (r) => {
|
|
298
|
+
const u = r.usage; const f = (x) => Math.round(x * 100) / 100;
|
|
299
|
+
return u?.samples ? { reserved: { cpus: r.cpus, mem_gb: r.mem_gb }, cpu_avg: f(u.cpu_sum / u.samples), cpu_peak: f(u.cpu_peak), mem_avg_gb: f(u.mem_sum / u.samples), mem_peak_gb: f(u.mem_peak), samples: u.samples } : null;
|
|
300
|
+
};
|
|
301
|
+
let pendingResize = null;
|
|
302
|
+
async function resize(body) {
|
|
303
|
+
const int = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi ? v : null);
|
|
304
|
+
const cpus = int(body.cpus, 1, 256), mem = int(body.mem_gb, 1, 1024), vmCpus = int(body.vm_cpus ?? body.cpus, 1, 256), vmMem = int(body.vm_mem_gb ?? (body.mem_gb + 2), 2, 1024);
|
|
305
|
+
if (!cpus || !mem || !vmCpus || !vmMem || vmCpus < cpus || vmMem <= mem) return { status: 400, body: { error: "need integers cpus, mem_gb (and vm_cpus >= cpus, vm_mem_gb > mem_gb)" } };
|
|
306
|
+
if (capacity().active > 0) {
|
|
307
|
+
if (!body.drain) return { status: 409, body: { error: "sandboxes are running; retry when idle, or pass drain: true", active: capacity().active } };
|
|
308
|
+
// Drain: take no new sandboxes, let the running ones finish, then apply (the reaper loop checks).
|
|
309
|
+
pendingResize = { ...body, drain: false };
|
|
310
|
+
cfg.paused = true;
|
|
311
|
+
log.info?.(`resize pending: draining ${capacity().active} sandbox(es) first`);
|
|
312
|
+
return { status: 202, body: { ok: true, draining: capacity().active, pending: { cpus, mem_gb: mem, vm_cpus: vmCpus, vm_mem_gb: vmMem } } };
|
|
313
|
+
}
|
|
314
|
+
pendingResize = null;
|
|
315
|
+
cfg.paused = false;
|
|
316
|
+
let wsl = null;
|
|
317
|
+
let release = ""; try { release = readFileSync("/proc/sys/kernel/osrelease", "utf8"); } catch { /* not linux */ }
|
|
318
|
+
if (/microsoft/i.test(release)) {
|
|
319
|
+
const users = (() => { try { return readdirSync("/mnt/c/Users"); } catch { return []; } })();
|
|
320
|
+
const file = users.map((u) => join("/mnt/c/Users", u, ".wslconfig")).find((f) => existsSync(`${f}.computeshare-backup`) || (existsSync(f) && /vmIdleTimeout/i.test(readFileSync(f, "utf8"))));
|
|
321
|
+
if (!file) return { status: 500, body: { error: "could not find the .wslconfig the installer manages" } };
|
|
322
|
+
const want = { processors: String(vmCpus), memory: `${vmMem}GB`, ...(int(body.vm_swap_gb, 0, 512) !== null ? { swap: `${body.vm_swap_gb}GB` } : {}) };
|
|
323
|
+
const out = []; let inWsl2 = false; const seen = {};
|
|
324
|
+
for (const l of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
325
|
+
const sec = /^\s*\[(.+)\]\s*$/.exec(l);
|
|
326
|
+
if (sec) { if (inWsl2) for (const k of Object.keys(want)) if (!seen[k]) { out.push(`${k}=${want[k]}`); seen[k] = true; } inWsl2 = sec[1].trim().toLowerCase() === "wsl2"; out.push(l); continue; }
|
|
327
|
+
const kv = inWsl2 && /^\s*([A-Za-z]+)\s*=/.exec(l);
|
|
328
|
+
const k = kv && Object.keys(want).find((x) => x.toLowerCase() === kv[1].toLowerCase());
|
|
329
|
+
if (k) { out.push(`${k}=${want[k]}`); seen[k] = true; continue; }
|
|
330
|
+
out.push(l);
|
|
331
|
+
}
|
|
332
|
+
if (inWsl2) for (const k of Object.keys(want)) if (!seen[k]) out.push(`${k}=${want[k]}`);
|
|
333
|
+
writeFileSync(file, out.join("\r\n"));
|
|
334
|
+
wsl = file;
|
|
335
|
+
}
|
|
336
|
+
if (cfg.config_path) {
|
|
337
|
+
const c = JSON.parse(readFileSync(cfg.config_path, "utf8"));
|
|
338
|
+
c.allow_cpus = cpus; c.allow_mem_gb = mem;
|
|
339
|
+
writeFileSync(cfg.config_path, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
340
|
+
}
|
|
341
|
+
cfg.allow_cpus = cpus; cfg.allow_mem_gb = mem;
|
|
342
|
+
log.info?.(`resized: lanes ${cpus} CPU / ${mem} GB${wsl ? `; ${wsl} -> ${vmCpus} CPU / ${vmMem}GB, restarting WSL` : ""}`);
|
|
343
|
+
if (wsl) {
|
|
344
|
+
// systemd services lack WSL_INTEROP; point it at a live interop socket so wsl.exe can run.
|
|
345
|
+
const sock = (() => { try { return readdirSync("/run/WSL").filter((f) => f.endsWith("_interop")).map((f) => join("/run/WSL", f)).sort().pop(); } catch { return null; } })();
|
|
346
|
+
setTimeout(() => {
|
|
347
|
+
const child = spawn("/mnt/c/Windows/System32/wsl.exe", ["--shutdown"], { detached: true, stdio: "ignore", env: { ...process.env, ...(sock ? { WSL_INTEROP: sock } : {}) } });
|
|
348
|
+
child.unref();
|
|
349
|
+
}, 1500);
|
|
350
|
+
}
|
|
351
|
+
return { status: 202, body: { ok: true, lanes: { cpus, mem_gb: mem }, ...(wsl ? { vm: { cpus: vmCpus, mem_gb: vmMem }, wsl_restart: true } : {}) } };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const routes = [
|
|
355
|
+
[route("GET", "/health"), async () => ({ status: 200, body: { ok: true, ...capacity() } })],
|
|
356
|
+
[route("GET", "/capacity"), async () => ({ status: 200, body: capacity() })],
|
|
357
|
+
[route("POST", "/sandboxes"), async (_p, body) => launch(body ?? {})],
|
|
358
|
+
[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) }; }],
|
|
359
|
+
[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 } }; }],
|
|
360
|
+
[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" } }; }],
|
|
361
|
+
[route("GET", "/sandboxes/:id/usage"), async ({ id }) => { const r = own(id); return r ? { status: 200, body: { minutes: Math.round((((r.finishedAt ?? now()) - r.startedAt) / 60000) * 10) / 10 } } : { status: 404, body: {} }; }],
|
|
362
|
+
[route("POST", "/sandboxes/:id/lease"), async ({ id }) => { const r = own(id); if (!r) return { status: 404, body: { state: "gone" } }; r.lease = now(); store.save(); return { status: 200, body: { ok: true } }; }],
|
|
363
|
+
[route("GET", "/usage-history"), async () => {
|
|
364
|
+
let rows = [];
|
|
365
|
+
try { rows = readFileSync(join(stateDir, "usage-history.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch { /* none yet */ }
|
|
366
|
+
return { status: 200, body: { rows: rows.slice(-500) } };
|
|
367
|
+
}],
|
|
368
|
+
[route("POST", "/admin/resize"), async (_p, body) => resize(body ?? {})],
|
|
369
|
+
[route("POST", "/admin/update"), async () => { selfUpdate(); return { status: 202, body: { ok: true, from: running } }; }],
|
|
370
|
+
[route("DELETE", "/sandboxes/:id"), async ({ id }) => ((await destroy(id, "requested")) ? { status: 200, body: { ok: true } } : { status: 404, body: { state: "gone" } })],
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
async function handle(req, res) {
|
|
374
|
+
const url = new URL(req.url, "http://agent");
|
|
375
|
+
try {
|
|
376
|
+
const cred = req.method === "GET" && /^\/sandboxes\/([^/]+)\/creds\/([a-z]+)$/.exec(url.pathname);
|
|
377
|
+
if (cred) { const r = await sandboxCred(decodeURIComponent(cred[1]), cred[2], req); return send(res, r.status, r.body); }
|
|
378
|
+
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized" });
|
|
379
|
+
for (const [match, fn] of routes) {
|
|
380
|
+
const p = match(req, url);
|
|
381
|
+
if (!p) continue;
|
|
382
|
+
const body = req.method === "POST" ? await readJson(req) : null;
|
|
383
|
+
const r = await fn(p, body, url);
|
|
384
|
+
return send(res, r.status, r.body, r.retryAfter ? { "retry-after": String(r.retryAfter) } : {});
|
|
385
|
+
}
|
|
386
|
+
send(res, 404, { error: "not found" });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
log.error?.(e);
|
|
389
|
+
send(res, e.status ?? 500, { error: e.status ? e.message : "internal error" });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Build the lane image for the running ForgeDock version when this host does not have it yet. */
|
|
394
|
+
async function ensureImage() {
|
|
395
|
+
if (await docker.hasImage?.(image)) return;
|
|
396
|
+
log.info?.(`building ${image}`);
|
|
397
|
+
await docker.build(join(cfg.code_root ?? repoRoot, "pool", "image"), image, laneBuildArgs(running.version));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { handle, launch, reap, destroy, capacity, refresh, store, prepullImages, ensureImage };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* WSL with mirrored networking often has no IPv6 default route, yet dockerd still dials registries over IPv6 and fails
|
|
405
|
+
* ("network is unreachable"). Turn IPv6 off inside the distro (persistently) and restart docker while it is idle.
|
|
406
|
+
*/
|
|
407
|
+
export async function wslNetworkFixup({ run: runCmd = run, log = console, idle = () => true } = {}) {
|
|
408
|
+
let release = "";
|
|
409
|
+
try { release = readFileSync("/proc/sys/kernel/osrelease", "utf8"); } catch { return "not linux"; }
|
|
410
|
+
if (!/microsoft/i.test(release)) return "not wsl";
|
|
411
|
+
try {
|
|
412
|
+
const { stdout } = await runCmd("ip", ["-6", "route", "show", "default"]);
|
|
413
|
+
if (String(stdout).trim()) return "ipv6 route present";
|
|
414
|
+
} catch { /* no ip tool or no route: treat as missing */ }
|
|
415
|
+
const conf = "net.ipv6.conf.all.disable_ipv6=1\nnet.ipv6.conf.default.disable_ipv6=1\n";
|
|
416
|
+
try { if (readFileSync("/etc/sysctl.d/90-computeshare-noipv6.conf", "utf8") === conf) return "already applied"; } catch { /* first time */ }
|
|
417
|
+
writeFileSync("/etc/sysctl.d/90-computeshare-noipv6.conf", conf);
|
|
418
|
+
await runCmd("sysctl", ["-p", "/etc/sysctl.d/90-computeshare-noipv6.conf"]);
|
|
419
|
+
if (idle()) await runCmd("systemctl", ["restart", "docker"], { timeout: 120000 });
|
|
420
|
+
log.info?.("WSL: IPv6 disabled in the distro (no IPv6 route); docker restarted for IPv4 registry pulls");
|
|
421
|
+
return "applied";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function startAgent(cfg, deps) {
|
|
425
|
+
const agent = createAgent(cfg, deps);
|
|
426
|
+
const server = createServer(agent.handle);
|
|
427
|
+
server.listen(cfg.listen.port, cfg.listen.host);
|
|
428
|
+
const timer = setInterval(() => agent.reap().catch((e) => console.error("reap failed", e)), (cfg.reap_interval_s ?? 30) * 1000);
|
|
429
|
+
agent.reap().catch((e) => console.error("startup reap failed", e));
|
|
430
|
+
agent.ensureImage().catch((e) => console.error("lane image build failed", e));
|
|
431
|
+
wslNetworkFixup({ idle: () => agent.capacity().active === 0 })
|
|
432
|
+
.catch((e) => console.error("wsl network fixup failed", e))
|
|
433
|
+
.finally(() => agent.prepullImages());
|
|
434
|
+
return { agent, server, close: () => { clearInterval(timer); server.close(); } };
|
|
435
|
+
}
|