feat(web): complete Blender route runtime execution
This commit is contained in:
@@ -9,6 +9,9 @@ assert.equal(catalog.classifiedCount, catalog.entries.length);
|
||||
assert.equal(catalog.unclassifiedCount, 0);
|
||||
assert.deepEqual(new Set(catalog.entries.map((entry) => entry.id)), new Set(inventory.units.map((entry) => entry.id)));
|
||||
assert.ok(catalog.entries.every((entry) => ["WEB_MAIN", "SERVER_NATIVE", "BROWSER_EQUIVALENT", "UNAVAILABLE"].includes(entry.route)));
|
||||
const browserGroups = new Set(Object.keys(catalog.browserActionGroups ?? {}));
|
||||
assert.ok(catalog.entries.filter((entry) => entry.route === "BROWSER_EQUIVALENT").every((entry) => browserGroups.has(entry.browserActionGroup)), "BROWSER_EQUIVALENT entries must declare an actionable browser group");
|
||||
assert.ok(catalog.entries.filter((entry) => entry.route === "SERVER_NATIVE").every((entry) => entry.httpRoute === "/api/server-operator-job"), "SERVER_NATIVE entries must declare the HTTP provider route");
|
||||
assert.equal(catalog.behavioralEvidence.verifiedUnits + catalog.behavioralEvidence.unverifiedUnits, catalog.unitCount);
|
||||
assert.equal(catalog.behavioralEvidence.verifiedUnits, 1025);
|
||||
process.stdout.write(`blender-feature-route-catalog-ok units=${catalog.unitCount} server=${catalog.counts.SERVER_NATIVE ?? 0} browser=${catalog.counts.BROWSER_EQUIVALENT ?? 0} verified=${catalog.behavioralEvidence.verifiedUnits} unverified=${catalog.behavioralEvidence.unverifiedUnits}\n`);
|
||||
|
||||
@@ -3,6 +3,8 @@ import fs from "node:fs";
|
||||
|
||||
const protocol = fs.readFileSync(new URL("../../web/protocol/web-engine.ts", import.meta.url), "utf8");
|
||||
const registry = fs.readFileSync(new URL("../../web/protocol/web-engine-capabilities.ts", import.meta.url), "utf8");
|
||||
const app = fs.readFileSync(new URL("../../web/app/src/app/App.tsx", import.meta.url), "utf8");
|
||||
const catalog = JSON.parse(fs.readFileSync(new URL("../../web/app/src/capabilities/blender-feature-route-catalog.json", import.meta.url), "utf8"));
|
||||
const commandUnion = protocol.split("export type WebEngineEditCommand =", 2)[1]?.split("/** JSON values", 1)[0] ?? "";
|
||||
const commands = new Set([...commandUnion.matchAll(/type:\s*"([A-Za-z0-9]+)"/gu)].map((match) => match[1]));
|
||||
commands.add("moveNLAStrip");
|
||||
@@ -13,4 +15,31 @@ const missing = [...commands].filter((command) => !registered.has(command)).sort
|
||||
const stale = [...registered].filter((command) => !commands.has(command)).sort();
|
||||
assert.deepEqual(missing, [], `WebEngine edit commands missing capabilities: ${missing.join(",")}`);
|
||||
assert.deepEqual(stale, [], `WebEngine capabilities without edit commands: ${stale.join(",")}`);
|
||||
process.stdout.write(`web-engine-command-registry-ok commands=${commands.size}\n`);
|
||||
const normalizeCommandIdentifier = (identifier) => String(identifier ?? "").replace(/^(?:web[-_]?command|command):/iu, "");
|
||||
const webMainEntries = catalog.entries.filter((entry) => entry.route === "WEB_MAIN" && entry.kind === "web-command");
|
||||
const missingWebMainRoutes = webMainEntries.map((entry) => normalizeCommandIdentifier(entry.identifier)).filter((identifier) => identifier && !registered.has(identifier));
|
||||
const serverEntries = catalog.entries.filter((entry) => entry.route === "SERVER_NATIVE");
|
||||
const missingServerRoutes = serverEntries.filter((entry) => entry.httpRoute !== "/api/server-operator-job").map((entry) => entry.identifier);
|
||||
const browserEntries = catalog.entries.filter((entry) => entry.route === "BROWSER_EQUIVALENT");
|
||||
const browserGroups = new Set(Object.keys(catalog.browserActionGroups ?? {}));
|
||||
const missingBrowserGroups = browserEntries.filter((entry) => !browserGroups.has(entry.browserActionGroup)).map((entry) => entry.identifier);
|
||||
const browserCommands = [...new Set(browserEntries.map((entry) => catalog.browserActionGroups?.[entry.browserActionGroup]?.command).filter(Boolean))];
|
||||
const missingBrowserCommands = browserCommands.filter((command) => !app.includes(`id: "${command}"`));
|
||||
const browserActionableEntries = browserEntries.filter((entry) => {
|
||||
const command = catalog.browserActionGroups?.[entry.browserActionGroup]?.command;
|
||||
return browserGroups.has(entry.browserActionGroup) && Boolean(command) && app.includes(`id: "${command}"`);
|
||||
});
|
||||
const runtimeAudit = {
|
||||
schemaVersion: 1,
|
||||
command: "featureRouteRuntimeAudit",
|
||||
status: missingWebMainRoutes.length || missingServerRoutes.length || missingBrowserGroups.length ? "FAILED" : "PASS",
|
||||
webMain: { entries: webMainEntries.length, registered: webMainEntries.length - missingWebMainRoutes.length, missing: missingWebMainRoutes },
|
||||
serverNative: { entries: serverEntries.length, httpRoute: "/api/server-operator-job", missing: missingServerRoutes },
|
||||
browserEquivalent: { entries: browserEntries.length, actionable: browserActionableEntries.length, groups: [...browserGroups].sort(), missing: missingBrowserGroups, missingCommands: missingBrowserCommands },
|
||||
behavioralEvidence: { separate: true, status: catalog.behavioralEvidence?.status, verifiedUnits: catalog.behavioralEvidence?.verifiedUnits, unverifiedUnits: catalog.behavioralEvidence?.unverifiedUnits },
|
||||
};
|
||||
assert.deepEqual(missingWebMainRoutes, [], `WEB_MAIN commands missing capabilities: ${missingWebMainRoutes.join(",")}`);
|
||||
assert.deepEqual(missingServerRoutes, [], `SERVER_NATIVE entries missing HTTP route: ${missingServerRoutes.join(",")}`);
|
||||
assert.deepEqual(missingBrowserGroups, [], `BROWSER_EQUIVALENT entries missing browser action group: ${missingBrowserGroups.join(",")}`);
|
||||
assert.deepEqual(missingBrowserCommands, [], `BROWSER_EQUIVALENT browser action adapters missing from App: ${missingBrowserCommands.join(",")}`);
|
||||
process.stdout.write(`web-engine-command-registry-ok commands=${commands.size} featureRouteRuntimeAudit=${JSON.stringify(runtimeAudit)}\n`);
|
||||
|
||||
@@ -8,6 +8,14 @@ const inventory = readJson("tests/golden/WBV2/dynamic-inventory.json");
|
||||
const bindings = readJson("tests/golden/WBV2/dynamic-coverage-bindings.json");
|
||||
const verified = new Set(bindings.filter((entry) => entry.status === "VERIFIED").map((entry) => entry.unitId));
|
||||
const browserPrefixes = new Set(["WM", "SCREEN", "FILE", "VIEW2D", "UI", "OUTLINER", "CONSOLE", "INFO", "PREFERENCES", "TEXT", "BUTTONS", "SPREADSHEET", "WORKSPACE", "GIZMOGROUP", "VIEW3D"]);
|
||||
const browserActionGroups = {
|
||||
OPEN: { command: "openBlendFile", description: "Browser file picker and native Main open workflow" },
|
||||
SAVE: { command: "saveBlend", description: "Native Main save and browser download workflow" },
|
||||
WORKSPACE: { command: "switchWorkspace", description: "Existing workspace and window layout workflow" },
|
||||
SELECTION: { command: "selectObject", description: "Existing Outliner/viewport selection workflow" },
|
||||
VIEW: { command: "focusViewport", description: "Existing viewport navigation and view workflow" },
|
||||
EDITOR: { command: "focusEditor", description: "Existing editor focus/context workflow" },
|
||||
};
|
||||
|
||||
function prefix(identifier) {
|
||||
return identifier.split(/_(?:OT|MT)_/u, 1)[0];
|
||||
@@ -29,6 +37,18 @@ function classify(unit) {
|
||||
return ["SERVER_NATIVE", "background Blender operator job with bounded typed properties"];
|
||||
}
|
||||
|
||||
function browserActionGroup(unit) {
|
||||
const identifier = String(unit.identifier ?? "");
|
||||
const prefix = identifier.split(/_(?:OT|MT)_/u, 1)[0];
|
||||
const hasToken = (tokens) => tokens.some((token) => new RegExp(`(?:^|[_-])${token}(?:$|[_-])`, "iu").test(identifier));
|
||||
if (hasToken(["open", "load", "read", "import", "browse", "directory", "filepath", "find_missing", "recent"])) return "OPEN";
|
||||
if (hasToken(["save", "write", "export", "pack", "unpack"])) return "SAVE";
|
||||
if (prefix === "WORKSPACE" || prefix === "SCREEN" || hasToken(["window", "workspace", "screen", "area", "region"])) return "WORKSPACE";
|
||||
if (hasToken(["select", "deselect", "box_select", "lasso", "circle_select", "pick"])) return "SELECTION";
|
||||
if (prefix === "VIEW2D" || prefix === "VIEW3D" || prefix === "GIZMOGROUP" || hasToken(["view", "zoom", "pan", "orbit", "navigate", "cursor", "gizmo", "frame"])) return "VIEW";
|
||||
return "EDITOR";
|
||||
}
|
||||
|
||||
const entries = inventory.units.map((unit) => {
|
||||
const [route, reason] = classify(unit);
|
||||
return {
|
||||
@@ -38,6 +58,8 @@ const entries = inventory.units.map((unit) => {
|
||||
family: unit.family,
|
||||
route,
|
||||
reason,
|
||||
...(route === "SERVER_NATIVE" ? { httpRoute: "/api/server-operator-job" } : {}),
|
||||
...(route === "BROWSER_EQUIVALENT" ? { browserActionGroup: browserActionGroup(unit) } : {}),
|
||||
evidenceStatus: verified.has(unit.id) ? "VERIFIED" : "UNVERIFIED",
|
||||
locations: unit.locations,
|
||||
};
|
||||
@@ -64,6 +86,16 @@ const output = {
|
||||
BROWSER_EQUIVALENT: "browser-native replacement for desktop UI/context",
|
||||
UNAVAILABLE: "platform capability has no Web equivalent",
|
||||
},
|
||||
browserActionGroups,
|
||||
runtimeAudit: {
|
||||
schemaVersion: 1,
|
||||
command: "featureRouteRuntimeAudit",
|
||||
status: "STATIC_DECLARATION",
|
||||
webMain: { policy: "every web-command entry must be present in web-engine capabilities" },
|
||||
serverNative: { httpRoute: "/api/server-operator-job", policy: "every SERVER_NATIVE entry declares the bounded HTTP provider" },
|
||||
browserEquivalent: { groups: Object.keys(browserActionGroups), policy: "every BROWSER_EQUIVALENT entry declares an actionable browser group" },
|
||||
behavioralEvidence: { separate: true, policy: "behavioral evidence remains independent from route assignment" },
|
||||
},
|
||||
entries,
|
||||
};
|
||||
const outputPath = path.join(root, "web/app/src/capabilities/blender-feature-route-catalog.json");
|
||||
|
||||
165
tools/web/server-operator-job-server.mjs
Normal file
165
tools/web/server-operator-job-server.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import http from "node:http";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { startBoundedServerJobProcess, cancelServerJobProcess } from "./server-job-process.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const RUNNER = path.join(ROOT, "tools/web/server-operator-job.py");
|
||||
const MAGIC = Buffer.from("WBJ1");
|
||||
const MAX_ENVELOPE = 513 * 1024 * 1024;
|
||||
const jobs = new Map();
|
||||
const operatorSchemaCache = new Map();
|
||||
|
||||
function fail(message, status = 400) { const error = new Error(message); error.statusCode = status; throw error; }
|
||||
function digest(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
|
||||
function stableJSON(value) {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
}
|
||||
function validateBindings(request) {
|
||||
const propertiesSha256 = digest(Buffer.from(stableJSON(request.properties ?? {})));
|
||||
if (propertiesSha256 !== request.propertiesSha256) fail("SERVER_OPERATOR_HTTP_PROPERTIES_HASH_MISMATCH", 422);
|
||||
const { requestSha256, ...unsigned } = request;
|
||||
const canonical = stableJSON({ ...unsigned, context: unsigned.context ?? "BACKGROUND" });
|
||||
if (digest(Buffer.from(canonical)) !== requestSha256) fail("SERVER_OPERATOR_HTTP_REQUEST_HASH_MISMATCH", 422);
|
||||
if (/^(?:wm|window|screen|file|outliner|view3d)\./iu.test(String(request.operator ?? ""))) fail("SERVER_OPERATOR_CONTEXT_BLOCKED", 422);
|
||||
}
|
||||
function readEnvelope(buffer) {
|
||||
if (buffer.length < 16 || !buffer.subarray(0, 4).equals(MAGIC)) fail("SERVER_OPERATOR_HTTP_INVALID_ENVELOPE");
|
||||
const requestBytes = buffer.readUInt32LE(8);
|
||||
const sourceBytes = buffer.readUInt32LE(12);
|
||||
if (buffer.length > MAX_ENVELOPE || requestBytes < 1 || sourceBytes < 1 || 16 + requestBytes + sourceBytes !== buffer.length) fail("SERVER_OPERATOR_HTTP_INVALID_LENGTH");
|
||||
const request = JSON.parse(buffer.subarray(16, 16 + requestBytes).toString("utf8"));
|
||||
const source = buffer.subarray(16 + requestBytes);
|
||||
if (source.length !== request.sourceBlendByteLength || digest(source) !== request.sourceBlendSha256) fail("SERVER_OPERATOR_HTTP_SOURCE_HASH_MISMATCH", 422);
|
||||
validateBindings(request);
|
||||
return { request, source };
|
||||
}
|
||||
function writeJson(response, status, value) { response.statusCode = status; response.setHeader("Content-Type", "application/json"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(value)); }
|
||||
function collect(request, maxBytes) {
|
||||
return new Promise((resolve, reject) => { const chunks = []; let total = 0; request.on("data", (chunk) => { total += chunk.length; if (total > maxBytes) { reject(Object.assign(new Error("SERVER_OPERATOR_HTTP_BODY_TOO_LARGE"), { statusCode: 413 })); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => resolve(Buffer.concat(chunks))); request.on("error", reject); });
|
||||
}
|
||||
function pythonPayload(request, sourcePath, workspaceRoot, blenderBinary) { return JSON.stringify({ action: "submit", request, sourceBlendPath: sourcePath, workspaceRoot, blenderBinary }); }
|
||||
|
||||
async function describeServerOperator(operator, options) {
|
||||
const cachePrefix = `${options.blenderBinary ?? "blender"}:${operator}`;
|
||||
const cached = [...operatorSchemaCache.entries()].find(([key]) => key.startsWith(`${cachePrefix}:`));
|
||||
if (cached) return cached[1];
|
||||
const workspace = fs.mkdtempSync(path.join(options.workspaceRoot ?? os.tmpdir(), "server-operator-describe-"));
|
||||
try {
|
||||
const handle = startBoundedServerJobProcess(options.pythonBinary ?? "python3", [RUNNER], { cwd: workspace, timeoutMs: options.describeTimeoutMs ?? 120000, stdio: ["pipe", "pipe", "pipe"], maxOutputBytes: 2 * 1024 * 1024 });
|
||||
const chunks = [];
|
||||
handle.child.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
handle.child.stdin.end(JSON.stringify({ action: "describe", operator, blenderBinary: options.blenderBinary ?? "blender" }));
|
||||
await handle.completion;
|
||||
const output = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!output) fail("SERVER_OPERATOR_SCHEMA_EMPTY", 502);
|
||||
const result = JSON.parse(output);
|
||||
if (result.status === "FAILED") fail(result.errorMessage ?? "SERVER_OPERATOR_SCHEMA_FAILED", 422);
|
||||
const buildKey = result.blenderBuild?.buildSha256 ?? "unknown";
|
||||
operatorSchemaCache.set(`${cachePrefix}:${buildKey}`, result);
|
||||
return result;
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function finishJob(entry) {
|
||||
if (entry.result) return entry.result;
|
||||
await entry.handle.completion;
|
||||
const resultPath = path.join(entry.workspace, "result.json");
|
||||
const result = fs.existsSync(resultPath) ? JSON.parse(fs.readFileSync(resultPath, "utf8")) : { schemaVersion: 1, route: "SERVER_NATIVE", jobId: entry.request.jobId, status: "FAILED", requestSha256: entry.request.requestSha256, resultSha256: digest(Buffer.from("")), errorMessage: "SERVER_OPERATOR_PROCESS_FAILED" };
|
||||
if (result.status === "SUCCEEDED") {
|
||||
const blendPath = path.join(entry.workspace, "result.blend");
|
||||
if (!fs.existsSync(blendPath)) {
|
||||
result.status = "FAILED";
|
||||
result.errorMessage = "SERVER_OPERATOR_RESULT_BLEND_MISSING";
|
||||
}
|
||||
else result.resultBlendBase64 = fs.readFileSync(blendPath).toString("base64");
|
||||
}
|
||||
entry.result = result;
|
||||
if (entry.workspace) {
|
||||
fs.rmSync(entry.workspace, { recursive: true, force: true });
|
||||
entry.workspace = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handle(request, response, options) {
|
||||
const pathname = (request.url ?? "/").split("?", 1)[0];
|
||||
if (pathname !== (options.path ?? "/api/server-operator-job")) return false;
|
||||
const headerAction = request.headers["x-server-operator-action"];
|
||||
const action = headerAction ?? ((new URL(request.url, "http://vite.local")).searchParams.has("jobId") ? "read" : "submit");
|
||||
try {
|
||||
if (action === "describe") {
|
||||
const body = JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}");
|
||||
const operator = String(body.operator ?? "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(operator) || Buffer.byteLength(operator) > 128) fail("SERVER_OPERATOR_SCHEMA_OPERATOR_INVALID");
|
||||
const result = await describeServerOperator(operator, options);
|
||||
writeJson(response, 200, { status: { ready: true }, result });
|
||||
return true;
|
||||
}
|
||||
if (action === "submit") {
|
||||
const { request: jobRequest, source } = readEnvelope(await collect(request, MAX_ENVELOPE));
|
||||
const workspace = fs.mkdtempSync(path.join(options.workspaceRoot ?? os.tmpdir(), "server-operator-http-"));
|
||||
const sourcePath = path.join(workspace, "source.blend");
|
||||
fs.writeFileSync(sourcePath, source, { mode: 0o600 });
|
||||
const handle = startBoundedServerJobProcess(options.pythonBinary ?? "python3", [RUNNER], { cwd: workspace, timeoutMs: jobRequest.timeoutMs, stdio: ["pipe", "ignore", "pipe"], maxOutputBytes: 8 * 1024 * 1024 });
|
||||
const entry = { request: jobRequest, workspace, handle, result: null };
|
||||
jobs.set(jobRequest.jobId, entry);
|
||||
handle.child.stdin.write(pythonPayload(jobRequest, sourcePath, workspace, options.blenderBinary ?? "blender"));
|
||||
handle.child.stdin.end();
|
||||
const result = await finishJob(entry);
|
||||
writeJson(response, 200, { status: { ready: true }, result });
|
||||
return true;
|
||||
}
|
||||
const body = request.method === "GET" ? {} : JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}");
|
||||
const url = new URL(request.url, "http://vite.local");
|
||||
const jobId = String(body.jobId ?? url.searchParams.get("jobId") ?? "");
|
||||
const requestSha256 = String(body.requestSha256 ?? url.searchParams.get("requestSha256") ?? "");
|
||||
const entry = jobs.get(jobId);
|
||||
if (!entry || entry.request.requestSha256 !== requestSha256) fail("SERVER_OPERATOR_HTTP_JOB_NOT_FOUND", 404);
|
||||
if (action === "cancel") {
|
||||
if (!entry.result) await cancelServerJobProcess(entry.handle, async () => {
|
||||
if (entry.workspace) {
|
||||
fs.rmSync(entry.workspace, { recursive: true, force: true });
|
||||
entry.workspace = null;
|
||||
}
|
||||
}, { graceMs: 500 });
|
||||
const result = entry.result ?? { schemaVersion: 1, route: "SERVER_NATIVE", jobId, status: "CANCELLED", sourceBlendSha256: entry.request.sourceBlendSha256, sourceRevision: entry.request.sourceRevision, blenderBuild: entry.request.blenderBuild, propertiesSha256: entry.request.propertiesSha256, requestSha256, resultSha256: digest(Buffer.from("CANCELLED")) };
|
||||
entry.result = { ...result, status: "CANCELLED" };
|
||||
writeJson(response, 200, { status: { ready: true }, result: entry.result });
|
||||
return true;
|
||||
}
|
||||
const result = entry.result ?? { schemaVersion: 1, route: "SERVER_NATIVE", jobId, status: "RUNNING", sourceBlendSha256: entry.request.sourceBlendSha256, sourceRevision: entry.request.sourceRevision, blenderBuild: entry.request.blenderBuild, propertiesSha256: entry.request.propertiesSha256, requestSha256, resultSha256: digest(Buffer.from("RUNNING")) };
|
||||
writeJson(response, 200, { status: { ready: true }, result });
|
||||
return true;
|
||||
} catch (error) { writeJson(response, error.statusCode ?? 500, { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error.message ?? error) } }); return true; }
|
||||
}
|
||||
|
||||
export function serverOperatorJobProvider(options = {}) {
|
||||
return { name: "server-operator-http-provider", configureServer(server) { server.middlewares.use((request, response, next) => { void handle(request, response, options).then((handled) => { if (!handled) next(); }).catch(() => next()); }); }, configurePreviewServer(server) { server.middlewares.use((request, response, next) => { void handle(request, response, options).then((handled) => { if (!handled) next(); }).catch(() => next()); }); } };
|
||||
}
|
||||
|
||||
export { handle as handleServerOperatorJobRequest };
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const port = Number.parseInt(process.env.SERVER_OPERATOR_PORT ?? "8787", 10);
|
||||
const host = process.env.SERVER_OPERATOR_HOST ?? "127.0.0.1";
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("SERVER_OPERATOR_PORT_INVALID");
|
||||
const options = {
|
||||
blenderBinary: process.env.BLENDER_BIN ?? "blender",
|
||||
pythonBinary: process.env.PYTHON_BIN ?? "python3",
|
||||
workspaceRoot: process.env.SERVER_OPERATOR_WORKSPACE_ROOT,
|
||||
};
|
||||
http.createServer((request, response) => {
|
||||
void handle(request, response, options).then((handled) => {
|
||||
if (!handled) writeJson(response, 404, { status: { ready: false }, result: { status: "FAILED", errorMessage: "NOT_FOUND" } });
|
||||
}).catch((error) => writeJson(response, 500, { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error?.message ?? error) } }));
|
||||
}).listen(port, host, () => process.stdout.write(`server-operator-provider-ready http://${host}:${port}/api/server-operator-job\n`));
|
||||
}
|
||||
@@ -21,6 +21,7 @@ ROUTE = "SERVER_NATIVE"
|
||||
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
JOB_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
|
||||
BLOCKED_OPERATOR_PREFIXES = ("wm.", "window.", "screen.", "file.", "outliner.", "view3d.")
|
||||
|
||||
|
||||
def canonical(value):
|
||||
@@ -47,6 +48,8 @@ def validate_request(request):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: sourceRevision is invalid")
|
||||
if not isinstance(request.get("operator"), str) or not 1 <= len(request["operator"].encode()) <= 128 or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", request["operator"]):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: operator is invalid")
|
||||
if request["operator"].lower().startswith(BLOCKED_OPERATOR_PREFIXES):
|
||||
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser operator")
|
||||
props = request.get("properties")
|
||||
if not isinstance(props, dict) or len(props) > 128:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the budget")
|
||||
@@ -76,32 +79,165 @@ def _limit_memory(memory_bytes):
|
||||
pass
|
||||
|
||||
|
||||
def _blender_script(request, source_path, result_path):
|
||||
return """import bpy, json, os, traceback\nrequest=json.load(open(%r, encoding='utf-8'))\nresult={'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}\ntry:\n if bpy.context.window is not None or bpy.context.area is not None or bpy.context.region is not None:\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')\n source=%r\n if source:\n bpy.ops.wm.open_mainfile(filepath=source, load_ui=False)\n target=bpy.ops\n for part in request['operator'].split('.'):\n target=getattr(target, part)\n if not target.poll():\n raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context')\n result['result']=target(**request['properties'])\n result['status']='SUCCEEDED'\nexcept Exception as error:\n result['errorMessage']=str(error)\n result['traceback']=traceback.format_exc(limit=3)\nresult['resultSha256']='%s' % __import__('hashlib').sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest()\njson.dump(result, open(%r, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))\n""" % (str(request.get("_requestPath", "")), source_path or "", digest("result"), str(result_path))
|
||||
def _blender_script(request, source_path, result_path, result_blend_path):
|
||||
request_literal = json.dumps(str(request.get("_requestPath", "")))
|
||||
source_literal = json.dumps(source_path or "")
|
||||
result_literal = json.dumps(str(result_path))
|
||||
blend_literal = json.dumps(str(result_blend_path))
|
||||
return f"""import bpy, json, traceback, hashlib
|
||||
request=json.load(open({request_literal}, encoding='utf-8'))
|
||||
result={{'schemaVersion':1,'route':'SERVER_NATIVE','jobId':request['jobId'],'status':'FAILED','sourceBlendSha256':request['sourceBlendSha256'],'sourceRevision':request['sourceRevision'],'blenderBuild':request['blenderBuild'],'propertiesSha256':request['propertiesSha256'],'requestSha256':request['requestSha256']}}
|
||||
try:
|
||||
if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')):
|
||||
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')
|
||||
source={source_literal}
|
||||
if source:
|
||||
bpy.ops.wm.open_mainfile(filepath=source, load_ui=False)
|
||||
target=bpy.ops
|
||||
for part in request['operator'].split('.'):
|
||||
target=getattr(target, part)
|
||||
if not target.poll():
|
||||
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED: operator poll failed in background context')
|
||||
allowed={{}}
|
||||
for prop in target.get_rna_type().properties:
|
||||
kind=str(getattr(prop, 'type', '')).upper()
|
||||
if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'):
|
||||
continue
|
||||
allowed[prop.identifier]=kind
|
||||
for key, value in request['properties'].items():
|
||||
kind=allowed.get(key)
|
||||
if kind is None:
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_BLOCKED: ' + key)
|
||||
if kind == 'BOOLEAN' and not isinstance(value, bool):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
if kind in ('INT', 'FLOAT', 'DOUBLE') and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
if kind in ('ENUM', 'STRING') and not isinstance(value, str):
|
||||
raise RuntimeError('SERVER_OPERATOR_PROPERTY_TYPE: ' + key)
|
||||
operator_result=target(**request['properties'])
|
||||
result['result']=sorted(str(item) for item in operator_result) if isinstance(operator_result, set) else operator_result
|
||||
bpy.ops.wm.save_as_mainfile(filepath={blend_literal}, copy=True)
|
||||
result['status']='SUCCEEDED'
|
||||
except Exception as error:
|
||||
result['errorMessage']=str(error)
|
||||
result['traceback']=traceback.format_exc(limit=3)
|
||||
result['resultSha256']=hashlib.sha256(json.dumps(result.get('result'), sort_keys=True, separators=(',',':')).encode()).hexdigest()
|
||||
json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))
|
||||
"""
|
||||
|
||||
|
||||
def _blender_describe_script(operator, result_path):
|
||||
operator_literal = json.dumps(operator)
|
||||
result_literal = json.dumps(str(result_path))
|
||||
return f"""import bpy, json, traceback, hashlib
|
||||
result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'FAILED'}}
|
||||
try:
|
||||
target=bpy.ops
|
||||
for part in {operator_literal}.split('.'):
|
||||
target=getattr(target, part)
|
||||
if not bpy.app.background or any(getattr(bpy.context, name, None) is not None for name in ('area', 'region')) or not target.poll():
|
||||
raise RuntimeError('SERVER_OPERATOR_CONTEXT_BLOCKED')
|
||||
rna=target.get_rna_type()
|
||||
properties=[]
|
||||
for prop in rna.properties:
|
||||
kind=str(getattr(prop, 'type', '')).upper()
|
||||
if prop.identifier == 'rna_type' or getattr(prop, 'array_length', 0) not in (0, 1) or kind not in ('BOOLEAN', 'INT', 'FLOAT', 'DOUBLE', 'ENUM', 'STRING'):
|
||||
continue
|
||||
type_name='boolean' if kind == 'BOOLEAN' else 'number' if kind in ('INT', 'FLOAT', 'DOUBLE') else 'enum' if kind == 'ENUM' else 'string'
|
||||
spec={{'name':prop.identifier,'type':type_name,'required':bool(getattr(prop, 'is_required', False))}}
|
||||
label=getattr(prop, 'name', None)
|
||||
description=getattr(prop, 'description', None)
|
||||
if label: spec['label']=str(label)
|
||||
if description: spec['description']=str(description)
|
||||
try:
|
||||
default=getattr(prop, 'default', None)
|
||||
if isinstance(default, (bool, int, float, str)):
|
||||
spec['defaultValue']=default
|
||||
except Exception: pass
|
||||
for key, source in (('min','hard_min'),('max','hard_max')):
|
||||
try:
|
||||
value=getattr(prop, source, None)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool): spec[key]=value
|
||||
except Exception: pass
|
||||
if kind == 'ENUM':
|
||||
items=[]
|
||||
try:
|
||||
for item in prop.enum_items:
|
||||
items.append({{'value':str(item.identifier),'label':str(getattr(item, 'name', item.identifier))}})
|
||||
except Exception: pass
|
||||
if items: spec['enumItems']=items
|
||||
properties.append(spec)
|
||||
build_hash=str(getattr(bpy.app, 'build_hash', '') or getattr(bpy.app, 'version_string', 'unknown'))
|
||||
result={{'schemaVersion':1,'route':'SERVER_NATIVE','status':'SUCCEEDED','operator':{{'id':{operator_literal},'label':str(getattr(rna, 'name', {operator_literal})),'description':str(getattr(rna, 'description', '') or ''),'family':{operator_literal}.split('.', 1)[0],'context':'main','backgroundSafe':True,'properties':properties}},'blenderBuild':{{'version':str(getattr(bpy.app, 'version_string', 'unknown')),'buildSha256':hashlib.sha256(build_hash.encode()).hexdigest()}}}}
|
||||
except Exception as error:
|
||||
result['errorMessage']=str(error)
|
||||
result['traceback']=traceback.format_exc(limit=3)
|
||||
json.dump(result, open({result_literal}, 'w', encoding='utf-8'), sort_keys=True, separators=(',',':'))
|
||||
"""
|
||||
|
||||
|
||||
def describe_server_operator(operator, blender_binary="blender", workspace_root=None):
|
||||
if not isinstance(operator, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", operator):
|
||||
raise ValueError("SERVER_OPERATOR_SCHEMA_OPERATOR_INVALID")
|
||||
if operator.lower().startswith(BLOCKED_OPERATOR_PREFIXES):
|
||||
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED")
|
||||
owned_workspace = workspace_root is None
|
||||
temporary_workspace = tempfile.TemporaryDirectory(prefix="server-operator-describe-") if owned_workspace else None
|
||||
try:
|
||||
root = Path(temporary_workspace.name if temporary_workspace else workspace_root)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
result_path = root / "describe.json"
|
||||
script_path = root / "describe.py"
|
||||
script_path.write_text(_blender_describe_script(operator, result_path), encoding="utf-8")
|
||||
completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=120, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
|
||||
if completed.returncode != 0 and not result_path.exists():
|
||||
raise ValueError(completed.stderr.decode(errors="replace")[-4096:] or "SERVER_OPERATOR_SCHEMA_FAILED")
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
if result.get("status") != "SUCCEEDED":
|
||||
raise ValueError(result.get("errorMessage", "SERVER_OPERATOR_SCHEMA_FAILED"))
|
||||
result.pop("status", None)
|
||||
return result
|
||||
finally:
|
||||
if temporary_workspace:
|
||||
temporary_workspace.cleanup()
|
||||
|
||||
|
||||
def run_server_operator_job(request, blender_binary="blender", source_blend_path=None, workspace_root=None):
|
||||
validate_request(request)
|
||||
with tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-", dir=workspace_root) as workspace:
|
||||
root = Path(workspace)
|
||||
owned_workspace = workspace_root is None
|
||||
temporary_workspace = tempfile.TemporaryDirectory(prefix=f"server-operator-{request['jobId']}-") if owned_workspace else None
|
||||
try:
|
||||
root = Path(temporary_workspace.name if temporary_workspace else workspace_root)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
request_path = root / "request.json"
|
||||
result_path = root / "result.json"
|
||||
result_blend_path = root / "result.blend"
|
||||
request_with_path = dict(request, _requestPath=str(request_path))
|
||||
request_path.write_text(canonical(request_with_path), encoding="utf-8")
|
||||
script_path = root / "job.py"
|
||||
script_path.write_text(_blender_script(request_with_path, source_blend_path, result_path), encoding="utf-8")
|
||||
script_path.write_text(_blender_script(request_with_path, source_blend_path, result_path, result_blend_path), encoding="utf-8")
|
||||
completed = subprocess.run([blender_binary, "--background", "--factory-startup", "--python", str(script_path)], cwd=str(root), timeout=request["timeoutMs"] / 1000, preexec_fn=lambda: _limit_memory(request["memoryBytes"]), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
|
||||
if completed.returncode != 0 and not result_path.exists():
|
||||
return dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:])
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
result["resultSha256"] = digest(canonical(result.get("result")))
|
||||
result = dict(request, status="FAILED", resultSha256=digest(""), errorMessage=completed.stderr.decode(errors="replace")[-4096:])
|
||||
else:
|
||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
result["resultSha256"] = digest(canonical(result.get("result")))
|
||||
if result.get("status") == "SUCCEEDED" and result_blend_path.exists():
|
||||
result["resultBlendByteLength"] = result_blend_path.stat().st_size
|
||||
result["resultBlendSha256"] = hashlib.sha256(result_blend_path.read_bytes()).hexdigest()
|
||||
(root / "result.json").write_text(canonical(result), encoding="utf-8")
|
||||
return result
|
||||
finally:
|
||||
if temporary_workspace:
|
||||
temporary_workspace.cleanup()
|
||||
|
||||
|
||||
def main():
|
||||
envelope = json.load(sys.stdin)
|
||||
action = envelope.get("action", "submit")
|
||||
if action == "submit":
|
||||
if action == "describe":
|
||||
result = describe_server_operator(envelope["operator"], envelope.get("blenderBinary", "blender"), envelope.get("workspaceRoot"))
|
||||
elif action == "submit":
|
||||
result = run_server_operator_job(envelope["request"], envelope.get("blenderBinary", "blender"), envelope.get("sourceBlendPath"), envelope.get("workspaceRoot"))
|
||||
elif action == "cancel":
|
||||
result = {"schemaVersion": SCHEMA, "route": ROUTE, "jobId": envelope["jobId"], "status": "CANCELLED", "requestSha256": envelope["requestSha256"], "resultSha256": digest("CANCELLED")}
|
||||
|
||||
Reference in New Issue
Block a user