feat(web): complete Blender route runtime execution
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 00:12:32 +08:00
parent e2e81c7c00
commit e9bfef0c5a
35 changed files with 57527 additions and 75 deletions

View File

@@ -15,9 +15,9 @@
| 字段 | 值 |
| --- | --- |
| 里程碑 | WBV2 capability workflow |
| 当前任务 | `WBV2-P1423-001` |
| parent manifest | `tests/golden/WBV2-P1423-000/manifest.json` |
| 任务卡 | [`tasks/WBV2-P1423-001.md`](tasks/WBV2-P1423-001.md) |
| 当前任务 | `WBV2-P1424-001` |
| parent manifest | `tests/golden/WBV2-P1424-000/manifest.json` |
| 任务卡 | [`tasks/WBV2-P1424-001.md`](tasks/WBV2-P1424-001.md) |
| 专项验收 | `node tools/web/check-execution-control-plane.mjs` |
| legacy coverage checkpoint | `M16-GAP-00282` |
| legacy coverage parent | `tests/golden/M16-GAP-00281/manifest.json` |

View File

@@ -4,4 +4,4 @@
- workflow modules: `16/16`
- implementation tasks: `31/31`
- verification: `deferred_until_user_reports`
- nextTask: `NONE`
- nextTask: `WBV2-P1424-000`

View File

@@ -0,0 +1,4 @@
# WBV2-P1424-000 status
- status: `done`
- nextTask: `WBV2-P1424-001`

View File

@@ -0,0 +1,7 @@
# WBV2-P1424-001 status
- status: `implemented`
- workflow modules: `16/16`
- implementation tasks: `34/34`
- verification: `deferred_until_user_reports`
- nextTask: `NONE`

View File

@@ -0,0 +1,6 @@
# WBV2-P1424-000: authorize server operator runtime closure
- task: WBV2-P1424-000
- parent: WBV2-P1423-001
- status: done
- nextTask: WBV2-P1424-001

View File

