feat(web): classify and route complete Blender inventory
This commit is contained in:
14
tools/web/check-blender-feature-route-catalog.mjs
Normal file
14
tools/web/check-blender-feature-route-catalog.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const catalog = JSON.parse(fs.readFileSync(new URL("../../web/app/src/capabilities/blender-feature-route-catalog.json", import.meta.url), "utf8"));
|
||||
const inventory = JSON.parse(fs.readFileSync(new URL("../../tests/golden/WBV2/dynamic-inventory.json", import.meta.url), "utf8"));
|
||||
assert.equal(catalog.schemaVersion, 1);
|
||||
assert.equal(catalog.unitCount, inventory.summary.unitCount);
|
||||
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)));
|
||||
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`);
|
||||
16
tools/web/check-web-engine-command-registry.mjs
Normal file
16
tools/web/check-web-engine-command-registry.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 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");
|
||||
const registered = new Set([...registry.matchAll(/^ ([A-Za-z0-9]+): \{/gmu)].map((match) => match[1]));
|
||||
registered.delete("listBlenderOperators");
|
||||
registered.delete("invokeBlenderOperator");
|
||||
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`);
|
||||
72
tools/web/generate-blender-feature-route-catalog.mjs
Normal file
72
tools/web/generate-blender-feature-route-catalog.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
|
||||
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"]);
|
||||
|
||||
function prefix(identifier) {
|
||||
return identifier.split(/_(?:OT|MT)_/u, 1)[0];
|
||||
}
|
||||
|
||||
function classify(unit) {
|
||||
if (unit.kind === "web-command") return ["WEB_MAIN", "typed WebEngine command"];
|
||||
if (unit.kind === "node") return ["WEB_MAIN", "shared native node graph route"];
|
||||
if (unit.kind === "rna-struct") return ["WEB_MAIN", "bounded RNA reflection route"];
|
||||
if (unit.kind === "modifier-source") {
|
||||
return unit.identifier === "MOD_none" || unit.identifier === "MOD_ui_common"
|
||||
? ["BROWSER_EQUIVALENT", "non-runtime modifier sentinel or desktop UI helper"]
|
||||
: ["WEB_MAIN", "metadata-driven native modifier stack route"];
|
||||
}
|
||||
if (verified.has(unit.id)) return ["WEB_MAIN", "existing exact native Main binding"];
|
||||
const operatorPrefix = prefix(unit.identifier);
|
||||
if (operatorPrefix === "WM" && /_XR_/u.test(unit.identifier)) return ["UNAVAILABLE", "XR window-manager context is outside the Web runtime"];
|
||||
if (browserPrefixes.has(operatorPrefix)) return ["BROWSER_EQUIVALENT", "browser-owned editor, window, view or file interaction"];
|
||||
return ["SERVER_NATIVE", "background Blender operator job with bounded typed properties"];
|
||||
}
|
||||
|
||||
const entries = inventory.units.map((unit) => {
|
||||
const [route, reason] = classify(unit);
|
||||
return {
|
||||
id: unit.id,
|
||||
kind: unit.kind,
|
||||
identifier: unit.identifier,
|
||||
family: unit.family,
|
||||
route,
|
||||
reason,
|
||||
evidenceStatus: verified.has(unit.id) ? "VERIFIED" : "UNVERIFIED",
|
||||
locations: unit.locations,
|
||||
};
|
||||
});
|
||||
const counts = Object.fromEntries([...new Set(entries.map((entry) => entry.route))].sort().map((route) => [route, entries.filter((entry) => entry.route === route).length]));
|
||||
const verifiedUnits = entries.filter((entry) => entry.evidenceStatus === "VERIFIED").length;
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
sourceInventory: "tests/golden/WBV2/dynamic-inventory.json",
|
||||
sourceTreeSha256: inventory.sourceFiles?.treeSha256 ?? inventory.authority?.sourceTreeSha256 ?? null,
|
||||
unitCount: entries.length,
|
||||
classifiedCount: entries.length,
|
||||
unclassifiedCount: 0,
|
||||
counts,
|
||||
behavioralEvidence: {
|
||||
status: verifiedUnits === entries.length ? "VERIFIED" : "PARTIAL",
|
||||
verifiedUnits,
|
||||
unverifiedUnits: entries.length - verifiedUnits,
|
||||
policy: "route classification does not replace exact behavioral evidence",
|
||||
},
|
||||
routes: {
|
||||
WEB_MAIN: "typed native Main/WASM or shared reflection route",
|
||||
SERVER_NATIVE: "bounded Blender background operator job",
|
||||
BROWSER_EQUIVALENT: "browser-native replacement for desktop UI/context",
|
||||
UNAVAILABLE: "platform capability has no Web equivalent",
|
||||
},
|
||||
entries,
|
||||
};
|
||||
const outputPath = path.join(root, "web/app/src/capabilities/blender-feature-route-catalog.json");
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`blender-feature-routes-written units=${entries.length} routes=${JSON.stringify(counts)} output=${path.relative(root, outputPath)}\n`);
|
||||
@@ -115,3 +115,19 @@ export async function cancelServerJobProcess(handle, cleanup, options = {}) {
|
||||
handle.receipt.orphanCount = treeExited ? 0 : 1;
|
||||
return Object.freeze({ ...result, treeSignal: handle.receipt.treeSignal, cleanupCount: handle.receipt.cleanupCount, orphanCount: handle.receipt.orphanCount });
|
||||
}
|
||||
|
||||
export function validateServerJobBudget(options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? 8 * 1024 * 1024;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 10 * 60 * 1000) invalid("timeoutMs is outside the bounded server-job budget");
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1024 || maxOutputBytes > 64 * 1024 * 1024) invalid("maxOutputBytes is outside the bounded server-job budget");
|
||||
return Object.freeze({ timeoutMs, maxOutputBytes });
|
||||
}
|
||||
|
||||
export function startBoundedServerJobProcess(command, args = [], options = {}) {
|
||||
const budget = validateServerJobBudget(options);
|
||||
const handle = startServerJobProcess(command, args, options);
|
||||
const timeout = setTimeout(() => { void cancelServerJobProcess(handle, options.cleanup ?? (async () => {}), { graceMs: options.graceMs ?? 500 }); }, budget.timeoutMs);
|
||||
handle.completion.finally(() => clearTimeout(timeout));
|
||||
return handle;
|
||||
}
|
||||
|
||||
120
tools/web/server-operator-job.py
Normal file
120
tools/web/server-operator-job.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded SERVER_NATIVE Blender operator runner.
|
||||
|
||||
The runner owns a temporary workspace, invokes Blender in background mode, and
|
||||
binds the result to the request/source/property hashes. UI, window and
|
||||
file-browser contexts are intentionally unavailable.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import resource
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = 1
|
||||
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}$")
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_request(request):
|
||||
if not isinstance(request, dict) or request.get("schemaVersion") != SCHEMA or request.get("route") != ROUTE:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: schema or route mismatch")
|
||||
required = {"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "requestSha256"}
|
||||
if set(request) - required - {"context"}:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: undeclared request field")
|
||||
if not JOB_ID.fullmatch(str(request.get("jobId", ""))):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: jobId is invalid")
|
||||
if not all(isinstance(request.get(name), str) and SHA256.fullmatch(request[name]) for name in ("sourceBlendSha256", "propertiesSha256", "requestSha256")):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: hash binding is invalid")
|
||||
if not isinstance(request.get("sourceBlendByteLength"), int) or not 1 <= request["sourceBlendByteLength"] <= 512 * 1024 * 1024:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: source blend exceeds the budget")
|
||||
if not isinstance(request.get("sourceRevision"), int) or request["sourceRevision"] < 0:
|
||||
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")
|
||||
props = request.get("properties")
|
||||
if not isinstance(props, dict) or len(props) > 128:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the budget")
|
||||
for key, value in props.items():
|
||||
if not KEY.fullmatch(key) or not ((isinstance(value, str) and len(value.encode()) <= 4096) or isinstance(value, (bool, int, float)) and not isinstance(value, bool) or isinstance(value, bool)):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties must be typed scalar values")
|
||||
if len(canonical(props).encode()) > 64 * 1024:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: properties exceed the byte budget")
|
||||
if not isinstance(request.get("blenderBuild"), dict) or not SHA256.fullmatch(str(request["blenderBuild"].get("buildSha256", ""))):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: Blender build binding is invalid")
|
||||
binding = request.get("resultBinding")
|
||||
if not isinstance(binding, dict) or binding.get("kind") not in ("json", "snapshot", "scene-delta") or not KEY.fullmatch(str(binding.get("key", ""))):
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: result binding is invalid")
|
||||
if request.get("context", "BACKGROUND") != "BACKGROUND":
|
||||
raise ValueError("SERVER_OPERATOR_CONTEXT_BLOCKED: UI/window/file-browser contexts are unavailable")
|
||||
if not isinstance(request.get("timeoutMs"), int) or not 1000 <= request["timeoutMs"] <= 600000:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: timeout budget is invalid")
|
||||
if not isinstance(request.get("memoryBytes"), int) or not 64 * 1024 * 1024 <= request["memoryBytes"] <= 4 * 1024 * 1024 * 1024:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: memory budget is invalid")
|
||||
return request
|
||||
|
||||
|
||||
def _limit_memory(memory_bytes):
|
||||
try:
|
||||
resource.setrlimit(resource.RLIMIT_AS, (memory_bytes, memory_bytes))
|
||||
except (OSError, ValueError):
|
||||
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 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)
|
||||
request_path = root / "request.json"
|
||||
result_path = root / "result.json"
|
||||
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")
|
||||
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")))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
envelope = json.load(sys.stdin)
|
||||
action = envelope.get("action", "submit")
|
||||
if 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")}
|
||||
elif action == "read":
|
||||
result = json.loads(Path(envelope["resultPath"]).read_text(encoding="utf-8"))
|
||||
else:
|
||||
raise ValueError("SERVER_OPERATOR_JOB_INVALID: unknown action")
|
||||
print(canonical(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "FAILED", "errorMessage": str(error)}, separators=(",", ":")))
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user