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