Release Healer Man 0.1.47
This commit is contained in:
1 parent
9ff574ac0b
commit
2162039b49
24 files changed
+486
-55
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
createReadStream,
|
||||
existsSync,
|
||||
@@ -101,6 +101,36 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
return { executable, args, cwd: projectRoot };
|
||||
}
|
||||
|
||||
export function inspectBalanceLabRuntime(executable, run = spawnSync) {
|
||||
const command = String(executable || (process.platform === "win32" ? "python" : "python3"));
|
||||
try {
|
||||
const result = run(command, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = `${result?.stdout ?? ""} ${result?.stderr ?? ""}`.trim();
|
||||
if (result?.status === 0 && /^Python 3\./i.test(version)) {
|
||||
return { available: true, executable: command, version, error: null };
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
executable: command,
|
||||
version: version || null,
|
||||
error: version && /^Python 2\./i.test(version)
|
||||
? "Balance Lab requires Python 3; this server resolved Python 2."
|
||||
: `The server could not execute ${command} as Python 3.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
executable: command,
|
||||
version: null,
|
||||
error: `The server could not execute ${command}: ${error?.message ?? error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function publicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
@@ -166,6 +196,13 @@ export function createBalanceLabJobService(options = {}) {
|
||||
? Math.min(50, Math.floor(configuredQueueLimit))
|
||||
: 8;
|
||||
const launch = options.spawnProcess ?? spawn;
|
||||
const pythonExecutable = String(options.pythonExecutable
|
||||
?? process.env.BALANCE_LAB_PYTHON
|
||||
?? (process.platform === "win32" ? "python" : "python3"));
|
||||
const runtimeProbe = options.runtimeProbe
|
||||
?? (options.spawnProcess
|
||||
? () => ({ available: true, executable: pythonExecutable, version: "test process", error: null })
|
||||
: () => inspectBalanceLabRuntime(pythonExecutable));
|
||||
const jobs = new Map();
|
||||
const queue = [];
|
||||
const children = new Map();
|
||||
@@ -288,7 +325,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
persistJob(job);
|
||||
const command = balanceLabCommand(job.spec, job.outputDir, {
|
||||
projectRoot,
|
||||
pythonExecutable: options.pythonExecutable,
|
||||
pythonExecutable,
|
||||
});
|
||||
const child = launch(command.executable, command.args, {
|
||||
cwd: command.cwd,
|
||||
@@ -344,6 +381,14 @@ export function createBalanceLabJobService(options = {}) {
|
||||
return {
|
||||
start(input, account) {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const runtime = runtimeProbe();
|
||||
if (!runtime.available) {
|
||||
throw new GameDatabaseError(
|
||||
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
|
||||
503,
|
||||
"balance_lab_runtime_unavailable",
|
||||
);
|
||||
}
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending >= maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
@@ -390,6 +435,9 @@ export function createBalanceLabJobService(options = {}) {
|
||||
.slice(0, 100)
|
||||
.map(publicJob);
|
||||
},
|
||||
runtime() {
|
||||
return { ...runtimeProbe() };
|
||||
},
|
||||
get(id) {
|
||||
return publicJob(requireJob(id));
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import test from "node:test";
|
||||
import {
|
||||
balanceLabCommand,
|
||||
createBalanceLabJobService,
|
||||
inspectBalanceLabRuntime,
|
||||
normalizeBalanceLabJobSpec,
|
||||
} from "./balance-lab-jobs.mjs";
|
||||
|
||||
@@ -50,6 +51,39 @@ test("builds an argument-array command without a shell", () => {
|
||||
assert.equal(command.cwd, path.resolve("C:/app"));
|
||||
});
|
||||
|
||||
test("detects available and missing Python 3 runtimes", () => {
|
||||
const available = inspectBalanceLabRuntime("python3", () => ({
|
||||
status: 0, stdout: "Python 3.12.8\n", stderr: "",
|
||||
}));
|
||||
assert.equal(available.available, true);
|
||||
assert.equal(available.version, "Python 3.12.8");
|
||||
const missing = inspectBalanceLabRuntime("python3", () => ({
|
||||
status: 127, stdout: "", stderr: "python3: not found",
|
||||
}));
|
||||
assert.equal(missing.available, false);
|
||||
assert.match(missing.error, /could not execute python3/i);
|
||||
});
|
||||
|
||||
test("rejects a job before queueing when the server runtime is unavailable", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-runtime-"));
|
||||
try {
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
runtimeProbe: () => ({ available: false, executable: "python3", version: null, error: "python3 is missing." }),
|
||||
});
|
||||
assert.throws(() => service.start({
|
||||
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}, { username: "Phenom" }), /Install Python 3 in the server container/);
|
||||
assert.equal(service.list().length, 0);
|
||||
assert.equal(service.runtime().available, false);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
|
||||
try {
|
||||
|
||||
@@ -506,6 +506,14 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { jobs: balanceLabJobs.list() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/runtime" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
|
||||
@@ -248,6 +248,11 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(balanceLabJobs.status, 200);
|
||||
assert.deepEqual((await balanceLabJobs.json()).jobs, []);
|
||||
const balanceLabRuntime = await fetch(`${origin}/api/gm/balance-lab/runtime`, {
|
||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||
});
|
||||
assert.equal(balanceLabRuntime.status, 200);
|
||||
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
|
||||
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
|
||||
Reference in new issue
Block a user