"""Spec Manager — 执行计划规格文档管理器 将 PlanExecEngine 生成的执行计划持久化为 Spec 文档, 用户可在执行前查看、编辑和确认 Spec,作为人与 AI 之间的契约。 """ from __future__ import annotations import logging from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any import yaml logger = logging.getLogger(__name__) @dataclass class SpecStep: """A single step in a spec.""" step_id: str name: str description: str dependencies: list[str] = field(default_factory=list) status: str = "pending" # pending | confirmed | executing | completed | failed @dataclass class Spec: """A specification document for a planned task.""" spec_id: str goal: str steps: list[SpecStep] = field(default_factory=list) # draft | confirmed | executing | completed | failed | parked # U8/R8: "parked" is set when the spec review gate times out (30 min). # A parked spec is NOT failed — the user can resume the review on return. status: str = "draft" created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) confirmed_at: str | None = None metadata: dict[str, Any] = field(default_factory=dict) class SpecManager: """Manages Spec documents as first-class citizens. Specs are persisted to .agentkit/specs/ directory as YAML files. Users can view, edit, and confirm specs before execution. """ def __init__(self, specs_dir: str | None = None): """ Args: specs_dir: Directory for spec files. Default: .agentkit/specs/ """ self._specs_dir = Path(specs_dir or ".agentkit/specs") self._specs_dir.mkdir(parents=True, exist_ok=True) self._cache: dict[str, Spec] = {} def create(self, spec: Spec) -> Path: """Persist a Spec to disk. Returns the file path.""" path = self._specs_dir / f"{spec.spec_id}.yaml" data = asdict(spec) path.write_text( yaml.dump(data, allow_unicode=True, default_flow_style=False), encoding="utf-8" ) self._cache[spec.spec_id] = spec logger.info(f"Spec created: {spec.spec_id} -> {path}") return path def get(self, spec_id: str) -> Spec | None: """Load a Spec by ID.""" if spec_id in self._cache: return self._cache[spec_id] path = self._specs_dir / f"{spec_id}.yaml" if not path.exists(): return None try: data = yaml.safe_load(path.read_text(encoding="utf-8")) spec = self._dict_to_spec(data) self._cache[spec_id] = spec return spec except Exception as e: logger.error(f"Failed to load spec {spec_id}: {e}") return None def update(self, spec_id: str, **kwargs: Any) -> Spec | None: """Update spec fields and persist.""" spec = self.get(spec_id) if spec is None: return None for key, value in kwargs.items(): if key == "steps" and isinstance(value, list): spec.steps = [self._dict_to_step(s) if isinstance(s, dict) else s for s in value] elif hasattr(spec, key): setattr(spec, key, value) self.create(spec) # re-persist return spec def confirm(self, spec_id: str) -> Spec | None: """Mark a spec as confirmed (user approved execution).""" spec = self.get(spec_id) if spec is None: return None spec.status = "confirmed" spec.confirmed_at = datetime.now(timezone.utc).isoformat() # Mark all steps as confirmed for step in spec.steps: if step.status == "pending": step.status = "confirmed" self.create(spec) # re-persist logger.info(f"Spec confirmed: {spec_id}") return spec def park(self, spec_id: str) -> Spec | None: """U8/R8: Park a spec when the review gate times out. A parked spec is distinct from a failed spec — the user can resume the review flow on return (see ``resume``). Mirrors ``confirm``. """ spec = self.get(spec_id) if spec is None: return None spec.status = "parked" self.create(spec) # re-persist logger.info(f"Spec parked: {spec_id}") return spec def resume(self, spec_id: str) -> Spec | None: """U8/R8: Un-park a spec back to ``draft`` so the review flow restarts. Only valid when status == "parked". Returns the spec unchanged (no-op, logged) when the spec is not parked — ponytail: no-op over raise keeps callers simple; an idempotent resume is safer than crashing on a double-resume. Returns None when the spec does not exist. """ spec = self.get(spec_id) if spec is None: return None if spec.status != "parked": logger.warning(f"Spec {spec_id} not parked (status={spec.status}), resume is a no-op") return spec spec.status = "draft" self.create(spec) # re-persist logger.info(f"Spec resumed: {spec_id}") return spec def list_specs(self, status: str | None = None) -> list[Spec]: """List all specs, optionally filtered by status. Sorted by created_at desc.""" specs: list[Spec] = [] for path in self._specs_dir.glob("*.yaml"): try: data = yaml.safe_load(path.read_text(encoding="utf-8")) spec = self._dict_to_spec(data) if status is None or spec.status == status: specs.append(spec) except Exception as e: logger.warning(f"Failed to load spec from {path}: {e}") specs.sort(key=lambda s: s.created_at, reverse=True) return specs def delete(self, spec_id: str) -> bool: """Delete a spec file.""" path = self._specs_dir / f"{spec_id}.yaml" if not path.exists(): return False path.unlink() self._cache.pop(spec_id, None) logger.info(f"Spec deleted: {spec_id}") return True @staticmethod def _dict_to_spec(data: dict[str, Any]) -> Spec: """Convert a dict to a Spec instance.""" steps = [SpecManager._dict_to_step(s) for s in data.get("steps", [])] return Spec( spec_id=data["spec_id"], goal=data["goal"], steps=steps, status=data.get("status", "draft"), created_at=data.get("created_at", ""), confirmed_at=data.get("confirmed_at"), metadata=data.get("metadata", {}), ) @staticmethod def _dict_to_step(data: dict[str, Any] | SpecStep) -> SpecStep: """Convert a dict to a SpecStep instance.""" if isinstance(data, SpecStep): return data return SpecStep( step_id=data["step_id"], name=data["name"], description=data["description"], dependencies=data.get("dependencies", []), status=data.get("status", "pending"), )