@@ -0,0 +1,35 @@
# WBV2-P1424-001: complete server operator runtime closure
- task: WBV2-P1424-001
- parent: WBV2-P1424-000
- status: in_progress
- targetImplementationClass: PRODUCT_WORKFLOW
- node tools/web/print-task-context.mjs
## 目标
Close the audited gap between classified feature routes and real browser/server execution.
## 输入与范围
- `tests/golden/WBV2/web-product-backlog.json`
- `web/app/src/capabilities/blender-feature-route-catalog.json`
- each pending task's owned paths
## 验收
- SERVER_NATIVE performs a real bounded HTTP Blender background job and returns a mutated blend
- F3 loads typed operator properties on demand
- browser-equivalent routes invoke existing browser workflows
- verification remains incremental and behavioral evidence stays explicit
## 失败边界
- missing provider, malformed envelope, hash mismatch, timeout, cancellation and context-only operators fail closed
- failed turns remain pending and never publish false completion
## 交付与回滚
- results: `tests/golden/WBV2/product-migration/*-result.json`
- aggregate: `tests/golden/WBV2-P1424-001/product-implementation-report.json`
- rollback: stop the runner and preserve the last completed runtime task

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Activate the final server-operator and browser-equivalent runtime closure."""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from pathlib import Path
from wbv2_runtime import CONTROL_ROOT, QUEUE, ROOT
CURRENT, ROOT_TASK, TASK = "WBV2-P1423-001", "WBV2-P1424-000", "WBV2-P1424-001"
def read(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def write(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def text(path: Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
TASKS = [
{
"id": "SERVER-OPERATOR-HTTP-PROVIDER", "module": "RUNTIME", "title": "Connect server-native routes to an executable HTTP provider",
"commands": ["submitServerOperatorJob", "cancelServerOperatorJob", "readServerOperatorResult"],
"ownedPaths": ["web/protocol/server-operator-job.ts", "web/app/src/engine-client/WebEngineClient.ts", "web/app/src/app/App.tsx", "web/app/vite.config.ts", "tools/web/server-operator-job-server.mjs", "tools/web/server-operator-job.py", "tools/web/server-job-process.mjs"],
"implementation": "Replace the QUEUED-only browser stub with a real same-origin/configurable HTTP provider. Save the current Main to .blend, submit a bounded binary envelope, execute Blender background in an isolated workspace, save and hash the mutated .blend, return it, reopen it in WebEngine, and support cancellation/result reads. Preserve source/build/property/request/result hash bindings and resource budgets.",
},
{
"id": "SERVER-OPERATOR-SCHEMA-PALETTE", "module": "WORKSPACE", "title": "Load native operator RNA schemas on demand in F3",
"commands": ["describeServerOperator", "invokeFeatureRoute"],
"ownedPaths": ["web/protocol/server-operator-job.ts", "web/protocol/blender-operator.ts", "web/app/src/engine-client/WebEngineClient.ts", "web/app/src/app/App.tsx", "tools/web/server-operator-job-server.mjs", "tools/web/server-operator-job.py"],
"implementation": "Query Blender background RNA metadata for a selected SERVER_NATIVE operator, cache the typed boolean/number/enum/string schema by Blender build, render the existing compact property form, and submit a real server job. Unsupported pointer/collection/context properties remain blocked.",
},
{
"id": "BROWSER-EQUIVALENT-RUNTIME", "module": "WORKSPACE", "title": "Make browser-equivalent feature routes actionable",
"commands": ["invokeBrowserEquivalent", "featureRouteRuntimeAudit"],
"ownedPaths": ["web/protocol/blender-operator.ts", "web/app/src/app/App.tsx", "web/app/src/capabilities/blender-feature-route-catalog.json", "tools/web/generate-blender-feature-route-catalog.mjs", "tools/web/check-blender-feature-route-catalog.mjs", "tools/web/check-web-engine-command-registry.mjs", "docs/WEB_FULL_FEATURE_GAP_INVENTORY.md", "my-tools/show_blender_web_scheduler.py"],
"implementation": "Attach explicit browser action groups to BROWSER_EQUIVALENT routes and dispatch them to existing open/save/workspace/selection/view/editor workflows from F3. Publish a static runtime audit proving every WEB_MAIN command is registered, every SERVER_NATIVE entry has the HTTP route, and every BROWSER_EQUIVALENT entry has an actionable browser group; keep behavioral evidence separate.",
},
]
def main() -> int:
backlog_path = CONTROL_ROOT / "web-product-backlog.json"
backlog = read(backlog_path)
existing = {item.get("id") for item in backlog.get("tasks", [])}
for task in TASKS:
item = {**task, "attempts": 0, "status": "pending", "resultPath": f"tests/golden/WBV2/product-migration/{task['id']}-result.json"}
if task["id"] not in existing:
backlog["tasks"].append(item)
else:
next(current for current in backlog["tasks"] if current.get("id") == task["id"]).update(item)
grouped = {}
for item in backlog["tasks"]:
grouped.setdefault(item.get("module"), []).append(item["id"])
for module in backlog["workflowModules"]:
if grouped.get(module["id"]):
module["taskIds"] = list(dict.fromkeys([*module.get("taskIds", []), *grouped[module["id"]]]))
module["status"] = "partial"
backlog.update({"previousQueueTask": CURRENT, "rootTask": ROOT_TASK, "queueTask": TASK, "strategy": "implementation_first_runtime_closure"})
write(backlog_path, backlog)
current_manifest_path = ROOT / "tests/golden" / CURRENT / "manifest.json"
current_manifest = read(current_manifest_path)
current_manifest["nextTask"] = ROOT_TASK
current_status = ROOT / "docs/status" / f"{CURRENT}.md"
if current_status.is_file():
status = re.sub(r"- nextTask: `[^`]+`", f"- nextTask: `{ROOT_TASK}`", current_status.read_text(encoding="utf-8"))
text(current_status, status)
if isinstance(current_manifest.get("artifacts", {}).get("status"), dict):
current_manifest["artifacts"]["status"]["sha256"] = sha(current_status)
write(current_manifest_path, current_manifest)
root_dir = ROOT / "tests/golden" / ROOT_TASK
plan_path = root_dir / "runtime-closure-plan.json"
base_plan = read(ROOT / "tests/golden/WBV2-P1423-000/operator-route-implementation-plan.json")
root_record = {"id": ROOT_TASK, "phase": "1424", "title": "authorize server operator runtime closure", "kind": "enabling", "owner": "PROGRAM", "dependsOn": [CURRENT], "implementationClass": "QUEUE_ONLY", "card": f"docs/tasks/{ROOT_TASK}.md", "commands": [], "status": "done", "ready": False, "queueMutationAllowed": True}
task_record = {"id": TASK, "phase": "1424", "title": "complete server operator runtime closure", "kind": "product-module", "owner": "WEB_PRODUCT", "dependsOn": [ROOT_TASK], "implementationClass": "PRODUCT_WORKFLOW", "card": f"docs/tasks/{TASK}.md", "commands": ["python3 my-tools/run_web_product_migration.py"], "status": "in_progress", "ready": True, "queueMutationAllowed": True, "productBacklog": "tests/golden/WBV2/web-product-backlog.json", "module": {"sourcePaths": list(dict.fromkeys(path for task in TASKS for path in task["ownedPaths"]))}}
write(plan_path, {**base_plan, "planId": "BLENDER_WEB_RUNTIME_CLOSURE_P1424", "status": "ACTIVE_RUNTIME_CLOSURE", "tasks": [*base_plan.get("tasks", []), root_record, task_record]})
text(ROOT / "docs/tasks" / f"{ROOT_TASK}.md", f"# {ROOT_TASK}: authorize server operator runtime closure\n\n- task: {ROOT_TASK}\n- parent: {CURRENT}\n- status: done\n- nextTask: {TASK}\n")
text(ROOT / "docs/tasks" / f"{TASK}.md", f"""# {TASK}: complete server operator runtime closure
- task: {TASK}
- parent: {ROOT_TASK}
- status: in_progress
- targetImplementationClass: PRODUCT_WORKFLOW
- node tools/web/print-task-context.mjs
## 目标
Close the audited gap between classified feature routes and real browser/server execution.
## 输入与范围
- `tests/golden/WBV2/web-product-backlog.json`
- `web/app/src/capabilities/blender-feature-route-catalog.json`
- each pending task's owned paths
## 验收
- SERVER_NATIVE performs a real bounded HTTP Blender background job and returns a mutated blend
- F3 loads typed operator properties on demand
- browser-equivalent routes invoke existing browser workflows
- verification remains incremental and behavioral evidence stays explicit
## 失败边界
- missing provider, malformed envelope, hash mismatch, timeout, cancellation and context-only operators fail closed
- failed turns remain pending and never publish false completion
## 交付与回滚
- results: `tests/golden/WBV2/product-migration/*-result.json`
- aggregate: `tests/golden/{TASK}/product-implementation-report.json`
- rollback: stop the runner and preserve the last completed runtime task
""")
report = root_dir / "runtime-closure-authorization.json"
write(report, {"schemaVersion": 1, "task": ROOT_TASK, "operation": "AUTHORIZE_RUNTIME_CLOSURE", "status": "PASS", "pendingTaskCount": len(TASKS), "nextTask": TASK})
status_path = ROOT / "docs/status" / f"{ROOT_TASK}.md"
text(status_path, f"# {ROOT_TASK} status\n\n- status: `done`\n- nextTask: `{TASK}`\n")
manifest = root_dir / "manifest.json"
write(manifest, {"schemaVersion": 1, "task": ROOT_TASK, "parentTask": CURRENT, "operation": "AUTHORIZE_RUNTIME_CLOSURE", "status": "done", "nextTask": TASK, "artifacts": {"parentManifest": {"path": f"tests/golden/{CURRENT}/manifest.json", "sha256": sha(current_manifest_path)}, "backlog": {"path": str(backlog_path.relative_to(ROOT)), "sha256": sha(backlog_path)}, "plan": {"path": str(plan_path.relative_to(ROOT)), "sha256": sha(plan_path)}}})
queue = QUEUE.read_text(encoding="utf-8")
queue = re.sub(r"\| 当前任务 \| `[^`]+` \|", f"| 当前任务 | `{TASK}` |", queue, count=1)
queue = re.sub(r"\| parent manifest \| `[^`]+` \|", f"| parent manifest | `tests/golden/{ROOT_TASK}/manifest.json` |", queue, count=1, flags=re.IGNORECASE)
queue = re.sub(r"\| 任务卡 \| \[[^\]]+\]\([^\)]+\) \|", f"| 任务卡 | [`tasks/{TASK}.md`](tasks/{TASK}.md) |", queue, count=1)
text(QUEUE, queue)
subprocess.run(["node", "tools/web/generate-v2-task-index.mjs", "--plan-path", str(plan_path.relative_to(ROOT))], cwd=ROOT, check=True)
subprocess.run(["node", "tools/web/check-task-context.mjs", "--task", TASK, "--write"], cwd=ROOT, check=True)
print(f"runtime-closure-phase-activated task={TASK} pending={len(TASKS)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -25,7 +25,7 @@
},
"status": {
"path": "docs/status/WBV2-P1423-001.md",
"sha256": "913a745b47569dd0ffce98be53533e6fab556bc7d3b0e3c785e07d184cd0496d"
"sha256": "9c3d0e2bb3084c37cedf6cca58a42fb81707621ad1b559ab59e51f719c23ca27"
},
"plan": {
"path": "tests/golden/WBV2-P1423-000/operator-route-implementation-plan.json",
@@ -36,5 +36,5 @@
"sha256": "e9e46f56f5538ad9865357aecbf92b475482691f1fb09ee4cf37149a1b6bc0cc"
}
},
"nextTask": null
"nextTask": "WBV2-P1424-000"
}

View File

@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"task": "WBV2-P1424-000",
"parentTask": "WBV2-P1423-001",
"operation": "AUTHORIZE_RUNTIME_CLOSURE",
"status": "done",
"nextTask": "WBV2-P1424-001",
"artifacts": {
"parentManifest": {
"path": "tests/golden/WBV2-P1423-001/manifest.json",
"sha256": "e4cb86e30ec3fe13a890b6497e99f8693ddba78f07d9c71b776d6c6af8e8a8b9"
},
"backlog": {
"path": "tests/golden/WBV2/web-product-backlog.json",
"sha256": "fe9361dfbb0cab8196c004cd044b69fb5cc10cacfb461312eae0f5497d6c4da0"
},
"plan": {
"path": "tests/golden/WBV2-P1424-000/runtime-closure-plan.json",
"sha256": "9d1bcc4b6b25b1926210893bcc767c16259bb4b132a49fd134e3397c7ff7ad53"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"task": "WBV2-P1424-000",
"operation": "AUTHORIZE_RUNTIME_CLOSURE",
"status": "PASS",
"pendingTaskCount": 3,
"nextTask": "WBV2-P1424-001"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"task": "WBV2-P1424-001",
"parentTask": "WBV2-P1424-000",
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "done",
"implementationComplete": true,
"verificationDeferred": true,
"artifacts": {
"parentManifest": {
"path": "tests/golden/WBV2-P1424-000/manifest.json",
"sha256": "46a3ce65d56747da8684af422a1a94540612c767a6dcc42da7939930731399d5"
},
"backlog": {
"path": "tests/golden/WBV2/web-product-backlog.json",
"sha256": "fe9361dfbb0cab8196c004cd044b69fb5cc10cacfb461312eae0f5497d6c4da0"
},
"strategy": {
"path": "docs/WEB_PRODUCT_MIGRATION_STRATEGY.md",
"sha256": "b0086cf81a52c71b9efdd764c005d65de7fe48a8f013ce6405a76486bf2310e6"
},
"report": {
"path": "tests/golden/WBV2-P1424-001/product-implementation-report.json",
"sha256": "ec2c29e2cc26d573e821647d4864e9bda3da77d46dbde27959aea2a2dc78218d"
},
"status": {
"path": "docs/status/WBV2-P1424-001.md",
"sha256": "05f829a182784f0fecc94413c92460ac40875af9042a9739177a14a104550ab3"
},
"plan": {
"path": "tests/golden/WBV2-P1424-000/runtime-closure-plan.json",
"sha256": "9d1bcc4b6b25b1926210893bcc767c16259bb4b132a49fd134e3397c7ff7ad53"
},
"taskContext": {
"path": "tests/golden/WBV2-P1424-001/task-context.json",
"sha256": "397446b249988953596bd36444da908d9f4c70ea86426cc196e63c76507b94aa"
}
},
"nextTask": null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
{
"schemaVersion": 1,
"task": "WBV2-P1424-001",
"parentTask": "WBV2-P1424-000",
"status": "in_progress",
"goal": "Close the audited gap between classified feature routes and real browser/server execution.",
"scope": {
"gap": "workflow:complete server operator runtime closure",
"ownerFamily": "WEB_PRODUCT",
"implementationClass": "PRODUCT_WORKFLOW"
},
"commands": [
"node tools/web/print-task-context.mjs"
],
"inputPaths": [],
"inputMetadata": {
"docs/EXECUTION_QUEUE.md": {
"path": "docs/EXECUTION_QUEUE.md",
"exists": true,
"bytes": 4030,
"mtime_ns": "1788700490472279178",
"sha256": "4d5b719ec73e3b77c88ed07d0e990e754c1c20dbf592e19406ea63dc9e854571"
},
"docs/tasks/WBV2-P1424-001.md": {
"path": "docs/tasks/WBV2-P1424-001.md",
"exists": true,
"bytes": 1210,
"mtime_ns": "1788700490470545655",
"sha256": "7c174f4477a98dd322b3d0b65837e65a391c1e6add75cb9b078d0111cf411bd3"
},
"tests/golden/WBV2-P1424-000/manifest.json": {
"path": "tests/golden/WBV2-P1424-000/manifest.json",
"exists": true,
"bytes": 719,
"mtime_ns": "1788704564365870361",
"sha256": "46a3ce65d56747da8684af422a1a94540612c767a6dcc42da7939930731399d5"
},
"docs/status/WBV2-P1424-000.md": {
"path": "docs/status/WBV2-P1424-000.md",
"exists": true,
"bytes": 71,
"mtime_ns": "1788700490470609833",
"sha256": "47fa6e527c1392ebcfadee459ff1c69e42450a2f57b40d0330ef60bd92bafc05"
}
},
"inputSelection": {
"schemaVersion": 1,
"limits": {
"maxFiles": 12,
"evidenceBytes": 8192,
"contextRemainingBytes": 3874,
"contextRemainingTokens": 967
},
"source": {
"bytes": 6030,
"tokens": 1509
},
"selected": [],
"excluded": [
{
"path": "tests/golden/WBV2/web-product-backlog.json",
"bytes": 41232,
"reason": "EVIDENCE_BYTE_BUDGET"
},
{
"path": "web/app/src/capabilities/blender-feature-route-catalog.json",
"bytes": 1957003,
"reason": "EVIDENCE_BYTE_BUDGET"
},
{
"path": "tests/golden/WBV2/product-migration/*-result.json",
"bytes": null,
"reason": "MISSING_PATH"
},
{
"path": "tests/golden/WBV2-P1424-001/product-implementation-report.json",
"bytes": 44229,
"reason": "EVIDENCE_BYTE_BUDGET"
}
],
"totals": {
"files": 0,
"bytes": 0,
"tokens": 0
}
},
"exitCriteria": [
"focused command exits 0",
"report and manifest are hash-bound",
"failure path does not publish a late result"
],
"nextTask": null,
"sourceDocuments": {
"queue": "docs/EXECUTION_QUEUE.md",
"taskCard": "docs/tasks/WBV2-P1424-001.md",
"taskIndex": "tests/golden/WBV2/task-index.json",
"parentManifest": "tests/golden/WBV2-P1424-000/manifest.json",
"parentStatus": "docs/status/WBV2-P1424-000.md"
},
"readPolicy": {
"required": [
"docs/EXECUTION_QUEUE.md",
"docs/tasks/WBV2-P1424-001.md",
"tests/golden/WBV2-P1424-000/manifest.json",
"docs/status/WBV2-P1424-000.md"
],
"machineOnly": [
"tests/golden/WBV2/task-index.json",
"tests/golden/M15-03A/task-catalog.jsonl"
],
"optionalByNeed": [
"docs/WEB_BLENDER_MODELER_V1_SCOPE.md",
"任务卡列出的精确协议/生产路径",
"任务卡明确命名的长期计划小节"
],
"forbiddenByDefault": [
"完整 CURRENT_EXECUTION_PLAN.md",
"完整 PROJECT_STATUS_AND_NEXT_WORK.md",
"全部 docs/status/*.md",
"完整 parity/WBS 计划",
"README 历史和 test-results/"
]
},
"parent": {
"manifest": {
"schemaVersion": 1,
"task": "WBV2-P1424-000",
"parentTask": "WBV2-P1423-001",
"status": "done",
"nextTask": "WBV2-P1424-001",
"artifactCount": 3
},
"statusSummary": [
"# WBV2-P1424-000 status",
"- status: `done`",
"- nextTask: `WBV2-P1424-001`"
]
},
"budgets": {
"queueBytes": 4096,
"taskBytes": 1210,
"parentManifestBytes": 24576,
"parentStatusBytes": 6144,
"evidenceFiles": 12,
"evidenceBytes": 8192,
"contextTokens": 3500,
"contextEnvelopeTokens": 1024,
"taskLines": 36,
"taskTokens": 303
},
"size": {
"documents": [
{
"path": "docs/EXECUTION_QUEUE.md",
"bytes": 4030,
"lines": 58,
"tokens": 1008
},
{
"path": "docs/tasks/WBV2-P1424-001.md",
"bytes": 1210,
"lines": 36,
"tokens": 303
},
{
"path": "tests/golden/WBV2-P1424-000/manifest.json",
"bytes": 719,
"lines": 23,
"tokens": 180
},
{
"path": "docs/status/WBV2-P1424-000.md",
"bytes": 71,
"lines": 5,
"tokens": 18
}
],
"sourceTokens": 1509,
"evidenceFiles": 0,
"evidenceBytes": 0,
"evidenceTokens": 0,
"totalTokens": 1509,
"reservedEnvelopeTokens": 1024,
"estimatedContextTokens": 2533,
"serializedContextTokens": 598,
"withinBudget": true
}
}

View File

@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"task": "BROWSER-EQUIVALENT-RUNTIME",
"status": "implemented",
"changedFiles": [
"web/protocol/blender-operator.ts",
"web/app/src/app/App.tsx",
"web/app/src/capabilities/blender-feature-route-catalog.json",
"tools/web/generate-blender-feature-route-catalog.mjs",
"tools/web/check-blender-feature-route-catalog.mjs",
"tools/web/check-web-engine-command-registry.mjs",
"tests/golden/WBV2/product-migration/BROWSER-EQUIVALENT-RUNTIME-result.json"
],
"summary": "Attached explicit browser action groups to every BROWSER_EQUIVALENT route, dispatching OPEN/SAVE/WORKSPACE/SELECTION/VIEW/EDITOR groups through existing F3 browser workflows. Added invokeBrowserEquivalent and featureRouteRuntimeAudit protocol/runtime surfaces, regenerated the static catalog, and published registry checks proving WEB_MAIN registration, SERVER_NATIVE HTTP routing, and actionable browser groups while keeping behavioral evidence separate."
}

View File

@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"task": "SERVER-OPERATOR-HTTP-PROVIDER",
"status": "implemented",
"changedFiles": [
"web/protocol/server-operator-job.ts",
"web/app/src/engine-client/WebEngineClient.ts",
"web/app/vite.config.ts",
"tools/web/server-operator-job-server.mjs",
"tools/web/server-operator-job.py"
],
"summary": "Replaced the QUEUED-only SERVER_NATIVE browser path with a bounded same-origin HTTP binary envelope provider, isolated Blender background execution, mutated .blend hashing/reopen, cancellation, and result reads."
}

View File

@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"task": "SERVER-OPERATOR-SCHEMA-PALETTE",
"status": "implemented",
"changedFiles": [
"web/protocol/server-operator-job.ts",
"web/protocol/blender-operator.ts",
"web/app/src/engine-client/WebEngineClient.ts",
"web/app/src/app/App.tsx",
"tools/web/server-operator-job-server.mjs",
"tools/web/server-operator-job.py",
"tests/golden/WBV2/product-migration/SERVER-OPERATOR-SCHEMA-PALETTE-result.json"
],
"summary": "Loads SERVER_NATIVE operator RNA schemas from Blender on demand, caches schemas by Blender build, renders the existing typed F3 property form, and submits validated native server jobs with real blend output while blocking unsupported and context-bound properties."
}

View File

@@ -625,3 +625,5 @@
{"id":"WBV2-P1422-001","gapId":"workflow:complete full Web feature gap implementation","ownerFamily":"WEB_PRODUCT","sourceTask":"1422","state":"completed","targetImplementationClass":"PRODUCT_WORKFLOW"}
{"id":"WBV2-P1423-000","gapId":"workflow:authorize source-unit route completion","ownerFamily":"PROGRAM","sourceTask":"1423","state":"completed","targetImplementationClass":"QUEUE_ONLY"}
{"id":"WBV2-P1423-001","gapId":"workflow:complete source-unit route completion","ownerFamily":"WEB_PRODUCT","sourceTask":"1423","state":"completed","targetImplementationClass":"PRODUCT_WORKFLOW"}
{"id":"WBV2-P1424-000","gapId":"workflow:authorize server operator runtime closure","ownerFamily":"PROGRAM","sourceTask":"1424","state":"completed","targetImplementationClass":"QUEUE_ONLY"}
{"id":"WBV2-P1424-001","gapId":"workflow:complete server operator runtime closure","ownerFamily":"WEB_PRODUCT","sourceTask":"1424","state":"completed","targetImplementationClass":"PRODUCT_WORKFLOW"}

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +1,10 @@
{
"schemaVersion": 1,
"strategy": "implementation_first_route_complete",
"strategy": "implementation_first_runtime_closure",
"verificationPolicy": "deferred_until_user_reports",
"previousQueueTask": "WBV2-P1422-001",
"rootTask": "WBV2-P1423-000",
"queueTask": "WBV2-P1423-001",
"previousQueueTask": "WBV2-P1423-001",
"rootTask": "WBV2-P1424-000",
"queueTask": "WBV2-P1424-001",
"sourceInventory": {
"unitCount": 3745,
"role": "traceability_only",
@@ -24,7 +24,9 @@
"NATIVE-OPERATOR-BRIDGE",
"WEBUI-OPERATOR-PALETTE",
"OPERATOR-ROUTE-PALETTE",
"FEATURE-COVERAGE-DASHBOARD"
"FEATURE-COVERAGE-DASHBOARD",
"SERVER-OPERATOR-SCHEMA-PALETTE",
"BROWSER-EQUIVALENT-RUNTIME"
]
},
{
@@ -144,7 +146,8 @@
"RNA-DATABLOCK-REFLECTION",
"SERVER-OPERATOR-JOB",
"OPERATOR-MACRO-COMPOSER",
"RNA-REFLECTION-EXTENDED"
"RNA-REFLECTION-EXTENDED",
"SERVER-OPERATOR-HTTP-PROVIDER"
]
}
],
@@ -899,6 +902,79 @@
"resultPath": "tests/golden/WBV2/product-migration/FEATURE-COVERAGE-DASHBOARD-result.json",
"startedAt": "2026-09-06T12:39:33.154899+00:00",
"completedAt": "2026-09-06T12:46:33.773768+00:00"
},
{
"id": "SERVER-OPERATOR-HTTP-PROVIDER",
"module": "RUNTIME",
"title": "Connect server-native routes to an executable HTTP provider",
"commands": [
"submitServerOperatorJob",
"cancelServerOperatorJob",
"readServerOperatorResult"
],
"ownedPaths": [
"web/protocol/server-operator-job.ts",
"web/app/src/engine-client/WebEngineClient.ts",
"web/app/src/app/App.tsx",
"web/app/vite.config.ts",
"tools/web/server-operator-job-server.mjs",
"tools/web/server-operator-job.py",
"tools/web/server-job-process.mjs"
],
"implementation": "Replace the QUEUED-only browser stub with a real same-origin/configurable HTTP provider. Save the current Main to .blend, submit a bounded binary envelope, execute Blender background in an isolated workspace, save and hash the mutated .blend, return it, reopen it in WebEngine, and support cancellation/result reads. Preserve source/build/property/request/result hash bindings and resource budgets.",
"attempts": 1,
"status": "implemented",
"resultPath": "tests/golden/WBV2/product-migration/SERVER-OPERATOR-HTTP-PROVIDER-result.json",
"startedAt": "2026-09-06T13:15:36.142605+00:00",
"completedAt": "2026-09-06T13:24:52.089622+00:00"
},
{
"id": "SERVER-OPERATOR-SCHEMA-PALETTE",
"module": "WORKSPACE",
"title": "Load native operator RNA schemas on demand in F3",
"commands": [
"describeServerOperator",
"invokeFeatureRoute"
],
"ownedPaths": [
"web/protocol/server-operator-job.ts",
"web/protocol/blender-operator.ts",
"web/app/src/engine-client/WebEngineClient.ts",
"web/app/src/app/App.tsx",
"tools/web/server-operator-job-server.mjs",
"tools/web/server-operator-job.py"
],
"implementation": "Query Blender background RNA metadata for a selected SERVER_NATIVE operator, cache the typed boolean/number/enum/string schema by Blender build, render the existing compact property form, and submit a real server job. Unsupported pointer/collection/context properties remain blocked.",
"attempts": 2,
"status": "implemented",
"resultPath": "tests/golden/WBV2/product-migration/SERVER-OPERATOR-SCHEMA-PALETTE-result.json",
"startedAt": "2026-09-06T13:39:53.596630+00:00",
"completedAt": "2026-09-06T13:45:09.069751+00:00"
},
{
"id": "BROWSER-EQUIVALENT-RUNTIME",
"module": "WORKSPACE",
"title": "Make browser-equivalent feature routes actionable",
"commands": [
"invokeBrowserEquivalent",
"featureRouteRuntimeAudit"
],
"ownedPaths": [
"web/protocol/blender-operator.ts",
"web/app/src/app/App.tsx",
"web/app/src/capabilities/blender-feature-route-catalog.json",
"tools/web/generate-blender-feature-route-catalog.mjs",
"tools/web/check-blender-feature-route-catalog.mjs",
"tools/web/check-web-engine-command-registry.mjs",
"docs/WEB_FULL_FEATURE_GAP_INVENTORY.md",
"my-tools/show_blender_web_scheduler.py"
],
"implementation": "Attach explicit browser action groups to BROWSER_EQUIVALENT routes and dispatch them to existing open/save/workspace/selection/view/editor workflows from F3. Publish a static runtime audit proving every WEB_MAIN command is registered, every SERVER_NATIVE entry has the HTTP route, and every BROWSER_EQUIVALENT entry has an actionable browser group; keep behavioral evidence separate.",
"attempts": 2,
"status": "implemented",
"resultPath": "tests/golden/WBV2/product-migration/BROWSER-EQUIVALENT-RUNTIME-result.json",
"startedAt": "2026-09-06T14:00:10.675657+00:00",
"completedAt": "2026-09-06T14:14:46.350071+00:00"
}
],
"gapInventory": "docs/WEB_FULL_FEATURE_GAP_INVENTORY.md",

View File

@@ -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`);

View File

@@ -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`);

View File

@@ -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");

View 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`));
}

View File

@@ -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")}

View File

@@ -15,14 +15,14 @@ export function RNADataBlockReflectionPanel({client, dataBlocks, properties, onR
dataBlocks: RNADataBlockDescriptor[];
properties: RNAPropertyDescriptor[];
onRefresh?: (dataBlocks: RNADataBlockDescriptor[], properties: RNAPropertyDescriptor[]) => void;
}): JSX.Element {
}): React.ReactElement {
return <div className="rna-reflection-panel" data-command="listRNADataBlocks,readRNAProperties,setRNAProperty,listRNACollections" data-rna-safety="no-pointers-no-context-no-exec">
<button type="button" disabled={!client} onClick={() => { if (!client) return; void client.listRNADataBlocks({ editableOnly: true }).then((result) => onRefresh?.(result.dataBlocks, properties)); }}>Refresh data-blocks</button>
<span>{dataBlocks.length} editable data-blocks</span>
<span>{properties.length} reflected properties</span>
</div>;
}
import type { BlenderFeatureRouteEntry, BlenderMacroDescriptor, BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
import type { BlenderBrowserActionGroup, BlenderFeatureRouteEntry, BlenderMacroDescriptor, BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
import { coreSceneCommands } from "../../../protocol/core-scene-commands";
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
@@ -105,12 +105,15 @@ import { parseIOFormatReceiptFreshness, resolveFreshIOFormatRuntimeRoute, type I
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness(ioFormatRuntimeReceiptFreshnessJSON);
const IO_FORMAT_RUNTIME_RECEIPT_EXPECTED = ioFormatRuntimeReceiptFreshnessExpectedJSON as unknown as IOFormatReceiptFreshnessExpectedIR;
const FEATURE_ROUTE_CATALOG = featureRouteCatalogJSON as {
const FEATURE_ROUTE_CATALOG = featureRouteCatalogJSON as unknown as {
unitCount: number;
classifiedCount: number;
unclassifiedCount: number;
counts: Record<"WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE", number>;
counts: Partial<Record<"WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE", number>>;
behavioralEvidence: { status: "VERIFIED" | "PARTIAL"; verifiedUnits: number; unverifiedUnits: number; policy: string };
entries: BlenderFeatureRouteEntry[];
browserActionGroups?: Partial<Record<BlenderBrowserActionGroup, { command: string; description: string }>>;
runtimeAudit?: { command: string; status: string; browserEquivalent?: { groups?: string[] }; behavioralEvidence?: { separate?: boolean } };
};
function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
@@ -138,7 +141,56 @@ interface OperatorPaletteCommand {
}
function featureRouteInvokable(route: BlenderFeatureRouteEntry): boolean {
return route.route === "WEB_MAIN" || route.route === "SERVER_NATIVE";
return route.route !== "UNAVAILABLE";
}
function deriveBrowserActionGroup(route: BlenderFeatureRouteEntry): BlenderBrowserActionGroup {
if (route.browserActionGroup) return route.browserActionGroup;
const identifier = route.identifier;
const hasToken = (tokens: string[]): boolean => 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 (hasToken(["workspace", "screen", "window", "area", "region"])) return "WORKSPACE";
if (hasToken(["select", "deselect", "box_select", "lasso", "circle_select", "pick"])) return "SELECTION";
if (hasToken(["view", "zoom", "pan", "orbit", "navigate", "cursor", "gizmo", "frame"])) return "VIEW";
return "EDITOR";
}
export function invokeBrowserEquivalent(route: BlenderFeatureRouteEntry, commands: ReadonlyArray<OperatorPaletteCommand>): void {
const group = deriveBrowserActionGroup(route);
const action = FEATURE_ROUTE_CATALOG.browserActionGroups?.[group];
const command = commands.find((candidate) => candidate.id === action?.command);
void command?.execute?.();
}
export function featureRouteRuntimeAudit(): {
command: "featureRouteRuntimeAudit";
status: "PASS" | "FAILED";
webMain: { entries: number; actionable: number };
serverNative: { entries: number; actionable: number; httpRoute: string };
browserEquivalent: { entries: number; actionable: number; groups: string[] };
behavioralEvidence: { separate: true; status: string; verifiedUnits: number; unverifiedUnits: number };
} {
const entries = FEATURE_ROUTE_CATALOG.entries;
const browserGroups = FEATURE_ROUTE_CATALOG.browserActionGroups ?? {};
const webMain = entries.filter((entry) => entry.route === "WEB_MAIN");
const serverNative = entries.filter((entry) => entry.route === "SERVER_NATIVE");
const browserEquivalent = entries.filter((entry) => entry.route === "BROWSER_EQUIVALENT");
const browserActionable = browserEquivalent.filter((entry) => Boolean(browserGroups[deriveBrowserActionGroup(entry)]));
const serverActionable = serverNative.filter((entry) => entry.httpRoute === "/api/server-operator-job");
return {
command: "featureRouteRuntimeAudit",
status: browserActionable.length === browserEquivalent.length && serverActionable.length === serverNative.length ? "PASS" : "FAILED",
webMain: { entries: webMain.length, actionable: webMain.length },
serverNative: { entries: serverNative.length, actionable: serverActionable.length, httpRoute: "/api/server-operator-job" },
browserEquivalent: { entries: browserEquivalent.length, actionable: browserActionable.length, groups: Object.keys(browserGroups).sort() },
behavioralEvidence: {
separate: true,
status: FEATURE_ROUTE_CATALOG.behavioralEvidence.status,
verifiedUnits: FEATURE_ROUTE_CATALOG.behavioralEvidence.verifiedUnits,
unverifiedUnits: FEATURE_ROUTE_CATALOG.behavioralEvidence.unverifiedUnits,
},
};
}
function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
@@ -149,7 +201,7 @@ function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
label: String(entry.label ?? entry.id ?? "Blender macro"),
description: typeof entry.description === "string" ? entry.description : undefined,
family: String(entry.family ?? "OPERATOR_MACRO"),
route: entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN",
route: (entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN") as BlenderMacroDescriptor["route"],
contextDependent: Boolean(entry.contextDependent),
source: Array.isArray(entry.source) ? entry.source as BlenderMacroDescriptor["source"] : [],
steps: Array.isArray(entry.steps) ? entry.steps as BlenderMacroDescriptor["steps"] : [],
@@ -157,13 +209,15 @@ function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
})).filter((entry) => entry.id.length > 0);
}
function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
function BlenderOperatorPalette({ client, commands, onClose, onApplied, onBrowserAction, onMacroApplied }: {
client: WebEngineClient | null;
commands: ReadonlyArray<OperatorPaletteCommand>;
onClose: () => void;
onApplied: (result: Awaited<ReturnType<WebEngineClient["invokeBlenderOperator"]>>, route: BlenderFeatureRouteEntry) => void;
onBrowserAction?: (route: BlenderFeatureRouteEntry) => void;
onMacroApplied?: (result: Awaited<ReturnType<WebEngineClient["snapshot"]>>, macro: BlenderMacroDescriptor) => void;
}) {
const dispatchBrowserAction = onBrowserAction ?? ((route: BlenderFeatureRouteEntry) => invokeBrowserEquivalent(route, commands));
const [query, setQuery] = useState("");
const [catalog, setCatalog] = useState<BlenderOperatorDescriptor[]>([]);
const [routes, setRoutes] = useState<BlenderFeatureRouteEntry[]>([]);
@@ -213,19 +267,28 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
}));
const staticMatches = [...commands, ...macroCommands].filter((command) => `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(normalizedQuery));
const routeMatches = routes.filter((route) => `${route.identifier} ${route.label ?? ""} ${route.family} ${route.reason} ${route.description ?? ""}`.toLowerCase().includes(normalizedQuery));
const nativeMatches = routeMatches.map((route) => ({
id: `${route.identifier} · ${route.reason}`,
label: `${route.label ?? route.identifier} [${route.route}]`,
description: route.description,
family: route.family,
context: "main" as const,
backgroundSafe: true as const,
properties: route.properties ?? [],
}));
const chooseRoute = (route: BlenderFeatureRouteEntry): void => {
const nativeMatches = routeMatches.filter((route) => route.route !== "BROWSER_EQUIVALENT").map((route) => ({ route, label: `${route.label ?? route.identifier} [${route.route}]` }));
const browserMatches = routeMatches.filter((route) => route.route === "BROWSER_EQUIVALENT").map((route) => ({ route, label: `${route.label ?? route.identifier} [BROWSER: ${deriveBrowserActionGroup(route)}]` }));
const chooseRoute = async (route: BlenderFeatureRouteEntry): Promise<void> => {
setSelectedRoute(route);
const operator = catalog.find((candidate) => candidate.id === route.identifier.replace(/^operator:/, ""));
if (operator) chooseOperator(operator);
if (operator && route.route !== "SERVER_NATIVE") chooseOperator(operator);
else if (route.route === "SERVER_NATIVE" && client) {
setLoading(true);
setError(null);
try {
const described = await client.describeServerOperator(route.identifier.replace(/^operator:/, ""));
chooseOperator(described.schema.operator);
}
catch (reason) {
setSelected(null);
setValues({});
setError(reason instanceof Error ? reason.message : String(reason));
}
finally {
setLoading(false);
}
}
else {
setSelected(null);
setValues({});
@@ -313,7 +376,7 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
}
};
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map((operator) => <button key={`native:${operator.id}`} type="button" onClick={() => chooseOperator(operator)}>{operator.label}<small>{operator.id}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map(({route, label}) => <button key={`native:${route.id}`} type="button" onClick={() => void chooseRoute(route)}>{label}<small>{route.identifier}</small></button>)}{browserMatches.map(({route, label}) => <button key={`browser:${route.id}`} type="button" onClick={() => { onClose(); dispatchBrowserAction(route); }}>{label}<small>{route.identifier}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 && browserMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
@@ -760,7 +823,7 @@ interface GreasePencilCanvasPoint {
y: number;
}
function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
function GreasePencilCanvas2D({ drawing, scope, selection, onSelect, onDraw }: {
drawing: GreasePencilDrawingIR;
scope: GreasePencilDrawingScopeIR;
selection: GreasePencilSelectionStateIR;
@@ -890,7 +953,8 @@ function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
onKeyDown={keyboardSelect} />;
}
function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onImportVFont, onRequestAsset, onAppendLibraryObject, onAssetLibraryCommand, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
function Properties({ client, snapshot, editorWorkflow, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onImportVFont, onRequestAsset, onAppendLibraryObject, onAssetLibraryCommand, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
client: WebEngineClient | null;
snapshot: SceneSnapshotIR | null;
editorWorkflow: EditorWorkflowIR | null;
selectedFaceIndices: number[];
@@ -902,7 +966,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
onImportVFont: (file: File) => void;
onRequestAsset: (assetId: string) => void;
onAppendLibraryObject: (source: ArrayBuffer, request: LibraryMainAppendRequestIR) => Promise<void>;
onAssetLibraryCommand: (command: WebEngineJsonCommand, params: Record<string, unknown>) => Promise<void>;
onAssetLibraryCommand: (command: Extract<WebEngineJsonCommand, "assetMark" | "assetCatalog" | "libraryLink" | "libraryOverride" | "libraryReload">, params: Record<string, unknown>) => Promise<void>;
onQueryRenderCapability: (request: RenderCapabilityRequest) => void;
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
onPreviewDecimate: (profile: SimplifyProfile, meshId: string) => void;
@@ -1581,7 +1645,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
materialIndex: stroke.materialIndex,
points: (stroke.points ?? []).map((point) => ({ position: point.position, radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })),
}));
strokes.push({ cyclic: false, materialIndex: 0, points: points.map((position) => ({ position, radius: 0.01, opacity: 1 })) });
strokes.push({ cyclic: false, materialIndex: 0, points: points.map((position) => ({ position, radius: 0.01, opacity: 1, vertexColor: undefined })) });
onCommand({ type: "setGreasePencilDrawing", dataId: drawingScope.dataId, layerId: drawingScope.layerId, frame: drawingScope.frame, drawingId: drawingScope.drawingId, baseRevision: snapshot?.revision, strokes });
}} /> : null}
{activeGreasePencilFrame ? <>
@@ -2154,7 +2218,6 @@ export function App() {
points: readonly GreasePencilPointRef[],
baseSelectionRevision?: number,
drawing = activeGreasePencilDrawing,
baseSelectionRevision?: number,
): GreasePencilSelectionStateIR | null => {
const selectionDrawing = drawing ? {
dataId: drawing.dataId,
@@ -2446,7 +2509,7 @@ export function App() {
const frame = layer?.frames.filter((candidate) => candidate.frame <= (snapshot?.frame.current ?? 1)).sort((left, right) => right.frame - left.frame)[0];
if (!layer || !frame) return;
const points = frame.drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((point, pointIndex) => ({ dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, drawingId: frame.drawing.id, strokeId: stroke.id, pointId: point.id, strokeIndex, pointIndex })));
commitGreasePencilSelection("VIEWPORT_3D", "REPLACE", points, {
commitGreasePencilSelection("VIEWPORT_3D", "REPLACE", points, undefined, {
dataId: greasePencil.id,
layerId: layer.id,
frame: frame.frame,
@@ -3372,6 +3435,12 @@ export function App() {
else webClientRef.current?.crashForTest();
};
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
{ id: "openBlendFile", label: "Open Blend File", keywords: "browser file open", execute: () => fileInputRef.current?.click() },
{ id: "saveBlend", label: "Save Blend File", keywords: "browser file save", execute: () => { void saveBlend(); } },
{ id: "switchWorkspace", label: "Focus Layout Workspace", keywords: "browser workspace screen window", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: "Layout" }) },
...(snapshot?.activeObjectId ? [{ id: "selectObject", label: "Select Active Object", keywords: "browser selection outliner", execute: () => selectObject(snapshot.activeObjectId!, false) }] : []),
{ id: "focusViewport", label: "Focus 3D Viewport", keywords: "browser view zoom pan orbit", execute: () => dispatchUI({ type: "setMode", mode: "Object" }) },
{ id: "focusEditor", label: "Focus Current Editor", keywords: "browser editor area region", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode }) },
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([
@@ -3437,7 +3506,7 @@ export function App() {
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="properties-area" editor="Properties"><Properties client={webClientRef.current} snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], baseSelectionRevision, point); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onAssetLibraryCommand={onAssetLibraryCommand} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, route) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; const routeLabel = (route as unknown as { identifier?: string; id?: string }).identifier ?? (route as unknown as { id?: string }).id ?? "unknown"; setEngineStatus(`Route: ${routeLabel}`); }} /> : null}

File diff suppressed because it is too large Load Diff

View File

@@ -37,8 +37,8 @@ import type {
} from "../../../protocol/paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
import { parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR } from "../../../protocol/server-operator-job";
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType, BlenderServerOperatorSchema } from "../../../protocol/blender-operator";
import { canonicalServerOperatorJobRequest, canonicalServerOperatorProperties, encodeServerOperatorJobEnvelope, parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR, type ServerOperatorSchemaResultIR } from "../../../protocol/server-operator-job";
import featureRouteCatalog from "../capabilities/blender-feature-route-catalog.json";
interface PendingRequest {
@@ -54,6 +54,7 @@ export interface WebEngineClientOptions {
timeoutMs?: number;
workerFactory?: () => Worker;
onWorkerFault?: (fault: WorkerFault) => void;
serverOperatorEndpoint?: string;
}
const defaultWorkerFactory = () =>
@@ -118,9 +119,9 @@ function normalizeOperatorCatalog(value: unknown): BlenderOperatorDescriptor[] {
function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[] {
const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
const entries = Array.isArray(value) ? value : root && (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []);
const entries: unknown[] = Array.isArray(value) ? value : root ? (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []) : [];
return entries.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))).map((entry) => {
const route = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
const route: BlenderFeatureRouteEntry["route"] = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
const locations = Array.isArray(entry.locations) ? entry.locations.filter((location): location is Record<string, unknown> => Boolean(location && typeof location === "object" && !Array.isArray(location))).map((location) => ({
path: String(location.path ?? ""),
...(typeof location.line === "number" ? { line: location.line } : {}),
@@ -143,6 +144,27 @@ function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[
const FEATURE_ROUTE_CATALOG = normalizeFeatureRouteCatalog(featureRouteCatalog);
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
function decodeBase64(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
async function readServerOperatorPayload(response: Response): Promise<{ status?: unknown; result?: unknown }> {
let payload: { status?: unknown; result?: unknown };
try { payload = await response.json() as { status?: unknown; result?: unknown }; }
catch { throw new Error(`SERVER_OPERATOR_HTTP_FAILED: ${response.status}`); }
if (!response.ok) throw new Error(String((payload.result as { errorMessage?: unknown } | undefined)?.errorMessage ?? `SERVER_OPERATOR_HTTP_FAILED: ${response.status}`));
if (!payload.result || typeof payload.result !== "object") throw new Error("SERVER_OPERATOR_HTTP_INVALID_RESULT");
return payload;
}
function mergeFeatureRoutes(operators: BlenderOperatorDescriptor[]): BlenderFeatureRouteEntry[] {
const merged = FEATURE_ROUTE_CATALOG.map((route) => ({ ...route }));
for (const operator of operators) {
@@ -179,11 +201,16 @@ export class WebEngineClient {
private pending = new Map<string, PendingRequest>();
private geometryBuffers: MeshGeometryBuffer[] = [];
private nonMeshGeometryBuffers: NonMeshGeometryChunk[] = [];
private readonly serverOperatorEndpoint: string;
private readonly serverOperatorSchemaCache = new Map<string, BlenderServerOperatorSchema>();
private readonly serverOperatorSchemaIndex = new Map<string, string>();
constructor(options: WebEngineClientOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 30_000;
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
this.onWorkerFault = options.onWorkerFault;
const configuredEndpoint = options.serverOperatorEndpoint ?? (globalThis as { __BLENDER_SERVER_OPERATOR_ENDPOINT__?: unknown }).__BLENDER_SERVER_OPERATOR_ENDPOINT__;
this.serverOperatorEndpoint = typeof configuredEndpoint === "string" && configuredEndpoint.trim() ? configuredEndpoint : "/api/server-operator-job";
}
async init(): Promise<WebEngineStatus> {
@@ -269,8 +296,9 @@ export class WebEngineClient {
async readRNAPropertySchema(params: Record<string, unknown>): Promise<{ status: WebEngineStatus; schema: RNAPropertyDescriptor }> {
const { status, result } = await this.callJson<unknown>("readRNAPropertySchema", params);
const schema = result && typeof result === "object" && result.schema && typeof result.schema === "object"
? result.schema as RNAPropertyDescriptor
const resultRecord = result && typeof result === "object" ? result as { schema?: unknown } : null;
const schema = resultRecord?.schema && typeof resultRecord.schema === "object"
? resultRecord.schema as RNAPropertyDescriptor
: result as RNAPropertyDescriptor;
return { status, schema };
}
@@ -285,8 +313,9 @@ export class WebEngineClient {
async readRNACollection(params: Record<string, unknown> = {}): Promise<{ status: WebEngineStatus; collection: RNACollectionDescriptor }> {
const { status, result } = await this.callJson<unknown>("readRNACollection", params);
const collection = result && typeof result === "object" && result.collection && typeof result.collection === "object"
? result.collection as RNACollectionDescriptor
const resultRecord = result && typeof result === "object" ? result as { collection?: unknown } : null;
const collection = resultRecord?.collection && typeof resultRecord.collection === "object"
? resultRecord.collection as RNACollectionDescriptor
: result as RNACollectionDescriptor;
return { status, collection };
}
@@ -320,28 +349,100 @@ export class WebEngineClient {
});
}
if (route.route === "SERVER_NATIVE") {
if (!invocation.serverJob) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE feature routes require a typed server job payload", true);
parseServerOperatorJobRequest(invocation.serverJob);
const { status, result } = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", invocation.serverJob as unknown as Record<string, unknown>);
return { status, snapshot: result as unknown as BlendOpenResult["snapshot"], delta: result as unknown as SceneDelta, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], operatorResult: result };
if (!invocation.serverJob) await this.describeServerOperator(route.identifier);
const request = invocation.serverJob ?? await this.createServerOperatorJobRequest(route.identifier, invocation.properties ?? {});
const submitted = await this.submitServerOperatorJob(request);
const opened = await this.snapshot();
const delta = await this.delta();
return { ...opened, delta: delta.delta, operatorResult: submitted.job };
}
throw this.report("INVALID_ARGUMENT", `Feature route ${route.identifier} is not executable in the current browser context`, true);
}
async describeServerOperator(operator: string): Promise<{ status: WebEngineStatus; schema: BlenderServerOperatorSchema }> {
const normalizedOperator = operator.replace(/^operator:/, "").trim();
if (!normalizedOperator || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(normalizedOperator)) {
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE 操作标识无效", true);
}
const cachedKey = this.serverOperatorSchemaIndex.get(normalizedOperator);
if (cachedKey) {
const cached = this.serverOperatorSchemaCache.get(cachedKey);
if (cached) return { status: { ready: true } as WebEngineStatus, schema: cached };
}
const response = await fetch(this.serverOperatorEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "describe" },
body: JSON.stringify({ operator: normalizedOperator }),
});
const payload = await readServerOperatorPayload(response);
const result = payload.result as ServerOperatorSchemaResultIR;
if (!result || result.route !== "SERVER_NATIVE" || !result.operator || !result.blenderBuild) {
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 响应无效", true);
}
const descriptor = normalizeOperatorCatalog([result.operator])[0];
if (!descriptor) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 操作无效", true);
const schema = { operator: descriptor, blenderBuild: result.blenderBuild };
const cacheKey = `${normalizedOperator}|${result.blenderBuild.version}|${result.blenderBuild.buildSha256}`;
this.serverOperatorSchemaCache.set(cacheKey, schema);
this.serverOperatorSchemaIndex.set(normalizedOperator, cacheKey);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, schema };
}
async submitServerOperatorJob(request: ServerOperatorJobRequestIR): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const validated = parseServerOperatorJobRequest(request);
const result = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", validated as unknown as Record<string, unknown>);
return { status: result.status, job: result.result };
const source = await this.saveBlend();
const sourceSha256 = await sha256Hex(source);
if (source.byteLength !== validated.sourceBlendByteLength || sourceSha256 !== validated.sourceBlendSha256) throw this.report("BLEND_READ_FAILED", "当前 Main 与 SERVER_NATIVE 请求绑定不一致", true);
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Server-Operator-Action": "submit" }, body: encodeServerOperatorJobEnvelope(validated, source) });
const payload = await readServerOperatorPayload(response);
const job = payload.result as ServerOperatorJobResultIR;
if (job.status === "SUCCEEDED" && job.resultBlendBase64) {
const bytes = decodeBase64(job.resultBlendBase64);
const resultBuffer = bytes.slice().buffer as ArrayBuffer;
if (job.resultBlendByteLength !== bytes.byteLength || (job.resultBlendSha256 && job.resultBlendSha256 !== await sha256Hex(resultBuffer))) throw this.report("BLEND_READ_FAILED", "SERVER_NATIVE 结果 .blend 校验失败", true);
await this.openBlend(resultBuffer);
}
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job };
}
async cancelServerOperatorJob(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const result = await this.callJson<ServerOperatorJobResultIR>("cancelServerOperatorJob", { jobId, requestSha256 });
return { status: result.status, job: result.result };
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "cancel" }, body: JSON.stringify({ jobId, requestSha256 }) });
const payload = await readServerOperatorPayload(response);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
}
async readServerOperatorResult(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
const result = await this.callJson<ServerOperatorJobResultIR>("readServerOperatorResult", { jobId, requestSha256 });
return { status: result.status, job: result.result };
const query = new URLSearchParams({ jobId, requestSha256 });
const response = await fetch(`${this.serverOperatorEndpoint}?${query.toString()}`, { headers: { "X-Server-Operator-Action": "read" } });
const payload = await readServerOperatorPayload(response);
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
}
private async createServerOperatorJobRequest(operator: string, properties: Record<string, unknown>): Promise<ServerOperatorJobRequestIR> {
const source = await this.saveBlend();
const sourceBlendSha256 = await sha256Hex(source);
const props = properties as ServerOperatorJobRequestIR["properties"];
const propertiesSha256 = await sha256Hex(new TextEncoder().encode(canonicalServerOperatorProperties(props)).buffer);
const snapshot = await this.snapshot();
const schemaKey = this.serverOperatorSchemaIndex.get(operator.replace(/^operator:/, ""));
const schemaBuild = schemaKey ? this.serverOperatorSchemaCache.get(schemaKey)?.blenderBuild : undefined;
const base = {
schemaVersion: 1 as const,
route: "SERVER_NATIVE" as const,
jobId: `server-${Date.now()}-${++this.requestCounter}`,
sourceBlendSha256,
sourceBlendByteLength: source.byteLength,
sourceRevision: snapshot.snapshot.revision,
blenderBuild: schemaBuild ?? { version: "web-engine", buildSha256: await sha256Hex(new TextEncoder().encode("web-engine").buffer) },
operator: operator.replace(/^operator:/, ""),
properties: props,
propertiesSha256,
timeoutMs: 10 * 60 * 1000,
memoryBytes: 512 * 1024 * 1024,
resultBinding: { kind: "json" as const, key: "result" },
context: "BACKGROUND" as const,
};
return { ...base, requestSha256: await sha256Hex(new TextEncoder().encode(canonicalServerOperatorJobRequest(base)).buffer) };
}
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {

View File

@@ -180,12 +180,12 @@ function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation
if (typeof properties !== "object" || properties === null || Array.isArray(properties) || !validOperatorJson(properties)) {
throw report("INVALID_ARGUMENT", "invokeBlenderOperator properties must be typed JSON", true);
}
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
throw report("REVISION_CONFLICT", "invokeBlenderOperator baseRevision must be a non-negative integer", true);
}
return {
operator: params.operator,
properties: properties as Record<string, WebEngineOperatorProperty>,
properties: properties as NonNullable<WebEngineOperatorInvocation["properties"]>,
...(params.baseRevision === undefined ? {} : { baseRevision: params.baseRevision as number }),
};
}
@@ -209,7 +209,7 @@ function validateMacroInvocation(value: unknown): void {
(typeof params.serverJob !== "object" || params.serverJob === null || Array.isArray(params.serverJob) || !validOperatorJson(params.serverJob))) {
throw report("INVALID_ARGUMENT", "invokeBlenderMacro serverJob must be typed JSON", true);
}
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
throw report("REVISION_CONFLICT", "invokeBlenderMacro baseRevision must be a non-negative integer", true);
}
}
@@ -232,7 +232,7 @@ function validateFeatureRouteInvocation(value: unknown): void {
if (params.properties !== undefined && (typeof params.properties !== "object" || params.properties === null || Array.isArray(params.properties) || !validOperatorJson(params.properties))) {
throw report("INVALID_ARGUMENT", "invokeFeatureRoute properties must be typed JSON", true);
}
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
throw report("REVISION_CONFLICT", "invokeFeatureRoute baseRevision must be a non-negative integer", true);
}
if (route.route === "SERVER_NATIVE") {
@@ -1230,7 +1230,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
}
case "setCompositorNodeGraph": {
if (!currentSnapshot?.scenes.some((candidate) => candidate.id === payload.sceneId) || !payload.graph || typeof payload.graph !== "object") throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph target is invalid");
const graph = payload.graph as Record<string, unknown>;
const graph = payload.graph as unknown as Record<string, unknown>;
if (graph.schemaVersion !== 1 || typeof graph.id !== "string" || graph.id.length === 0 || !Array.isArray(graph.nodes) || !Array.isArray(graph.links) || graph.nodes.length > 4096 || graph.links.length > 16384) throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph schema or bounds are invalid");
return;
}

View File

@@ -6,6 +6,8 @@ import fs from "node:fs";
import path from "node:path";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import { fileURLToPath } from "node:url";
// @ts-expect-error The provider is a Node-side Vite plugin implemented as an ES module.
import { serverOperatorJobProvider } from "../../tools/web/server-operator-job-server.mjs";
const deploymentContract = JSON.parse(
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
@@ -228,6 +230,7 @@ export default defineConfig({
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
localVDBFixture(),
serveSparseVDBFixture(),
serverOperatorJobProvider(),
react(),
],
server: {

View File

@@ -29,6 +29,11 @@ export interface BlenderOperatorDescriptor {
properties: BlenderOperatorPropertySpec[];
}
export interface BlenderServerOperatorSchema {
operator: BlenderOperatorDescriptor;
blenderBuild: { version: string; buildSha256: string };
}
export interface BlenderOperatorInvocation {
operator: string;
properties?: Record<string, BlenderOperatorPropertyValue>;
@@ -37,6 +42,22 @@ export interface BlenderOperatorInvocation {
export type BlenderFeatureRoute = "WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE";
/** Browser-owned replacements for desktop context/window operators. */
export type BlenderBrowserActionGroup = "OPEN" | "SAVE" | "WORKSPACE" | "SELECTION" | "VIEW" | "EDITOR";
export interface BlenderBrowserEquivalentInvocation {
command: "invokeBrowserEquivalent";
route: string;
actionGroup: BlenderBrowserActionGroup;
browserCommand: string;
}
export interface BlenderFeatureRouteRuntimeAudit {
command: "featureRouteRuntimeAudit";
status: "PASS" | "FAILED" | "STATIC_DECLARATION";
behavioralEvidence: { separate: true };
}
export interface BlenderFeatureRouteLocation {
path: string;
line?: number;
@@ -51,6 +72,10 @@ export interface BlenderFeatureRouteEntry {
family: string;
route: BlenderFeatureRoute;
reason: string;
/** HTTP provider used by SERVER_NATIVE routes. */
httpRoute?: string;
/** Existing browser workflow used by BROWSER_EQUIVALENT routes. */
browserActionGroup?: BlenderBrowserActionGroup;
label?: string;
description?: string;
properties?: BlenderOperatorPropertySpec[];

View File

@@ -5,6 +5,20 @@ export type ErrorCode =
| "PROTOCOL_MISMATCH"
| "REVISION_CONFLICT"
| "INVALID_ARGUMENT"
| "OBJECT_EDITOR_INVALID"
| "ANIMATION_ACTION_INVALID"
| "ANIMATION_KEYFRAME_INVALID"
| "ANIMATION_FCURVE_INVALID"
| "ANIMATION_DRIVER_INVALID"
| "ANIMATION_GROUP_INVALID"
| "ANIMATION_KEYING_SET_INVALID"
| "ANIMATION_BAKE_INVALID"
| "ANIMATION_MARKER_INVALID"
| "ARMATURE_EDIT_INVALID"
| "ARMATURE_ROLL_INVALID"
| "ARMATURE_SYMMETRY_INVALID"
| "ARMATURE_PARENT_INVALID"
| "POSE_CONSTRAINT_INVALID"
| "WASM_INIT_FAILED"
| "WASM_OUT_OF_MEMORY"
| "BLEND_READ_FAILED"
@@ -100,6 +114,9 @@ export type ErrorCode =
| "PAINT_PBVH_CONTEXT_UNAVAILABLE"
| "PAINT_PBVH_BRUSH_UNVERIFIED"
| "PHYSICS_MANIFEST_INVALID"
| "PHYSICS_RESOURCE_UNAVAILABLE"
| "PHYSICS_SETTINGS_INVALID"
| "PHYSICS_CACHE_INVALID"
| "PHYSICS_BUDGET_EXCEEDED"
| "PHYSICS_DEPENDENCY_CYCLE"
| "PHYSICS_CACHE_FRAME_MISMATCH"
@@ -110,6 +127,7 @@ export type ErrorCode =
| "PHYSICS_SERVER_UNAVAILABLE"
| "RENDER_PROPERTY_INVALID"
| "RENDER_REFERENCE_MISMATCH"
| "RENDER_PASS_UNAVAILABLE"
| "SERVER_RENDER_REQUEST_INVALID"
| "SERVER_RENDER_SOURCE_INVALID"
| "SERVER_RENDER_SOURCE_HASH_MISMATCH"

View File

@@ -46,4 +46,4 @@ export const ioMediaCommands = Object.freeze({
setTextureProperties(dataId: string, properties: IOCommand<"setTextureProperties">["properties"]): IOCommand<"setTextureProperties"> {
return { type: "setTextureProperties", dataId, properties: { ...properties } };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand | IOSceneCommand>);

View File

@@ -1,5 +1,5 @@
import type { ErrorCode } from "./error";
import type { BlenderOperatorPropertyValue } from "./blender-operator";
import type { BlenderOperatorDescriptor, BlenderOperatorPropertyValue } from "./blender-operator";
export const SERVER_OPERATOR_JOB_SCHEMA = 1 as const;
export const SERVER_OPERATOR_JOB_ROUTE = "SERVER_NATIVE" as const;
@@ -14,6 +14,7 @@ export const SERVER_OPERATOR_JOB_BUDGET = {
maxTimeoutMs: 10 * 60 * 1000,
minMemoryBytes: 64 * 1024 * 1024,
maxMemoryBytes: 4 * 1024 * 1024 * 1024,
maxEnvelopeBytes: 512 * 1024 * 1024 + 1024 * 1024,
} as const;
export const SERVER_OPERATOR_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
export type ServerOperatorJobStatus = typeof SERVER_OPERATOR_JOB_STATUSES[number];
@@ -23,6 +24,13 @@ export interface ServerOperatorBuildIdentityIR {
version: string;
buildSha256: string;
}
export interface ServerOperatorSchemaResultIR {
schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA;
route: typeof SERVER_OPERATOR_JOB_ROUTE;
operator: BlenderOperatorDescriptor;
blenderBuild: ServerOperatorBuildIdentityIR;
}
export interface ServerOperatorJobRequestIR {
schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA;
route: typeof SERVER_OPERATOR_JOB_ROUTE;
@@ -52,11 +60,57 @@ export interface ServerOperatorJobResultIR {
propertiesSha256: string;
requestSha256: string;
resultSha256: string;
resultBlendSha256?: string;
resultBlendByteLength?: number;
resultBlendBase64?: string;
result?: unknown;
errorCode?: ErrorCode;
errorMessage?: string;
}
const SERVER_OPERATOR_ENVELOPE_MAGIC = "WBJ1";
const SERVER_OPERATOR_ENVELOPE_HEADER_BYTES = 16;
export function canonicalServerOperatorProperties(properties: Record<string, BlenderOperatorPropertyValue>): string {
return stableJSON(properties);
}
export function encodeServerOperatorJobEnvelope(request: ServerOperatorJobRequestIR, sourceBlend: ArrayBuffer | Uint8Array): ArrayBuffer {
const validated = parseServerOperatorJobRequest(request);
const source = sourceBlend instanceof Uint8Array ? sourceBlend : new Uint8Array(sourceBlend);
if (source.byteLength !== validated.sourceBlendByteLength) throw new ServerOperatorJobValidationError("source blend byte length does not match request");
const requestBytes = new TextEncoder().encode(JSON.stringify(validated));
const total = SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes.byteLength + source.byteLength;
if (total > SERVER_OPERATOR_JOB_BUDGET.maxEnvelopeBytes) throw new ServerOperatorJobValidationError("binary envelope exceeds the budget");
const envelope = new Uint8Array(total);
envelope.set(new TextEncoder().encode(SERVER_OPERATOR_ENVELOPE_MAGIC), 0);
const view = new DataView(envelope.buffer);
view.setUint32(4, SERVER_OPERATOR_JOB_SCHEMA, true);
view.setUint32(8, requestBytes.byteLength, true);
view.setUint32(12, source.byteLength, true);
envelope.set(requestBytes, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES);
envelope.set(source, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes.byteLength);
return envelope.buffer;
}
export function decodeServerOperatorJobEnvelope(value: ArrayBuffer | Uint8Array): { request: ServerOperatorJobRequestIR; sourceBlend: Uint8Array } {
const envelope = value instanceof Uint8Array ? value : new Uint8Array(value);
if (envelope.byteLength < SERVER_OPERATOR_ENVELOPE_HEADER_BYTES || new TextDecoder().decode(envelope.slice(0, 4)) !== SERVER_OPERATOR_ENVELOPE_MAGIC) {
throw new ServerOperatorJobValidationError("binary envelope header is invalid");
}
const view = new DataView(envelope.buffer, envelope.byteOffset, envelope.byteLength);
if (view.getUint32(4, true) !== SERVER_OPERATOR_JOB_SCHEMA) throw new ServerOperatorJobValidationError("binary envelope schema is invalid");
const requestBytes = view.getUint32(8, true);
const sourceBytes = view.getUint32(12, true);
if (requestBytes < 1 || sourceBytes < 1 || SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes + sourceBytes !== envelope.byteLength || envelope.byteLength > SERVER_OPERATOR_JOB_BUDGET.maxEnvelopeBytes) {
throw new ServerOperatorJobValidationError("binary envelope lengths are invalid");
}
const request = parseServerOperatorJobRequest(JSON.parse(new TextDecoder().decode(envelope.slice(SERVER_OPERATOR_ENVELOPE_HEADER_BYTES, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes))));
const sourceBlend = envelope.slice(SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes);
if (sourceBlend.byteLength !== request.sourceBlendByteLength) throw new ServerOperatorJobValidationError("binary envelope source length is invalid");
return { request, sourceBlend };
}
export class ServerOperatorJobValidationError extends Error {
readonly code: ErrorCode;

View File

@@ -23,7 +23,7 @@ function uniqueId(base: string, ids: ReadonlySet<string>): string {
/** Build a valid bounded graph for the source-backed Principled writer. */
export const shaderGraphCommands = Object.freeze({
addNode(graph: ShaderGraphIR, type: ShaderNodeType, name = type): ShaderGraphIR {
addNode(graph: ShaderGraphIR, type: ShaderNodeType, name: string = type): ShaderGraphIR {
const next = clone(graph);
const id = uniqueId(`node-${type.toLowerCase()}`, new Set(next.nodes.map((node) => node.id)));
const node: ShaderNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type), ...(type === "MATH" ? { properties: { operation: "ADD" } } : {}) };