feat(web): switch unattended migration to product workflows
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-06 04:37:51 +08:00
parent f5f77492f5
commit 5218ee0692
5 changed files with 476 additions and 14 deletions

View File

@@ -0,0 +1,68 @@
# Web Product Migration Strategy
## Decision
`3745` is a source traceability inventory, not a count of independent user
features and not the Web product completion denominator.
Its current composition is:
| Source unit | Count | Share | Product meaning |
| --- | ---: | ---: | --- |
| Blender editor operators | 2018 | 53.89% | Mostly desktop editor/UI actions; many are modal, contextual, aliases, or Web-irrelevant |
| RNA structs | 979 | 26.14% | Data schema/type surface, not 979 separate workflows |
| Node registrations | 497 | 13.27% | Supported type universe, implemented by shared graph readers/dispatchers |
| Modifier source modules | 97 | 2.59% | Backend implementation families, not separate screens |
| WebEngine commands | 96 | 2.56% | Direct Web product mutation surface at the inventory snapshot |
| Operator macros | 58 | 1.55% | Composite desktop actions that reuse lower-level operations |
More than half of the inventory is Blender's desktop operator layer. Porting
those identifiers one by one would duplicate the browser UI architecture and
still would not prove that a user can complete a workflow. The inventory also
lags the current command registry, which now declares 100 Main commands. This
drift is another reason not to use the value as a release percentage.
The inventory remains useful for source provenance, risk discovery, and later
defect diagnosis. It must not block implementation completion.
## Completion Model
Implementation progress is measured by 16 user workflow modules:
1. project open/save/recovery
2. Web workspace and viewport
3. object and scene graph
4. mesh modeling and UV
5. non-mesh geometry
6. materials, shaders, and textures
7. modifiers and geometry nodes
8. animation and NLA
9. sculpt, paint, and Grease Pencil
10. render, light, camera, and world
11. compositor
12. physics and simulation
13. media, audio, and sequencer
14. assets, libraries, and interchange
15. browser storage and recovery
16. worker, performance, and modular WASM lifecycle
A module is implementation-complete when its Web entry point, typed protocol,
Worker route, and native Main/WASM path exist and its user actions are reachable
from the product. Desktop windows, screen layouts, native file browsers, and
other OS-owned Blender UI are replaced by browser-native equivalents rather
than copied by operator identifier.
## Implementation-First Rules
- Implement one workflow gap per Codex turn.
- Do not run tests, builds, type checks, browsers, full validators, or coverage
binding during this phase.
- Do not claim source-inventory units from similar names or neighboring code.
- Preserve completed WASM modules for preload and keep the active debug module
dynamically loaded.
- Record implementation changes and move immediately to the next workflow.
- After implementation closure, defects are opened from real user operation
and fixed as focused tasks.
The source inventory and historical parity ledger are retained as reference
artifacts only. They no longer control the Web product completion percentage.

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Implement the Web product backlog through bounded non-interactive Codex turns."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import signal
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from wbv2_runtime import ROOT
CONTROL = ROOT / "tests/golden/WBV2"
BACKLOG = CONTROL / "web-product-backlog.json"
STATUS = CONTROL / "web-product-migration-status.json"
LOCK = CONTROL / "web-product-migration.lock"
PID = CONTROL / "web-product-migration.pid"
LOG_ROOT = CONTROL / "product-migration"
ACTIVE_PROCESS: subprocess.Popen[str] | None = None
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + f".tmp-{os.getpid()}")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def alive(pid: object) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def acquire_lock() -> None:
try:
handle = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
try:
owner = read_json(LOCK).get("pid")
except (OSError, json.JSONDecodeError):
owner = None
if not alive(owner):
LOCK.unlink(missing_ok=True)
return acquire_lock()
raise SystemExit(f"web product migration already running: pid={owner}")
os.write(handle, (json.dumps({"pid": os.getpid(), "startedAt": now()}) + "\n").encode())
os.close(handle)
def file_hash(path: str) -> str | None:
absolute = ROOT / path
if not absolute.is_file():
return None
return hashlib.sha256(absolute.read_bytes()).hexdigest()
def refresh_modules(backlog: dict) -> None:
states = {task["id"]: task["status"] for task in backlog.get("tasks", [])}
for module in backlog.get("workflowModules", []):
task_ids = module.get("taskIds", [])
if not task_ids:
continue
task_states = [states.get(task_id, "pending") for task_id in task_ids]
if all(state == "implemented" for state in task_states):
module["status"] = "implemented"
elif any(state == "active" for state in task_states):
module["status"] = "active"
elif any(state == "blocked" for state in task_states):
module["status"] = "blocked"
else:
module["status"] = "partial"
def write_status(backlog: dict, status: str, **extra: object) -> None:
tasks = backlog.get("tasks", [])
modules = backlog.get("workflowModules", [])
write_json(STATUS, {
"schemaVersion": 1,
"pid": os.getpid(),
"status": status,
"updatedAt": now(),
"tasks": {
"total": len(tasks),
"implemented": sum(task.get("status") == "implemented" for task in tasks),
"active": [task["id"] for task in tasks if task.get("status") == "active"],
"pending": sum(task.get("status") == "pending" for task in tasks),
"blocked": sum(task.get("status") == "blocked" for task in tasks),
},
"workflowModules": {
"total": len(modules),
"implemented": sum(module.get("status") == "implemented" for module in modules),
"active": [module["id"] for module in modules if module.get("status") == "active"],
"blocked": [module["id"] for module in modules if module.get("status") == "blocked"],
},
**extra,
})
def prompt(task: dict) -> str:
return f"""Implement exactly one Web Blender product workflow task in {ROOT}.
Task: {task['id']} - {task['title']}
Commands to expose: {', '.join(task['commands'])}
Owned paths: {', '.join(task['ownedPaths'])}
Implementation requirement: {task['implementation']}
Rules:
- Follow the repository AGENTS.md minimal-context instructions first.
- Preserve all existing dirty changes. Do not revert or rewrite unrelated work.
- Read only the owned paths and their direct imported types/helpers.
- Implement production behavior through the existing Blender Main/WASM command routes; do not simulate Blender behavior in JavaScript.
- Do not run tests, builds, typecheck, lint, Chromium, full validators, or coverage binding. The user explicitly deferred verification to real usage.
- Do not modify docs/EXECUTION_QUEUE.md, nextTask.md, source coverage ledgers, manifests, or this backlog.
- Do not commit or push. The outer unattended scheduler owns rotation and repository handoff.
- Finish the implementation in this turn. Do not stop at a plan.
- At the end write {task['resultPath']} as JSON with schemaVersion=1, task={task['id']!r}, status='implemented', changedFiles, and a concise summary.
"""
def run_task(task: dict, heartbeat_seconds: float) -> tuple[int, bool]:
global ACTIVE_PROCESS
before = {path: file_hash(path) for path in task["ownedPaths"]}
result_path = ROOT / task["resultPath"]
result_path.unlink(missing_ok=True)
log_path = LOG_ROOT / f"{task['id']}.codex.jsonl"
LOG_ROOT.mkdir(parents=True, exist_ok=True)
command = [
"codex", "exec", "--approve-for-me", "--sandbox", "workspace-write",
"--ephemeral", "--cd", str(ROOT), "--json", "-",
]
with log_path.open("w", encoding="utf-8") as log:
process = subprocess.Popen(
command,
cwd=ROOT,
stdin=subprocess.PIPE,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
ACTIVE_PROCESS = process
assert process.stdin is not None
process.stdin.write(prompt(task))
process.stdin.close()
while process.poll() is None:
write_status(read_json(BACKLOG), "implementing", activeTask=task["id"], childPid=process.pid, log=str(log_path.relative_to(ROOT)))
time.sleep(heartbeat_seconds)
code = process.returncode or 0
ACTIVE_PROCESS = None
after = {path: file_hash(path) for path in task["ownedPaths"]}
changed = any(before[path] != after[path] for path in before)
result_ok = False
if result_path.is_file():
try:
result = read_json(result_path)
result_ok = result.get("task") == task["id"] and result.get("status") == "implemented"
except json.JSONDecodeError:
pass
return code, changed and result_ok
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="run at most one task")
parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--heartbeat-seconds", type=float, default=5.0)
args = parser.parse_args()
if args.max_attempts < 1 or args.heartbeat_seconds <= 0:
parser.error("limits must be positive")
acquire_lock()
PID.write_text(f"{os.getpid()}\n", encoding="ascii")
stopped = False
def stop(signum: int, _frame: object) -> None:
nonlocal stopped
stopped = True
if ACTIVE_PROCESS and ACTIVE_PROCESS.poll() is None:
try:
os.killpg(ACTIVE_PROCESS.pid, signum)
except ProcessLookupError:
pass
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
try:
ran = 0
while not stopped:
backlog = read_json(BACKLOG)
for task in backlog.get("tasks", []):
if task.get("status") == "active":
task["status"] = "pending"
refresh_modules(backlog)
pending = next((task for task in backlog.get("tasks", []) if task.get("status") == "pending"), None)
if pending is None:
refresh_modules(backlog)
write_json(BACKLOG, backlog)
blocked = [task for task in backlog.get("tasks", []) if task.get("status") == "blocked"]
write_status(backlog, "blocked" if blocked else "implementation_complete")
return 2 if blocked else 0
pending["status"] = "active"
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["startedAt"] = now()
refresh_modules(backlog)
write_json(BACKLOG, backlog)
write_status(backlog, "implementing", activeTask=pending["id"])
code, implemented = run_task(pending, args.heartbeat_seconds)
backlog = read_json(BACKLOG)
task = next(item for item in backlog["tasks"] if item["id"] == pending["id"])
if code == 0 and implemented:
task["status"] = "implemented"
task["completedAt"] = now()
task.pop("lastError", None)
elif int(task.get("attempts", 0)) >= args.max_attempts:
task["status"] = "blocked"
task["lastError"] = f"codex exit={code}; implementation result or owned-path change missing"
else:
task["status"] = "pending"
task["lastError"] = f"codex exit={code}; retry scheduled"
refresh_modules(backlog)
write_json(BACKLOG, backlog)
ran += 1
if args.once and ran >= 1:
write_status(backlog, "idle", lastTask=pending["id"], exitCode=code)
return 0 if implemented else 2
finally:
PID.unlink(missing_ok=True)
LOCK.unlink(missing_ok=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -13,6 +13,7 @@ import json
import statistics
import re
import time
from datetime import datetime
from pathlib import Path
from wbv2_runtime import CONTROL_ROOT, ROOT, live_status, read_json
@@ -39,15 +40,22 @@ def main() -> int:
"watchdogStatus": read_json(CONTROL_ROOT / "my-tools-watchdog.json"),
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
}
status["progress"] = project_progress()
watchdog = status["myTools"]["watchdogStatus"]
watchdog_pid = watchdog.get("pid") if isinstance(watchdog, dict) else None
watchdog_alive = isinstance(watchdog_pid, int) and (Path("/proc") / str(watchdog_pid)).exists()
status["myTools"]["watchdogAlive"] = watchdog_alive
product = status["progress"].get("productMigration", {})
product_pid = product.get("pid")
product_alive = isinstance(product_pid, int) and (Path("/proc") / str(product_pid)).exists()
status["myTools"]["productMigrationAlive"] = product_alive
status["unattendedState"] = (
"PLANNING" if status.get("state") == "IDLE" and watchdog_alive and watchdog.get("status") == "waiting_for_source_batch"
"IMPLEMENTING" if product_alive and product.get("status") == "implementing"
else "COMPLETE" if product.get("status") == "implementation_complete"
else "BLOCKED" if product.get("status") == "blocked"
else "PLANNING" if status.get("state") == "IDLE" and watchdog_alive and watchdog.get("status") == "waiting_for_source_batch"
else status.get("state", "UNKNOWN")
)
status["progress"] = project_progress()
status["fastPath"] = fast_path_status(status)
if args.json:
print(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
@@ -74,11 +82,16 @@ def main() -> int:
print(f" supervisor: pid={supervisor.get('pid') or 'none'} alive={supervisor.get('alive', False)} state={supervisor.get('state') or 'unknown'}")
watchdog = status.get("myTools", {}).get("watchdogStatus", {})
print(f" watchdog: pid={watchdog.get('pid') or 'none'} alive={status.get('myTools', {}).get('watchdogAlive', False)} status={watchdog.get('status') or 'unknown'} reason={watchdog.get('reason') or 'none'} task={watchdog.get('task') or 'none'}")
print(f" product migration: pid={product.get('pid') or 'none'} alive={product_alive} status={product.get('status') or 'not-started'} active={product.get('activeTask') or 'none'}")
print(f" latest handoff: {status.get('latestHandoff') or 'none'}")
print(f" local HEAD: {status.get('localHead') or 'unknown'}")
for line in format_progress(status["progress"], status["fastPath"]):
print(line)
if status.get("unattendedState") == "PLANNING":
if status.get("unattendedState") == "IMPLEMENTING":
print(" message: IMPLEMENTING: one workflow task is being edited; verification is deferred to user operation.")
elif status.get("unattendedState") == "COMPLETE":
print(" message: COMPLETE: the Web workflow implementation backlog is closed; user-reported defects become focused follow-up tasks.")
elif status.get("unattendedState") == "PLANNING":
print(" message: PLANNING: watchdog heartbeat is active; exact source gaps are queued without false coverage.")
else:
print(f" message: {status.get('message', '')}")
@@ -109,6 +122,8 @@ def _queue_task() -> str | None:
def project_progress() -> dict:
"""Summarize progress from existing receipts without running any checks."""
inventory = _read_json(CONTROL_ROOT / "dynamic-inventory.json")
product_backlog = _read_json(CONTROL_ROOT / "web-product-backlog.json")
product_status = _read_json(CONTROL_ROOT / "web-product-migration-status.json")
total = int(inventory.get("summary", {}).get("unitCount", 0) or 0)
verified = int(inventory.get("coverage", {}).get("verifiedUnits", 0) or 0)
unverified = int(inventory.get("coverage", {}).get("unverifiedUnits", max(0, total - verified)) or 0)
@@ -166,6 +181,20 @@ def project_progress() -> dict:
stage_durations.setdefault(str(index + 1), []).append(duration)
stage_medians = {stage: round(statistics.median(values)) for stage, values in stage_durations.items() if values}
historical = max(historical_candidates, default=("", {}), key=lambda item: item[0])[1]
workflow_modules = product_backlog.get("workflowModules", []) if isinstance(product_backlog.get("workflowModules"), list) else []
product_tasks = product_backlog.get("tasks", []) if isinstance(product_backlog.get("tasks"), list) else []
workflow_implemented = sum(module.get("status") == "implemented" for module in workflow_modules)
task_implemented = sum(task.get("status") == "implemented" for task in product_tasks)
completed_durations = []
for task in product_tasks:
try:
started = datetime.fromisoformat(task["startedAt"])
completed = datetime.fromisoformat(task["completedAt"])
except (KeyError, TypeError, ValueError):
continue
completed_durations.append((completed - started).total_seconds())
remaining_tasks = len(product_tasks) - task_implemented
product_remaining_seconds = statistics.median(completed_durations) * remaining_tasks if completed_durations else None
return {
"scope": "dynamic-source-inventory",
"currentTask": _queue_task(),
@@ -192,6 +221,23 @@ def project_progress() -> dict:
"effectivePending": historical.get("effectivePending", 6609),
"reproductionRequired": True,
},
"productMigration": {
**product_status,
"strategy": product_backlog.get("strategy"),
"verificationPolicy": product_backlog.get("verificationPolicy"),
"workflowTotal": len(workflow_modules),
"workflowImplemented": workflow_implemented,
"workflowPercent": round(workflow_implemented / len(workflow_modules) * 100, 2) if workflow_modules else 0.0,
"workflowActive": [module.get("id") for module in workflow_modules if module.get("status") == "active"],
"workflowPartial": [module.get("id") for module in workflow_modules if module.get("status") == "partial"],
"workflowBlocked": [module.get("id") for module in workflow_modules if module.get("status") == "blocked"],
"taskTotal": len(product_tasks),
"taskImplemented": task_implemented,
"taskPending": sum(task.get("status") == "pending" for task in product_tasks),
"taskActive": [task.get("id") for task in product_tasks if task.get("status") == "active"],
"taskBlocked": [task.get("id") for task in product_tasks if task.get("status") == "blocked"],
"estimatedRemainingMinutes": round(product_remaining_seconds / 60, 1) if product_remaining_seconds is not None else None,
},
}
@@ -201,6 +247,7 @@ def fast_path_status(status: dict) -> dict:
cache = _read_json(CONTROL_ROOT / "context-cache.json")
ledger = _read_json(CONTROL_ROOT / "failure-ledger.json")
planner = _read_json(CONTROL_ROOT / "auto-planner.json")
product_backlog = _read_json(CONTROL_ROOT / "web-product-backlog.json")
modules = wasm_module_status()
return {
"batch": {
@@ -225,7 +272,7 @@ def fast_path_status(status: dict) -> dict:
"nextAction": planner.get("nextAction"),
},
"commandBudget": {"maxCommands": 48, "maxReadCommands": 16},
"verificationPolicy": "incremental-context-only-until-final-audit",
"verificationPolicy": product_backlog.get("verificationPolicy", "incremental-context-only-until-final-audit"),
"wasmModules": modules,
}
@@ -277,17 +324,17 @@ def format_progress(progress: dict, fast_path: dict | None = None) -> list[str]:
context_cache = fast_path.get("contextCache", {})
ledger = fast_path.get("failureLedger", {})
planner = fast_path.get("autoPlanner", {})
product = progress.get("productMigration", {})
product_estimate = product.get("estimatedRemainingMinutes")
return [
"",
"Project migration progress",
f" dynamic units: {progress.get('verifiedUnits', 0)}/{progress.get('totalUnits', 0)} verified ({progress.get('verifiedPercent', 0)}%), remaining={progress.get('unverifiedUnits', 0)}",
"Web product implementation progress",
f" workflow modules: {product.get('workflowImplemented', 0)}/{product.get('workflowTotal', 0)} implemented ({product.get('workflowPercent', 0)}%), active={','.join(product.get('workflowActive', [])) or 'none'}, partial={','.join(product.get('workflowPartial', [])) or 'none'}",
f" implementation tasks: {product.get('taskImplemented', 0)}/{product.get('taskTotal', 0)} done, active={','.join(product.get('taskActive', [])) or 'none'}, pending={product.get('taskPending', 0)}, blocked={','.join(product.get('taskBlocked', [])) or 'none'}",
f" implementation estimate: {product_estimate if product_estimate is not None else 'learning'} minutes remaining; verification={product.get('verificationPolicy') or 'deferred'}",
f" source traceability reference: {progress.get('verifiedUnits', 0)}/{progress.get('totalUnits', 0)} bound; excluded from product completion denominator",
f" task plan: {plan.get('done', 0)}/{plan.get('tasks', 0)} done, active={','.join(plan.get('active', [])) or 'none'}, blocked={plan.get('blocked', 0)}",
f" historical ledger: {progress.get('historicalInventory', {}).get('effectiveCompleted', 0)}/{progress.get('historicalInventory', {}).get('total', 6900)} effective, pending={progress.get('historicalInventory', {}).get('effectivePending', 0)} (full reproduction required)",
f" observed batches: {progress.get('observedBatches', 0)} total, productive={progress.get('productiveBatches', 0)}, reused={progress.get('reusedBatches', 0)}",
f" efficiency: median +{progress.get('medianIncrementalUnitsPerBatch', 0)} units/batch, {progress.get('medianIncrementalUnitsPerHour', 0)} units/hour",
f" stage medians: {progress.get('stageMedianDurationMs') or 'n/a'} ms (1=build, 2=typecheck, 3=web build, 4=Chromium)",
f" estimate: remaining={estimate_text}, total={total_text}",
f" estimate basis: {progress.get('estimateBasis', 'n/a')}",
f" old verification estimate: remaining={estimate_text}, total={total_text} (reference only; strategy retired)",
f" fast path: batch={batch.get('batchId') or 'none'} selected={batch.get('selectedRecordCount', 0)} completed={batch.get('completed', False)}",
f" context cache: {context_cache.get('status', 'MISSING')} task={context_cache.get('task') or 'none'} inputs={context_cache.get('inputCount', 0)}; failure ledger entries={ledger.get('entryCount', 0)} hydratedLogs={ledger.get('hydratedLogs', 0)}",
f" command budget: max={fast_path.get('commandBudget', {}).get('maxCommands', 48)}, read/exploration max={fast_path.get('commandBudget', {}).get('maxReadCommands', 16)}; verification={fast_path.get('verificationPolicy', 'n/a')}",

View File

@@ -10,6 +10,7 @@ WATCHDOG_INTERVAL="${WBV2_WATCHDOG_INTERVAL:-10}"
WATCHDOG_RESTART_DELAY="${WBV2_WATCHDOG_RESTART_DELAY:-5}"
AUTO_AUTHORIZE="${WBV2_AUTO_AUTHORIZE:-1}"
WAIT_ON_EMPTY="${WBV2_WAIT_ON_EMPTY:-1}"
MIGRATION_MODE="${WBV2_MIGRATION_MODE:-product}"
# Keep repository evidence on disk while placing compiler/transient caches in
# RAM. Eight compile jobs is the repository's stability limit.
@@ -75,5 +76,25 @@ start_watchdog() {
printf 'watchdog launch pid: %s\n' "$!"
}
start_scheduler
start_watchdog
start_product_migration() {
local pid_file="$CONTROL_DIR/web-product-migration.pid"
if test -f "$pid_file"; then
local product_pid
product_pid="$(tr -d '[:space:]' < "$pid_file")"
if test -n "$product_pid" && kill -0 "$product_pid" 2>/dev/null; then
printf 'product migration already running: %s\n' "$product_pid"
return
fi
mv "$pid_file" "$pid_file.stale-$(date +%s)"
fi
nohup setsid python3 "$ROOT_DIR/my-tools/run_web_product_migration.py" \
> "$CONTROL_DIR/web-product-migration.stdout.log" 2>&1 < /dev/null &
printf 'product migration launch pid: %s\n' "$!"
}
if test "$MIGRATION_MODE" = "source-verification"; then
start_scheduler
start_watchdog
else
start_product_migration
fi

View File

@@ -0,0 +1,74 @@
{
"schemaVersion": 1,
"strategy": "implementation_first_user_feedback",
"verificationPolicy": "deferred_until_user_reports",
"sourceInventory": {
"unitCount": 3745,
"role": "traceability_only",
"completionDenominator": false
},
"workflowModules": [
{ "id": "PROJECT", "title": "Project open, save and recovery", "status": "implemented" },
{ "id": "WORKSPACE", "title": "Web workspace and viewport", "status": "implemented" },
{ "id": "OBJECT", "title": "Object and scene graph", "status": "implemented" },
{ "id": "MESH", "title": "Mesh modeling and UV", "status": "implemented" },
{ "id": "NONMESH", "title": "Non-mesh geometry", "status": "partial", "taskIds": ["WEBUI-NONMESH-ADVANCED", "WEBUI-FONT-ASSETS"] },
{ "id": "MATERIAL", "title": "Materials, shaders and textures", "status": "implemented" },
{ "id": "MODIFIER", "title": "Modifiers and geometry nodes", "status": "implemented" },
{ "id": "ANIMATION", "title": "Animation and NLA", "status": "partial", "taskIds": ["WEBUI-NLA-STRIP"] },
{ "id": "PAINT", "title": "Sculpt, paint and Grease Pencil", "status": "partial", "taskIds": ["WEBUI-SCULPT"] },
{ "id": "RENDER", "title": "Render, light, camera and world", "status": "implemented" },
{ "id": "COMPOSITOR", "title": "Compositor", "status": "implemented" },
{ "id": "SIMULATION", "title": "Physics and simulation", "status": "implemented" },
{ "id": "MEDIA", "title": "Media, audio and sequencer", "status": "implemented" },
{ "id": "ASSET_IO", "title": "Assets, libraries and interchange", "status": "implemented" },
{ "id": "STORAGE", "title": "Browser storage and recovery", "status": "implemented" },
{ "id": "RUNTIME", "title": "Worker, performance and modular WASM lifecycle", "status": "implemented" }
],
"tasks": [
{
"id": "WEBUI-NONMESH-ADVANCED",
"module": "NONMESH",
"title": "Expose advanced curve, surface and non-mesh deletion commands",
"status": "pending",
"attempts": 0,
"commands": ["deleteNonMeshData", "setCurveHandle", "setCurveSplines", "setSurfaceTopology"],
"ownedPaths": ["web/app/src/app/App.tsx", "web/protocol/nonmesh-geometry-commands.ts"],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json",
"implementation": "Add context-aware user controls that construct the existing typed commands from the active non-mesh snapshot. Reuse current curve/surface state; do not create a generic JSON command console."
},
{
"id": "WEBUI-FONT-ASSETS",
"module": "NONMESH",
"title": "Expose advanced font layout and browser font import commands",
"status": "pending",
"attempts": 0,
"commands": ["setFontAdvanced", "importVFont", "setFontLinks"],
"ownedPaths": ["web/app/src/app/App.tsx", "web/protocol/nonmesh-geometry-commands.ts", "web/app/src/fonts/external-vfont-import.ts"],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json",
"implementation": "Add usable font layout/link controls and a TTF/OTF/PFB file picker that uses the existing browser font import adapter and native command route."
},
{
"id": "WEBUI-SCULPT",
"module": "PAINT",
"title": "Expose native sculpt stroke and sculpt mesh attribute commands",
"status": "pending",
"attempts": 0,
"commands": ["sculptStroke", "setSculptMeshAttributes"],
"ownedPaths": ["web/app/src/app/App.tsx", "web/protocol/paint.ts"],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-SCULPT-result.json",
"implementation": "Add a compact sculpt panel for the active mesh, including brush controls, a deliberate apply action, and the existing native sculpt attribute route."
},
{
"id": "WEBUI-NLA-STRIP",
"module": "ANIMATION",
"title": "Expose native NLA strip movement",
"status": "pending",
"attempts": 0,
"commands": ["moveNLAStrip"],
"ownedPaths": ["web/app/src/app/App.tsx", "web/protocol/animation-render-commands.ts", "web/protocol/nla.ts"],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NLA-STRIP-result.json",
"implementation": "Add context-aware NLA track/strip selection and frame-start editing to the Timeline, using the existing typed moveNLAStrip command."
}
]
}