feat(web): switch unattended migration to product workflows
This commit is contained in:
68
docs/WEB_PRODUCT_MIGRATION_STRATEGY.md
Normal file
68
docs/WEB_PRODUCT_MIGRATION_STRATEGY.md
Normal 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.
|
||||||
252
my-tools/run_web_product_migration.py
Normal file
252
my-tools/run_web_product_migration.py
Normal 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())
|
||||||
@@ -13,6 +13,7 @@ import json
|
|||||||
import statistics
|
import statistics
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from wbv2_runtime import CONTROL_ROOT, ROOT, live_status, read_json
|
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"),
|
"watchdogStatus": read_json(CONTROL_ROOT / "my-tools-watchdog.json"),
|
||||||
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
|
"schedulerWrapperStatus": read_json(CONTROL_ROOT / "my-tools-scheduler.json"),
|
||||||
}
|
}
|
||||||
|
status["progress"] = project_progress()
|
||||||
watchdog = status["myTools"]["watchdogStatus"]
|
watchdog = status["myTools"]["watchdogStatus"]
|
||||||
watchdog_pid = watchdog.get("pid") if isinstance(watchdog, dict) else None
|
watchdog_pid = watchdog.get("pid") if isinstance(watchdog, dict) else None
|
||||||
watchdog_alive = isinstance(watchdog_pid, int) and (Path("/proc") / str(watchdog_pid)).exists()
|
watchdog_alive = isinstance(watchdog_pid, int) and (Path("/proc") / str(watchdog_pid)).exists()
|
||||||
status["myTools"]["watchdogAlive"] = watchdog_alive
|
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"] = (
|
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")
|
else status.get("state", "UNKNOWN")
|
||||||
)
|
)
|
||||||
status["progress"] = project_progress()
|
|
||||||
status["fastPath"] = fast_path_status(status)
|
status["fastPath"] = fast_path_status(status)
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
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'}")
|
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", {})
|
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" 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" latest handoff: {status.get('latestHandoff') or 'none'}")
|
||||||
print(f" local HEAD: {status.get('localHead') or 'unknown'}")
|
print(f" local HEAD: {status.get('localHead') or 'unknown'}")
|
||||||
for line in format_progress(status["progress"], status["fastPath"]):
|
for line in format_progress(status["progress"], status["fastPath"]):
|
||||||
print(line)
|
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.")
|
print(" message: PLANNING: watchdog heartbeat is active; exact source gaps are queued without false coverage.")
|
||||||
else:
|
else:
|
||||||
print(f" message: {status.get('message', '')}")
|
print(f" message: {status.get('message', '')}")
|
||||||
@@ -109,6 +122,8 @@ def _queue_task() -> str | None:
|
|||||||
def project_progress() -> dict:
|
def project_progress() -> dict:
|
||||||
"""Summarize progress from existing receipts without running any checks."""
|
"""Summarize progress from existing receipts without running any checks."""
|
||||||
inventory = _read_json(CONTROL_ROOT / "dynamic-inventory.json")
|
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)
|
total = int(inventory.get("summary", {}).get("unitCount", 0) or 0)
|
||||||
verified = int(inventory.get("coverage", {}).get("verifiedUnits", 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)
|
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_durations.setdefault(str(index + 1), []).append(duration)
|
||||||
stage_medians = {stage: round(statistics.median(values)) for stage, values in stage_durations.items() if values}
|
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]
|
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 {
|
return {
|
||||||
"scope": "dynamic-source-inventory",
|
"scope": "dynamic-source-inventory",
|
||||||
"currentTask": _queue_task(),
|
"currentTask": _queue_task(),
|
||||||
@@ -192,6 +221,23 @@ def project_progress() -> dict:
|
|||||||
"effectivePending": historical.get("effectivePending", 6609),
|
"effectivePending": historical.get("effectivePending", 6609),
|
||||||
"reproductionRequired": True,
|
"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")
|
cache = _read_json(CONTROL_ROOT / "context-cache.json")
|
||||||
ledger = _read_json(CONTROL_ROOT / "failure-ledger.json")
|
ledger = _read_json(CONTROL_ROOT / "failure-ledger.json")
|
||||||
planner = _read_json(CONTROL_ROOT / "auto-planner.json")
|
planner = _read_json(CONTROL_ROOT / "auto-planner.json")
|
||||||
|
product_backlog = _read_json(CONTROL_ROOT / "web-product-backlog.json")
|
||||||
modules = wasm_module_status()
|
modules = wasm_module_status()
|
||||||
return {
|
return {
|
||||||
"batch": {
|
"batch": {
|
||||||
@@ -225,7 +272,7 @@ def fast_path_status(status: dict) -> dict:
|
|||||||
"nextAction": planner.get("nextAction"),
|
"nextAction": planner.get("nextAction"),
|
||||||
},
|
},
|
||||||
"commandBudget": {"maxCommands": 48, "maxReadCommands": 16},
|
"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,
|
"wasmModules": modules,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,17 +324,17 @@ def format_progress(progress: dict, fast_path: dict | None = None) -> list[str]:
|
|||||||
context_cache = fast_path.get("contextCache", {})
|
context_cache = fast_path.get("contextCache", {})
|
||||||
ledger = fast_path.get("failureLedger", {})
|
ledger = fast_path.get("failureLedger", {})
|
||||||
planner = fast_path.get("autoPlanner", {})
|
planner = fast_path.get("autoPlanner", {})
|
||||||
|
product = progress.get("productMigration", {})
|
||||||
|
product_estimate = product.get("estimatedRemainingMinutes")
|
||||||
return [
|
return [
|
||||||
"",
|
"",
|
||||||
"Project migration progress",
|
"Web product implementation progress",
|
||||||
f" dynamic units: {progress.get('verifiedUnits', 0)}/{progress.get('totalUnits', 0)} verified ({progress.get('verifiedPercent', 0)}%), remaining={progress.get('unverifiedUnits', 0)}",
|
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" 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" old verification estimate: remaining={estimate_text}, total={total_text} (reference only; strategy retired)",
|
||||||
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" fast path: batch={batch.get('batchId') or 'none'} selected={batch.get('selectedRecordCount', 0)} completed={batch.get('completed', False)}",
|
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" 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')}",
|
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')}",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ WATCHDOG_INTERVAL="${WBV2_WATCHDOG_INTERVAL:-10}"
|
|||||||
WATCHDOG_RESTART_DELAY="${WBV2_WATCHDOG_RESTART_DELAY:-5}"
|
WATCHDOG_RESTART_DELAY="${WBV2_WATCHDOG_RESTART_DELAY:-5}"
|
||||||
AUTO_AUTHORIZE="${WBV2_AUTO_AUTHORIZE:-1}"
|
AUTO_AUTHORIZE="${WBV2_AUTO_AUTHORIZE:-1}"
|
||||||
WAIT_ON_EMPTY="${WBV2_WAIT_ON_EMPTY:-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
|
# Keep repository evidence on disk while placing compiler/transient caches in
|
||||||
# RAM. Eight compile jobs is the repository's stability limit.
|
# RAM. Eight compile jobs is the repository's stability limit.
|
||||||
@@ -75,5 +76,25 @@ start_watchdog() {
|
|||||||
printf 'watchdog launch pid: %s\n' "$!"
|
printf 'watchdog launch pid: %s\n' "$!"
|
||||||
}
|
}
|
||||||
|
|
||||||
start_scheduler
|
start_product_migration() {
|
||||||
start_watchdog
|
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
|
||||||
|
|||||||
74
tests/golden/WBV2/web-product-backlog.json
Normal file
74
tests/golden/WBV2/web-product-backlog.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user