Files
FreeCADWasmDev/tools/sync_web_task_state.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

308 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Maintain the authoritative task-state ledger and its current-task projection."""
from __future__ import annotations
import hashlib
import argparse
import json
import re
from datetime import date
from pathlib import Path
from web_context import predecessor_ids, write_context_manifest, write_context_summary
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
TASKS = DOCS / "tasks"
CURRENT = DOCS / "current"
PROGRESS = CURRENT / "FREECAD_WEB_PROGRESS.md"
HANDOFF = CURRENT / "FREECAD_WEB_HANDOFF.md"
MIN_CONTEXT = CURRENT / "FREECAD_WEB_CONTEXT_MIN.md"
NEXT_TASK = ROOT / "nextTask.md"
MATRIX = DOCS / "web-parity-matrix.json"
STATE = CURRENT / "task-state.json"
CARD_ID = re.compile(r"^#\s+(FW-[^:: ]+)", re.MULTILINE)
CARD_STATUS = re.compile(r"^-\s*状态:`([^`]+)`", re.MULTILINE)
ACTIVE = re.compile(r"^\|\s*当前任务\s*\|\s*`([^`]+)`", re.MULTILINE)
def update_current_projection(active_task: str) -> None:
"""Keep the two human-readable current pointers aligned after --active."""
progress = PROGRESS.read_text(encoding="utf-8")
progress = re.sub(
r"(^\|\s*当前任务\s*\|\s*)`[^`]+`(\s*\|$)",
rf"\g<1>`{active_task}`\g<2>",
progress,
flags=re.MULTILINE,
)
PROGRESS.write_text(progress, encoding="utf-8")
handoff = HANDOFF.read_text(encoding="utf-8")
handoff = re.sub(
r"(^\|\s*`status`\s*\|\s*)`[^`]+`(\s*\|$)",
r"\g<1>`in_progress`\g<2>",
handoff,
flags=re.MULTILINE,
)
handoff = re.sub(
r"(^\|\s*`active_task`\s*\|\s*)`[^`]+`(\s*\|$)",
rf"\g<1>`{active_task}`\g<2>",
handoff,
flags=re.MULTILINE,
)
card_path = None
card_text = None
for candidate in TASKS.rglob("*.md"):
text = candidate.read_text(encoding="utf-8", errors="replace")
if re.search(r"^#\s+" + re.escape(active_task) + r"(?::|:|\s)", text, re.MULTILINE):
card_path = "../tasks/" + str(candidate.relative_to(TASKS))
card_text = text
break
card_link = f"[`{active_task}`]({card_path or '../tasks/'})"
handoff = re.sub(
r"(^\|\s*`task_card`\s*\|\s*).*$",
rf"\g<1>{card_link}",
handoff,
flags=re.MULTILINE,
)
context_link = f"[`context/{active_task}.json`](context/{active_task}.json)"
handoff = re.sub(
r"(^\|\s*`context_manifest`\s*\|\s*).*$",
rf"\g<1>{context_link}",
handoff,
flags=re.MULTILINE,
)
if card_text:
heading = re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE)
contract = re.search(r"^-\s*合同:(.+)$", card_text, re.MULTILINE)
predecessors = predecessor_ids(card_text)
replacements = {
"scope": heading.group(1).strip() if heading else active_task,
"direct_prerequisites": ", ".join(f"`{item}`" for item in predecessors) or "无",
"next_action": contract.group(1).strip() if contract else "执行当前任务卡中的合同",
}
for field, value in replacements.items():
handoff = re.sub(
rf"^\|\s*`{field}`\s*\|.*$",
f"| `{field}` | {value} |",
handoff,
flags=re.MULTILINE,
)
HANDOFF.write_text(handoff, encoding="utf-8")
if card_text:
progress = PROGRESS.read_text(encoding="utf-8")
heading = re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE)
if heading:
progress = re.sub(
r"(^\|\s*目标\s*\|\s*).*$",
rf"\g<1>{heading.group(1).strip()} |",
progress,
flags=re.MULTILINE,
)
PROGRESS.write_text(progress, encoding="utf-8")
# nextTask.md is a continuation entrypoint, not an independent state
# source. Keep its checkpoint synchronized so a fresh session never starts
# from a stale historical task.
next_text = NEXT_TASK.read_text(encoding="utf-8")
module = next((part for part in (card_path or "").split("/") if part not in {"..", "tasks", "p6"}), "unknown")
module_display = {row["module"].lower(): row["module"] for row in json.loads(MATRIX.read_text(encoding="utf-8")).get("rows", [])}.get(module.lower(), module)
task_scope = heading.group(1).strip() if card_text and (heading := re.search(r"^#\s+[^::]+[::]\s*(.+)$", card_text, re.MULTILINE)) else active_task
current_module_status = json.loads(STATE.read_text(encoding="utf-8")).get("module_status", {}) if STATE.exists() else {}
completed_modules = [name for name, status in current_module_status.items() if status == "completed"]
next_text = re.sub(r"(^- 活动任务:)`[^`]+`", rf"\g<1>`{active_task}`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 任务:).*$", rf"\g<1>{task_scope}", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 状态:)`[^`]+`", "- 状态:`in_progress`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 当前模块:).*$", rf"\g<1>`{module_display}`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 已完成模块:).*$", rf"\g<1>{', '.join(completed_modules) or '无'}", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 待完成模块:).*$", rf"\g<1>{', '.join(name for name, status in current_module_status.items() if status != 'completed') or '无'}", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"然后执行 `[^`]+`。", rf"然后执行当前账本的 `{active_task}`。", next_text, count=1)
NEXT_TASK.write_text(next_text, encoding="utf-8")
def update_terminal_projection(module_status: dict[str, str]) -> None:
"""Project the legitimate no-active-task state after the final module gate."""
progress = PROGRESS.read_text(encoding="utf-8")
progress = re.sub(r"(^\|\s*当前任务\s*\|\s*)`[^`]+`(\s*\|$)", r"\g<1>`none`\g<2>", progress, flags=re.MULTILINE)
progress = re.sub(r"(^\|\s*状态\s*\|\s*)`[^`]+`(\s*\|$)", r"\g<1>`completed`\g<2>", progress, flags=re.MULTILINE)
progress = re.sub(r"(^\|\s*目标\s*\|\s*).*$", r"\g<1>全部 Web 对标模块已完成 |", progress, flags=re.MULTILINE)
PROGRESS.write_text(progress, encoding="utf-8")
handoff = HANDOFF.read_text(encoding="utf-8")
terminal_fields = {
"active_task": "`none`",
"status": "`completed`",
"scope": "全部 Web 对标模块已完成",
"direct_prerequisites": "无",
"task_card": "无",
"context_manifest": "无",
"next_action": "无待执行叶子任务;新工作必须先创建任务卡并显式领取。",
}
for field, value in terminal_fields.items():
handoff = re.sub(rf"^\|\s*`{field}`\s*\|.*$", f"| `{field}` | {value} |", handoff, flags=re.MULTILINE)
HANDOFF.write_text(handoff, encoding="utf-8")
completed_modules = [name for name, status in module_status.items() if status == "completed"]
next_text = NEXT_TASK.read_text(encoding="utf-8")
next_text = re.sub(r"(^- 活动任务:)`[^`]+`", r"\g<1>`无`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 任务:).*$", r"\g<1>全部 Web 对标模块已完成", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 状态:)`[^`]+`", r"\g<1>`completed`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 当前模块:).*$", r"\g<1>`无`", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 已完成模块:).*$", rf"\g<1>{', '.join(completed_modules)}", next_text, count=1, flags=re.MULTILINE)
next_text = re.sub(r"(^- 待完成模块:).*$", r"\g<1>无", next_text, count=1, flags=re.MULTILINE)
NEXT_TASK.write_text(next_text, encoding="utf-8")
MIN_CONTEXT.write_text(
"# FreeCAD Web 最小续接上下文\n\n"
"- 当前任务:`none`(`completed`)\n"
"- 全部 Web 对标模块已通过各自 Y gate;当前没有活动叶子任务。\n"
"- 新工作必须先创建任务卡、写入账本并生成独立 context manifest。\n",
encoding="utf-8",
)
def scan_cards() -> dict[str, str]:
result: dict[str, str] = {}
for path in sorted(TASKS.rglob("*.md")):
if path.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}:
continue
text = path.read_text(encoding="utf-8", errors="replace")
card = CARD_ID.search(text)
status = CARD_STATUS.search(text)
if not card or not status:
continue
result[card.group(1)] = status.group(1)
return result
def update_card_status_projections(statuses: dict[str, str]) -> None:
"""Keep human-readable card status lines aligned with the ledger.
This is deliberately limited to the single status field; result sections
and generated card structure remain untouched.
"""
for path in sorted(TASKS.rglob("*.md")):
if path.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}:
continue
text = path.read_text(encoding="utf-8", errors="replace")
card = CARD_ID.search(text)
if not card or card.group(1) not in statuses:
continue
text = re.sub(
r"(^-\s*状态:)`[^`]+`",
rf"\g<1>`{statuses[card.group(1)]}`",
text,
count=1,
flags=re.MULTILINE,
)
path.write_text(text, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--set",
dest="updates",
action="append",
default=[],
metavar="TASK=STATUS",
help="change one task status; repeatable",
)
parser.add_argument("--active", help="set the one active task")
parser.add_argument("--clear-active", action="store_true", help="clear the active task after the final executable leaf completes")
args = parser.parse_args()
if args.active and args.clear_active:
parser.error("--active and --clear-active are mutually exclusive")
cards = scan_cards()
if STATE.exists():
state = json.loads(STATE.read_text(encoding="utf-8"))
statuses = {
task_id: status
for task_id, status in state.get("tasks", {}).items()
if task_id in cards
}
# New generated cards enter the ledger as planned; existing statuses
# are never inferred from a card projection.
for task_id in cards:
statuses.setdefault(task_id, "planned")
active_task = None if args.clear_active else (args.active or state.get("active_task"))
module_status = dict(state.get("module_status", {}))
else:
statuses = cards
progress_text = PROGRESS.read_text(encoding="utf-8")
active_match = ACTIVE.search(progress_text)
active_task = None if args.clear_active else (args.active or (active_match.group(1) if active_match else None))
module_status = {}
explicit_tasks = set()
for update in args.updates:
if "=" not in update:
parser.error(f"invalid --set value: {update!r}; expected TASK=STATUS")
task_id, status = update.split("=", 1)
if status not in {"planned", "in_progress", "blocked", "completed"}:
parser.error(f"invalid task status: {status!r}")
statuses[task_id] = status
explicit_tasks.add(task_id)
if active_task:
statuses[active_task] = "in_progress"
# A continuation queue has exactly one runnable card. Automatically clear
# stale parent/previous projections when a new active card is claimed.
if args.active:
active_card_path = None
for candidate in TASKS.rglob("*.md"):
if candidate.name in {"INDEX.md", "TASK_CARD_TEMPLATE.md"}:
continue
if re.search(r"^#\s+" + re.escape(active_task) + r"(?::|:|\s)", candidate.read_text(encoding="utf-8", errors="replace"), re.MULTILINE):
active_card_path = candidate
break
if active_card_path is None:
parser.error(f"unknown active task: {active_task}")
if active_card_path.parent == TASKS / "p6":
parser.error(f"module package is not executable; choose a leaf task below it: {active_task}")
if statuses.get(active_task) == "blocked" and active_task not in explicit_tasks:
parser.error(f"blocked task requires explicit recovery: --set {active_task}=planned --active {active_task}")
for task_id, status in list(statuses.items()):
if task_id != active_task and status == "in_progress" and task_id not in explicit_tasks:
statuses[task_id] = "planned"
if args.clear_active:
for task_id, status in list(statuses.items()):
if status == "in_progress" and task_id not in explicit_tasks:
statuses[task_id] = "planned"
if args.active:
update_current_projection(active_task)
matrix = json.loads(MATRIX.read_text(encoding="utf-8"))
if not module_status:
module_status = {
row["module"]: row["web_status"] for row in matrix.get("rows", [])
}
state = {
"schema": "freecad-web-task-state/v1",
"generated_at": date.today().isoformat(),
"active_task": active_task,
"default_status": "planned",
"source_matrix": str(MATRIX.relative_to(ROOT)),
"source_matrix_sha256": hashlib.sha256(MATRIX.read_bytes()).hexdigest(),
"module_status": module_status,
"tasks": dict(sorted(statuses.items())),
"counts": {
status: sum(value == status for value in statuses.values())
for status in ("planned", "in_progress", "blocked", "completed")
},
}
update_card_status_projections(statuses)
STATE.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if active_task:
write_context_manifest(active_task)
write_context_summary(active_task)
elif args.clear_active:
update_terminal_projection(module_status)
print(f"wrote {STATE.relative_to(ROOT)}: {len(statuses)} tasks, active={active_task}")
if __name__ == "__main__":
main()