#!/usr/bin/env python3 """Migrate a legacy ACK tasks.yaml into immutable task records. The default is a read-only dry run. ``--apply`` writes a sibling ``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces the task board with a small manifest that points at ``task-records/``. """ from __future__ import annotations import argparse import json import os import shutil import sys from pathlib import Path from typing import Any from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document def _atomic_write(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}") try: temporary.write_text(content, encoding="utf-8") os.replace(temporary, path) finally: if temporary.exists(): temporary.unlink() def _record_id(task_id: str) -> str: return f"EV-{task_id}-initial" def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]: value: dict[str, Any] = { "recordVersion": STORE_VERSION, "recordId": _record_id(task_id), "taskId": task_id, "parents": [], "kind": "task.snapshot", } if task is not None: value["task"] = task return value def build_records(board: dict[str, Any]) -> list[dict[str, Any]]: tasks = board.get("tasks") if not isinstance(tasks, list): raise TaskStoreError("legacy tasks 必须是列表") records: list[dict[str, Any]] = [] task_ids: set[str] = set() for task in tasks: if not isinstance(task, dict) or not isinstance(task.get("id"), str): raise TaskStoreError("每条 legacy task 必须包含字符串 id") task_id = task["id"] if task_id in task_ids: raise TaskStoreError(f"task id 重复: {task_id}") task_ids.add(task_id) records.append(_record(task_id, dict(task))) receipts = board.get("workerReceipts") or [] if not isinstance(receipts, list): raise TaskStoreError("workerReceipts 必须是列表") by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids} global_receipts: list[dict[str, Any]] = [] for receipt in receipts: if not isinstance(receipt, dict): raise TaskStoreError("workerReceipts 每项必须是对象") created_for = receipt.get("createdFor") task_id = created_for.get("taskId") if isinstance(created_for, dict) else None if isinstance(task_id, str) and task_id in by_task: by_task[task_id].append(receipt) else: global_receipts.append(receipt) for record in records: task_id = record["taskId"] if by_task[task_id]: record["workerReceipts"] = by_task[task_id] global_record = _record(GLOBAL_TASK_ID) if global_receipts: global_record["workerReceipts"] = global_receipts for field in ("deliveryRuns", "regressionRuns"): value = board.get(field) if value is not None: if not isinstance(value, list): raise TaskStoreError(f"{field} 必须是列表") global_record[field] = value if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")): records.append(global_record) return records def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]: board = parse_document(path) if "taskStore" in board: raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移") records = build_records(board) record_directory = directory or "task-records" relative = Path(record_directory) if relative.is_absolute() or ".." in relative.parts: raise TaskStoreError("records directory 必须是任务板目录下的相对路径") output_dir = path.parent / relative backup = path.with_name(f"{path.stem}.legacy{path.suffix}") if apply and backup.exists(): raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}") manifest = dict(board) for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"): manifest.pop(field, None) manifest["tasks"] = [] manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory} if apply: backup.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, backup) output_dir.mkdir(parents=True, exist_ok=True) for record in records: record_path = output_dir / f"{record['recordId']}.yaml" if record_path.exists(): raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}") _atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n") _atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") return { "mode": "apply" if apply else "dry-run", "tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]), "records": len(records), "recordsDirectory": str(output_dir), "backup": str(backup), "taskStore": manifest["taskStore"], } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml") parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板") parser.add_argument("--records-dir", help="任务记录目录,默认 task-records") args = parser.parse_args(argv) try: result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir) except (OSError, TaskStoreError) as exc: print(f"迁移失败: {exc}", file=sys.stderr) return 1 print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main())