forked from phenom/healer-man
Release Healer Man 0.1.56
This commit is contained in:
1 parent
38b3d4d160
commit
a05a79eb83
16 files changed
+388
-116
No files matched your search
+98
-52
@@ -17,6 +17,7 @@ const SOURCES = new Set(["wow", "coa", "rom", "all"]);
|
||||
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
|
||||
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
|
||||
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
|
||||
const MAX_BATCH_JOBS = 80;
|
||||
const JOB_METADATA_NAME = "job.json";
|
||||
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ARTIFACTS = new Map([
|
||||
@@ -103,6 +104,18 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function normalizeBalanceLabJobBatch(input) {
|
||||
const rawSpecs = Array.isArray(input?.specs) ? input.specs : [];
|
||||
if (rawSpecs.length < 1 || rawSpecs.length > MAX_BATCH_JOBS) {
|
||||
throw new GameDatabaseError(
|
||||
`A Balance Lab batch must contain 1 through ${MAX_BATCH_JOBS} level jobs.`,
|
||||
400,
|
||||
"invalid_balance_lab_batch",
|
||||
);
|
||||
}
|
||||
return Object.freeze(rawSpecs.map((spec) => normalizeBalanceLabJobSpec(spec)));
|
||||
}
|
||||
|
||||
export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||
const executable = String(options.pythonExecutable
|
||||
@@ -218,14 +231,14 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
|
||||
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
||||
const jobsDir = path.join(dataDir, "balance-lab", "jobs");
|
||||
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 1);
|
||||
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 4);
|
||||
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
|
||||
? Math.min(4, Math.floor(configuredConcurrency))
|
||||
: 1;
|
||||
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8);
|
||||
? Math.min(16, Math.floor(configuredConcurrency))
|
||||
: 4;
|
||||
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 100);
|
||||
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
|
||||
? Math.min(50, Math.floor(configuredQueueLimit))
|
||||
: 8;
|
||||
? Math.min(200, Math.floor(configuredQueueLimit))
|
||||
: 100;
|
||||
const launch = options.spawnProcess ?? spawn;
|
||||
const pythonExecutable = String(options.pythonExecutable
|
||||
?? process.env.BALANCE_LAB_PYTHON
|
||||
@@ -239,8 +252,14 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const children = new Map();
|
||||
let running = 0;
|
||||
let closed = false;
|
||||
let inspectedRuntime = null;
|
||||
mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
function currentRuntime() {
|
||||
inspectedRuntime ??= runtimeProbe();
|
||||
return inspectedRuntime;
|
||||
}
|
||||
|
||||
function persistJob(job) {
|
||||
const record = publicJob(job);
|
||||
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
||||
@@ -414,57 +433,78 @@ export function createBalanceLabJobService(options = {}) {
|
||||
return job;
|
||||
}
|
||||
|
||||
function requireAvailableRuntime() {
|
||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||
const runtime = currentRuntime();
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireQueueCapacity(count) {
|
||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
if (pending + count > maxQueued) {
|
||||
throw new GameDatabaseError(
|
||||
`The Balance Lab server queue is full for this request; it has room for ${Math.max(0, maxQueued - pending)} more jobs. Wait for running jobs to finish or submit a smaller level batch.`,
|
||||
429,
|
||||
"balance_lab_queue_full",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function queueJob(spec, account) {
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
requestedBy: account.username,
|
||||
spec,
|
||||
progress: {
|
||||
party: 0,
|
||||
partiesCompleted: 0,
|
||||
partiesTotal: totalParties,
|
||||
simulationsCompleted: 0,
|
||||
simulationsTotal: 0,
|
||||
},
|
||||
artifacts: [],
|
||||
error: null,
|
||||
logTail: [],
|
||||
summary: null,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
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(
|
||||
"The Balance Lab server queue is full. Wait for a running job to finish.",
|
||||
429,
|
||||
"balance_lab_queue_full",
|
||||
);
|
||||
}
|
||||
const spec = normalizeBalanceLabJobSpec(input);
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
requestedBy: account.username,
|
||||
spec,
|
||||
progress: {
|
||||
party: 0,
|
||||
partiesCompleted: 0,
|
||||
partiesTotal: totalParties,
|
||||
simulationsCompleted: 0,
|
||||
simulationsTotal: 0,
|
||||
},
|
||||
artifacts: [],
|
||||
error: null,
|
||||
logTail: [],
|
||||
summary: null,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(id, job);
|
||||
persistJob(job);
|
||||
queue.push(job);
|
||||
requireAvailableRuntime();
|
||||
requireQueueCapacity(1);
|
||||
const job = queueJob(spec, account);
|
||||
pump();
|
||||
return publicJob(job);
|
||||
},
|
||||
startMany(input, account) {
|
||||
const specs = normalizeBalanceLabJobBatch(input);
|
||||
requireAvailableRuntime();
|
||||
requireQueueCapacity(specs.length);
|
||||
const batch = specs.map((spec) => queueJob(spec, account));
|
||||
pump();
|
||||
return batch.map(publicJob);
|
||||
},
|
||||
list() {
|
||||
return [...jobs.values()]
|
||||
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
||||
@@ -472,7 +512,13 @@ export function createBalanceLabJobService(options = {}) {
|
||||
.map(publicJob);
|
||||
},
|
||||
runtime() {
|
||||
return { ...runtimeProbe() };
|
||||
return {
|
||||
...currentRuntime(),
|
||||
maxConcurrent,
|
||||
running,
|
||||
queued: queue.length,
|
||||
maxQueued,
|
||||
};
|
||||
},
|
||||
get(id) {
|
||||
return publicJob(requireJob(id));
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
balanceLabCommand,
|
||||
createBalanceLabJobService,
|
||||
inspectBalanceLabRuntime,
|
||||
normalizeBalanceLabJobBatch,
|
||||
normalizeBalanceLabJobSpec,
|
||||
} from "./balance-lab-jobs.mjs";
|
||||
|
||||
@@ -43,6 +44,20 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes complete level batches before any jobs are queued", () => {
|
||||
const batch = normalizeBalanceLabJobBatch({
|
||||
specs: [10, 20, 30].map((level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(batch.map((spec) => spec.level), [10, 20, 30]);
|
||||
assert.throws(() => normalizeBalanceLabJobBatch({ specs: [] }), /must contain 1 through 80/);
|
||||
assert.throws(() => normalizeBalanceLabJobBatch({
|
||||
specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }],
|
||||
}), /Level must be a whole number/);
|
||||
});
|
||||
|
||||
test("builds an argument-array command without a shell", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
|
||||
@@ -293,3 +308,69 @@ test("bounds the number of pending server jobs", () => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs separate level jobs concurrently and reports worker utilization", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-parallel-"));
|
||||
try {
|
||||
let launches = 0;
|
||||
const spawnProcess = () => {
|
||||
launches += 1;
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => child.emit("close", null);
|
||||
return child;
|
||||
};
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess,
|
||||
pythonExecutable: "python-test",
|
||||
maxConcurrent: 3,
|
||||
maxQueued: 10,
|
||||
});
|
||||
const requestForLevel = (level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
});
|
||||
const jobs = service.startMany({ specs: [10, 20, 30].map(requestForLevel) }, { username: "Phenom" });
|
||||
assert.equal(launches, 3);
|
||||
assert.deepEqual(jobs.map((job) => job.status), ["running", "running", "running"]);
|
||||
assert.deepEqual(jobs.map((job) => job.spec.level), [10, 20, 30]);
|
||||
assert.deepEqual(service.runtime(), {
|
||||
available: true,
|
||||
executable: "python-test",
|
||||
version: "test process",
|
||||
error: null,
|
||||
maxConcurrent: 3,
|
||||
running: 3,
|
||||
queued: 0,
|
||||
maxQueued: 10,
|
||||
});
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an oversized batch without queueing a partial sweep", () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-batch-capacity-"));
|
||||
try {
|
||||
const service = createBalanceLabJobService({
|
||||
dataDir: directory,
|
||||
projectRoot: directory,
|
||||
spawnProcess: () => { throw new Error("an over-capacity batch must not launch"); },
|
||||
pythonExecutable: "python-test",
|
||||
maxQueued: 2,
|
||||
});
|
||||
const specs = [10, 20, 30].map((level) => ({
|
||||
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
|
||||
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
|
||||
}));
|
||||
assert.throws(() => service.startMany({ specs }, { username: "Phenom" }), /server queue is full/);
|
||||
assert.equal(service.list().length, 0);
|
||||
service.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -514,6 +514,16 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account);
|
||||
console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`);
|
||||
json(response, 202, { jobs });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
|
||||
+10
-1
@@ -252,7 +252,9 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||
});
|
||||
assert.equal(balanceLabRuntime.status, 200);
|
||||
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
|
||||
const balanceLabRuntimePayload = await balanceLabRuntime.json();
|
||||
assert.equal(balanceLabRuntimePayload.runtime.available, true);
|
||||
assert.equal(balanceLabRuntimePayload.runtime.maxConcurrent, 4);
|
||||
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
@@ -260,6 +262,13 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(invalidBalanceLab.status, 400);
|
||||
assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job");
|
||||
const invalidBalanceLabBatch = await fetch(`${origin}/api/gm/balance-lab/jobs/batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({ specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }] }),
|
||||
});
|
||||
assert.equal(invalidBalanceLabBatch.status, 400);
|
||||
assert.equal((await invalidBalanceLabBatch.json()).code, "invalid_balance_lab_job");
|
||||
const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||
const published = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
method: "PUT",
|
||||
|
||||
Reference in new issue
Block a user