feat(ack): shard task records for merge-safe workspaces
This commit is contained in:
@@ -25,6 +25,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from approval_payload import approval_payload_hash, review_items
|
||||
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||
from validate_tasks import validate_builtin as validate_task_board
|
||||
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
|
||||
|
||||
@@ -180,21 +181,11 @@ def cli_environment() -> dict[str, str]:
|
||||
|
||||
def load_board(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.suffix.lower() == ".json":
|
||||
value = load_json_unique(text)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
|
||||
except ImportError:
|
||||
value = load_yaml_subset(text)
|
||||
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
|
||||
value = parse_document(path)
|
||||
if is_task_store_manifest(value):
|
||||
value = materialize_board(value, path)
|
||||
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc:
|
||||
raise IntakeError(f"cannot read task board: {exc}") from exc
|
||||
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
|
||||
raise IntakeError("cannot parse task board") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise IntakeError("task board must be an object")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate a legacy ACK tasks.yaml into immutable task records.
|
||||
|
||||
The default is a read-only dry run. ``--apply`` writes a sibling
|
||||
``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces
|
||||
the task board with a small manifest that points at ``task-records/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
||||
try:
|
||||
temporary.write_text(content, encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
def _record_id(task_id: str) -> str:
|
||||
return f"EV-{task_id}-initial"
|
||||
|
||||
|
||||
def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"recordVersion": STORE_VERSION,
|
||||
"recordId": _record_id(task_id),
|
||||
"taskId": task_id,
|
||||
"parents": [],
|
||||
"kind": "task.snapshot",
|
||||
}
|
||||
if task is not None:
|
||||
value["task"] = task
|
||||
return value
|
||||
|
||||
|
||||
def build_records(board: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
tasks = board.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
raise TaskStoreError("legacy tasks 必须是列表")
|
||||
records: list[dict[str, Any]] = []
|
||||
task_ids: set[str] = set()
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
|
||||
raise TaskStoreError("每条 legacy task 必须包含字符串 id")
|
||||
task_id = task["id"]
|
||||
if task_id in task_ids:
|
||||
raise TaskStoreError(f"task id 重复: {task_id}")
|
||||
task_ids.add(task_id)
|
||||
records.append(_record(task_id, dict(task)))
|
||||
|
||||
receipts = board.get("workerReceipts") or []
|
||||
if not isinstance(receipts, list):
|
||||
raise TaskStoreError("workerReceipts 必须是列表")
|
||||
by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids}
|
||||
global_receipts: list[dict[str, Any]] = []
|
||||
for receipt in receipts:
|
||||
if not isinstance(receipt, dict):
|
||||
raise TaskStoreError("workerReceipts 每项必须是对象")
|
||||
created_for = receipt.get("createdFor")
|
||||
task_id = created_for.get("taskId") if isinstance(created_for, dict) else None
|
||||
if isinstance(task_id, str) and task_id in by_task:
|
||||
by_task[task_id].append(receipt)
|
||||
else:
|
||||
global_receipts.append(receipt)
|
||||
for record in records:
|
||||
task_id = record["taskId"]
|
||||
if by_task[task_id]:
|
||||
record["workerReceipts"] = by_task[task_id]
|
||||
|
||||
global_record = _record(GLOBAL_TASK_ID)
|
||||
if global_receipts:
|
||||
global_record["workerReceipts"] = global_receipts
|
||||
for field in ("deliveryRuns", "regressionRuns"):
|
||||
value = board.get(field)
|
||||
if value is not None:
|
||||
if not isinstance(value, list):
|
||||
raise TaskStoreError(f"{field} 必须是列表")
|
||||
global_record[field] = value
|
||||
if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")):
|
||||
records.append(global_record)
|
||||
return records
|
||||
|
||||
|
||||
def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]:
|
||||
board = parse_document(path)
|
||||
if "taskStore" in board:
|
||||
raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移")
|
||||
records = build_records(board)
|
||||
record_directory = directory or "task-records"
|
||||
relative = Path(record_directory)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise TaskStoreError("records directory 必须是任务板目录下的相对路径")
|
||||
output_dir = path.parent / relative
|
||||
backup = path.with_name(f"{path.stem}.legacy{path.suffix}")
|
||||
if apply and backup.exists():
|
||||
raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}")
|
||||
|
||||
manifest = dict(board)
|
||||
for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"):
|
||||
manifest.pop(field, None)
|
||||
manifest["tasks"] = []
|
||||
manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory}
|
||||
|
||||
if apply:
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, backup)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for record in records:
|
||||
record_path = output_dir / f"{record['recordId']}.yaml"
|
||||
if record_path.exists():
|
||||
raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}")
|
||||
_atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
|
||||
_atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
return {
|
||||
"mode": "apply" if apply else "dry-run",
|
||||
"tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]),
|
||||
"records": len(records),
|
||||
"recordsDirectory": str(output_dir),
|
||||
"backup": str(backup),
|
||||
"taskStore": manifest["taskStore"],
|
||||
}
|
||||
|
||||
|
||||
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("--apply", action="store_true", help="写入记录并替换任务板")
|
||||
parser.add_argument("--records-dir", help="任务记录目录,默认 task-records")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir)
|
||||
except (OSError, TaskStoreError) as exc:
|
||||
print(f"迁移失败: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -22,15 +22,7 @@ import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
load_json_unique,
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
|
||||
|
||||
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
|
||||
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
|
||||
@@ -162,36 +154,12 @@ def _reject_unknown(
|
||||
|
||||
def _load_document(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
data = parse_document(path)
|
||||
if is_task_store_manifest(data):
|
||||
data = materialize_board(data, path)
|
||||
except TaskStoreError as exc:
|
||||
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if path.suffix.lower() == ".json":
|
||||
try:
|
||||
data = load_json_unique(content)
|
||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
data = load_yaml_subset(content)
|
||||
except YamlSubsetError as exc:
|
||||
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
||||
except yaml.YAMLError as exc: # type: ignore
|
||||
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
||||
raise SystemExit(1)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -21,15 +21,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
load_json_unique,
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
|
||||
|
||||
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||
@@ -75,36 +67,12 @@ def _reject_unknown(
|
||||
|
||||
def load_yaml(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
data = parse_document(path)
|
||||
if is_task_store_manifest(data):
|
||||
data = materialize_board(data, path)
|
||||
except TaskStoreError as exc:
|
||||
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if path.suffix.lower() == ".json":
|
||||
try:
|
||||
data = load_json_unique(content)
|
||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
data = load_yaml_subset(content)
|
||||
except YamlSubsetError as exc:
|
||||
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
||||
except yaml.YAMLError as exc: # type: ignore
|
||||
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
||||
raise SystemExit(1)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from approval_payload import approval_payload_hash
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
load_json_unique,
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||
from worker_profiles import validate_orchestration
|
||||
from worker_profiles import validate_routing_document
|
||||
|
||||
@@ -243,38 +237,12 @@ def _nonempty_string(value: object) -> bool:
|
||||
|
||||
def load_document(path: Path) -> dict:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
data = parse_document(path)
|
||||
if is_task_store_manifest(data):
|
||||
data = materialize_board(data, path)
|
||||
except TaskStoreError as exc:
|
||||
sys.stderr.write(f"任务板读取失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if path.suffix.lower() == ".json":
|
||||
try:
|
||||
data = load_json_unique(content)
|
||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||
sys.stderr.write(f"JSON 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
data = load_yaml_subset(content)
|
||||
except YamlSubsetError as exc:
|
||||
sys.stderr.write(f"YAML 子集解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
data = yaml.load(
|
||||
content,
|
||||
Loader=make_unique_pyyaml_loader(yaml),
|
||||
)
|
||||
except yaml.YAMLError as exc: # type: ignore
|
||||
sys.stderr.write(f"YAML 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
if not isinstance(data, dict):
|
||||
sys.stderr.write("任务板顶层必须是对象(mapping)\n")
|
||||
raise SystemExit(1)
|
||||
return data
|
||||
|
||||
|
||||
@@ -901,6 +869,11 @@ def validate_builtin(
|
||||
orchestration_override: dict | None = None,
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
store_errors = data.get("taskStoreErrors")
|
||||
if isinstance(store_errors, list):
|
||||
for error in store_errors:
|
||||
if isinstance(error, str) and error.strip():
|
||||
errors.append(f"taskStore: {error}")
|
||||
project_intake_workflow = "read-only-v1"
|
||||
|
||||
def validate_string_fields(
|
||||
|
||||
Reference in New Issue
Block a user