feat(ack): shard task records for merge-safe workspaces
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge-friendly ACK task records.
|
||||
|
||||
``tasks.yaml`` remains the small project manifest. When it contains a
|
||||
``taskStore`` mapping, task state is materialized from immutable record files
|
||||
under the configured directory. Legacy boards without that mapping keep
|
||||
their original single-file behavior.
|
||||
|
||||
Records are complete task snapshots rather than patches. A later record
|
||||
names its parent record(s), so a concurrent edit produces multiple heads and
|
||||
is reported as a semantic merge conflict instead of being silently resolved
|
||||
by file order or timestamps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
load_json_unique,
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
|
||||
|
||||
STORE_VERSION = 1
|
||||
GLOBAL_TASK_ID = "__project__"
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
RECORD_SUFFIXES = {".yaml", ".yml", ".json"}
|
||||
|
||||
|
||||
class TaskStoreError(ValueError):
|
||||
"""A malformed task store or record."""
|
||||
|
||||
|
||||
def parse_document(path: Path) -> dict[str, Any]:
|
||||
"""Parse one YAML/JSON mapping without importing the validator."""
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise TaskStoreError(f"读取失败: {path}: {exc}") from exc
|
||||
|
||||
try:
|
||||
if path.suffix.lower() == ".json":
|
||||
value = load_json_unique(content)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
value = load_yaml_subset(content)
|
||||
else:
|
||||
value = yaml.load(
|
||||
content,
|
||||
Loader=make_unique_pyyaml_loader(yaml),
|
||||
)
|
||||
except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
|
||||
raise TaskStoreError(f"解析失败: {path}: {exc}") from exc
|
||||
except Exception as exc: # PyYAML errors are intentionally bounded.
|
||||
raise TaskStoreError(f"解析失败: {path}") from exc
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)")
|
||||
return value
|
||||
|
||||
|
||||
def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path:
|
||||
config = manifest.get("taskStore")
|
||||
if not isinstance(config, dict):
|
||||
raise TaskStoreError("taskStore 必须是对象")
|
||||
version = config.get("version", STORE_VERSION)
|
||||
if version != STORE_VERSION:
|
||||
raise TaskStoreError(f"不支持 taskStore.version={version!r}")
|
||||
directory = config.get("directory", "task-records")
|
||||
if not isinstance(directory, str) or not directory.strip():
|
||||
raise TaskStoreError("taskStore.directory 必须是非空相对路径")
|
||||
relative = Path(directory)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise TaskStoreError("taskStore.directory 必须位于任务板目录下")
|
||||
return manifest_path.parent / relative
|
||||
|
||||
|
||||
def _record_paths(directory: Path) -> list[Path]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}")
|
||||
paths: list[Path] = []
|
||||
for path in sorted(directory.iterdir(), key=lambda item: item.name):
|
||||
if path.name.startswith("."):
|
||||
continue
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise TaskStoreError(f"任务记录必须是普通文件: {path}")
|
||||
if path.suffix.lower() in RECORD_SUFFIXES:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def _unique_collection(
|
||||
records: list[dict[str, Any]],
|
||||
field: str,
|
||||
errors: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for record in records:
|
||||
values = record.get(field)
|
||||
if values is None:
|
||||
continue
|
||||
if not isinstance(values, list):
|
||||
errors.append(f"{field} 必须是列表")
|
||||
continue
|
||||
for index, item in enumerate(values):
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"{field}[{index}] 必须是对象")
|
||||
continue
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
errors.append(f"{field}[{index}].id 必须是非空字符串")
|
||||
continue
|
||||
previous = by_id.get(item_id)
|
||||
if previous is not None and previous != item:
|
||||
errors.append(f"{field} 出现相同 id 的不同记录: {item_id}")
|
||||
continue
|
||||
if previous is None:
|
||||
by_id[item_id] = item
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]:
|
||||
"""Return a board-shaped view from a manifest and immutable records."""
|
||||
|
||||
directory = _record_directory(manifest, manifest_path)
|
||||
paths = _record_paths(directory)
|
||||
# This permits a staged rollout: a freshly initialized board has an empty
|
||||
# store and the legacy empty ``tasks: []`` field until its first record.
|
||||
if not paths:
|
||||
return manifest
|
||||
|
||||
errors: list[str] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
by_record_id: dict[str, dict[str, Any]] = {}
|
||||
for path in paths:
|
||||
record = parse_document(path)
|
||||
where = str(path.relative_to(manifest_path.parent))
|
||||
version = record.get("recordVersion", STORE_VERSION)
|
||||
record_id = record.get("recordId")
|
||||
task_id = record.get("taskId")
|
||||
parents = record.get("parents", [])
|
||||
if version != STORE_VERSION:
|
||||
errors.append(f"{where}: 不支持 recordVersion={version!r}")
|
||||
if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None:
|
||||
errors.append(f"{where}: recordId 格式无效")
|
||||
elif record_id in by_record_id:
|
||||
errors.append(f"{where}: recordId 重复: {record_id}")
|
||||
if not isinstance(task_id, str) or (
|
||||
task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None
|
||||
):
|
||||
errors.append(f"{where}: taskId 格式无效")
|
||||
if not isinstance(parents, list) or any(
|
||||
not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None
|
||||
for parent in parents
|
||||
):
|
||||
errors.append(f"{where}: parents 必须是 recordId 字符串列表")
|
||||
task = record.get("task")
|
||||
if task_id != GLOBAL_TASK_ID and not isinstance(task, dict):
|
||||
errors.append(f"{where}: 非全局记录必须包含 task 对象")
|
||||
if isinstance(record_id, str) and record_id not in by_record_id:
|
||||
by_record_id[record_id] = record
|
||||
records.append(record)
|
||||
|
||||
for record in records:
|
||||
record_id = record.get("recordId")
|
||||
task_id = record.get("taskId")
|
||||
if not isinstance(record_id, str) or not isinstance(task_id, str):
|
||||
continue
|
||||
for parent in record.get("parents", []):
|
||||
if parent not in by_record_id:
|
||||
errors.append(f"{record_id}: 引用了不存在的 parent: {parent}")
|
||||
elif by_record_id[parent].get("taskId") != task_id:
|
||||
errors.append(f"{record_id}: parent 必须属于同一个 taskId")
|
||||
|
||||
task_records: dict[str, list[dict[str, Any]]] = {}
|
||||
for record in records:
|
||||
task_id = record.get("taskId")
|
||||
if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID:
|
||||
task_records.setdefault(task_id, []).append(record)
|
||||
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for task_id in sorted(task_records):
|
||||
items = task_records[task_id]
|
||||
referenced = {
|
||||
parent
|
||||
for item in items
|
||||
for parent in item.get("parents", [])
|
||||
if isinstance(parent, str)
|
||||
}
|
||||
heads = [
|
||||
item for item in items
|
||||
if isinstance(item.get("recordId"), str)
|
||||
and item["recordId"] not in referenced
|
||||
]
|
||||
if len(heads) != 1:
|
||||
errors.append(
|
||||
f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录"
|
||||
)
|
||||
continue
|
||||
task = heads[0].get("task")
|
||||
if isinstance(task, dict):
|
||||
tasks.append(task)
|
||||
|
||||
result = dict(manifest)
|
||||
result["tasks"] = tasks
|
||||
result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors)
|
||||
for field in ("deliveryRuns", "regressionRuns"):
|
||||
if field in manifest or any(field in record for record in records):
|
||||
result[field] = _unique_collection(records, field, errors)
|
||||
result["summary"] = {
|
||||
"open": [task["id"] for task in tasks if task.get("status") == "open"],
|
||||
"verified": [task["id"] for task in tasks if task.get("status") == "verified"],
|
||||
"failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"],
|
||||
"leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"],
|
||||
}
|
||||
if errors:
|
||||
result["taskStoreErrors"] = errors
|
||||
else:
|
||||
result.pop("taskStoreErrors", None)
|
||||
return result
|
||||
|
||||
|
||||
def is_task_store_manifest(value: object) -> bool:
|
||||
return isinstance(value, dict) and "taskStore" in value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GLOBAL_TASK_ID",
|
||||
"STORE_VERSION",
|
||||
"TaskStoreError",
|
||||
"is_task_store_manifest",
|
||||
"materialize_board",
|
||||
"parse_document",
|
||||
]
|
||||
Reference in New Issue
Block a user