#!/usr/bin/env python3 """Append one immutable ACK task record. The input task is a complete snapshot. The command never edits an existing record; it creates a new file with exclusive creation and lets the materializer report multiple heads when two workspaces branched from the same parent. """ from __future__ import annotations import argparse import json import os import secrets import sys from datetime import datetime, timezone from pathlib import Path from typing import Any from task_store import ( RECORD_ID_RE, STORE_VERSION, TASK_ID_RE, TaskStoreError, materialize_board, parse_document, ) def _read_json_mapping(path: Path, label: str) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise TaskStoreError(f"{label}读取失败: {path}") from exc if not isinstance(value, dict): raise TaskStoreError(f"{label}必须是对象") return value def _read_json_list(path: Path, label: str) -> list[dict[str, Any]]: try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise TaskStoreError(f"{label}读取失败: {path}") from exc if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw): raise TaskStoreError(f"{label}必须是对象列表") return raw def _auto_record_id(task_id: str) -> str: stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") return f"EV-{task_id}-{stamp}-{secrets.token_hex(4)}" def append_record( board_path: Path, task: dict[str, Any], *, task_id: str, record_id: str | None, parents: list[str], kind: str, worker_receipts: list[dict[str, Any]] | None = None, delivery_runs: list[dict[str, Any]] | None = None, regression_runs: list[dict[str, Any]] | None = None, ) -> Path: manifest = parse_document(board_path) if not isinstance(manifest.get("taskStore"), dict): raise TaskStoreError("任务板没有启用 taskStore;先运行 migrate_tasks.py --apply") if TASK_ID_RE.fullmatch(task_id) is None: raise TaskStoreError("taskId 格式无效") if task.get("id") != task_id: raise TaskStoreError("输入 task.id 必须与 --task-id 相同") if not isinstance(kind, str) or not kind.strip(): raise TaskStoreError("kind 必须是非空字符串") chosen_id = record_id or _auto_record_id(task_id) if RECORD_ID_RE.fullmatch(chosen_id) is None: raise TaskStoreError("recordId 格式无效") if any(not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents): raise TaskStoreError("parents 必须是 recordId 字符串列表") # Materialize first so malformed existing records or unresolved heads stop # the next write instead of compounding an already ambiguous store. materialized = materialize_board(manifest, board_path) existing_errors = materialized.get("taskStoreErrors") if isinstance(existing_errors, list) and existing_errors: raise TaskStoreError("现有 taskStore 已有冲突,先解决: " + "; ".join(map(str, existing_errors))) directory = board_path.parent / str(manifest["taskStore"].get("directory", "task-records")) directory.mkdir(parents=True, exist_ok=True) output = directory / f"{chosen_id}.yaml" record: dict[str, Any] = { "recordVersion": STORE_VERSION, "recordId": chosen_id, "taskId": task_id, "parents": parents, "kind": kind, "task": task, } for field, value in ( ("workerReceipts", worker_receipts), ("deliveryRuns", delivery_runs), ("regressionRuns", regression_runs), ): if value: record[field] = value content = json.dumps(record, ensure_ascii=False, indent=2) + "\n" flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL descriptor: int | None = None try: descriptor = os.open(output, flags, 0o644) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: descriptor = None handle.write(content) except FileExistsError as exc: raise TaskStoreError(f"记录已存在,拒绝覆盖: {output}") from exc finally: if descriptor is not None: os.close(descriptor) return output 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("--task-id", required=True) parser.add_argument("--task-json", type=Path, required=True, help="完整 task 快照 JSON") parser.add_argument("--record-id") parser.add_argument("--parent", action="append", default=[]) parser.add_argument("--kind", default="task.snapshot") parser.add_argument("--worker-receipts-json", type=Path) parser.add_argument("--delivery-runs-json", type=Path) parser.add_argument("--regression-runs-json", type=Path) args = parser.parse_args(argv) try: task = _read_json_mapping(args.task_json, "task") output = append_record( Path(args.tasks), task, task_id=args.task_id, record_id=args.record_id, parents=args.parent, kind=args.kind, worker_receipts=( _read_json_list(args.worker_receipts_json, "worker receipt") if args.worker_receipts_json else None ), delivery_runs=( _read_json_list(args.delivery_runs_json, "delivery run") if args.delivery_runs_json else None ), regression_runs=( _read_json_list(args.regression_runs_json, "regression run") if args.regression_runs_json else None ), ) except (OSError, TaskStoreError) as exc: print(f"追加任务记录失败: {exc}", file=sys.stderr) return 1 print(output) return 0 if __name__ == "__main__": raise SystemExit(main())