Files

162 lines
6.0 KiB
Python

#!/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())