162 lines
6.0 KiB
Python
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())
|