213 lines
7.1 KiB
Python
213 lines
7.1 KiB
Python
"""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"),
|
||
)
|