feat(ack): shard task records for merge-safe workspaces
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user