from __future__ import annotations import json from dataclasses import dataclass, field from django.db import transaction from django.utils import timezone from control_plane.events.bus import EventBus from control_plane.events.models import EventType from control_plane.projects.models import Feature, Milestone, Project, ProjectPlan, ProjectStatus, Task, TaskDependency, TaskStatus @dataclass(frozen=True) class PlannedTask: task_id: str goal: str task_type: str = "implementation" acceptance_criteria: list[str] = field(default_factory=list) priority: int = 50 dependencies: list[str] = field(default_factory=list) @dataclass(frozen=True) class PlannedFeature: key: str title: str description: str = "" acceptance_criteria: list[str] = field(default_factory=list) tasks: list[PlannedTask] = field(default_factory=list) @dataclass(frozen=True) class PlannedMilestone: key: str title: str goal: str verification_contract: dict[str, object] = field(default_factory=dict) features: list[PlannedFeature] = field(default_factory=list) @dataclass(frozen=True) class ProjectPlanContract: goal: str scope: str = "" stack: dict[str, object] = field(default_factory=dict) architecture: dict[str, object] = field(default_factory=dict) constraints: dict[str, object] = field(default_factory=dict) acceptance_criteria: list[str] = field(default_factory=list) permissions: dict[str, object] = field(default_factory=dict) budget: dict[str, object] = field(default_factory=dict) open_decisions: list[str] = field(default_factory=list) milestones: list[PlannedMilestone] = field(default_factory=list) class PlanValidationError(ValueError): pass def parse_project_plan_response(content: str) -> ProjectPlanContract: try: payload = json.loads(content) except json.JSONDecodeError as exc: raise PlanValidationError("Sol planning response must be valid JSON") from exc if not isinstance(payload, dict): raise PlanValidationError("Plan response must be a JSON object") raw_plan = payload.get("project_plan", payload) if not isinstance(raw_plan, dict): raise PlanValidationError("project_plan must be an object") milestones: list[PlannedMilestone] = [] for raw_milestone in _required_list(raw_plan, "milestones"): features: list[PlannedFeature] = [] for raw_feature in _required_list(raw_milestone, "features"): tasks: list[PlannedTask] = [] for raw_task in _required_list(raw_feature, "tasks"): tasks.append( PlannedTask( task_id=_required_str(raw_task, "id"), goal=_required_str(raw_task, "goal"), task_type=str(raw_task.get("type", "implementation")), acceptance_criteria=_required_nonempty_str_list(raw_task, "acceptance_criteria"), priority=int(raw_task.get("priority", 50)), dependencies=[str(item) for item in raw_task.get("dependencies", [])], ) ) features.append( PlannedFeature( key=_required_str(raw_feature, "key"), title=_required_str(raw_feature, "title"), description=str(raw_feature.get("description", "")), acceptance_criteria=[str(item) for item in raw_feature.get("acceptance_criteria", [])], tasks=tasks, ) ) milestones.append( PlannedMilestone( key=_required_str(raw_milestone, "key"), title=_required_str(raw_milestone, "title"), goal=_required_str(raw_milestone, "goal"), verification_contract=dict(raw_milestone.get("verification_contract", {})), features=features, ) ) contract = ProjectPlanContract( goal=_required_str(raw_plan, "goal"), scope=str(raw_plan.get("scope", "")), stack=dict(raw_plan.get("stack", {})), architecture=dict(raw_plan.get("architecture", {})), constraints=dict(raw_plan.get("constraints", {})), acceptance_criteria=_required_nonempty_str_list(raw_plan, "acceptance_criteria"), permissions=dict(raw_plan.get("permissions", {})), budget=dict(raw_plan.get("budget", {})), open_decisions=[str(item) for item in raw_plan.get("open_decisions", [])], milestones=milestones, ) validate_project_plan(contract) return contract def validate_project_plan(contract: ProjectPlanContract) -> None: if not contract.goal.strip(): raise PlanValidationError("Project goal is required") if not contract.acceptance_criteria: raise PlanValidationError("Project acceptance criteria are required") task_ids: set[str] = set() dependencies: dict[str, list[str]] = {} milestone_keys: set[str] = set() feature_refs: set[tuple[str, str]] = set() for milestone in contract.milestones: if milestone.key in milestone_keys: raise PlanValidationError(f"Duplicate milestone key: {milestone.key}") milestone_keys.add(milestone.key) if not milestone.features: raise PlanValidationError(f"Milestone {milestone.key} must contain features") for feature in milestone.features: feature_ref = (milestone.key, feature.key) if feature_ref in feature_refs: raise PlanValidationError(f"Duplicate feature key in milestone: {feature.key}") feature_refs.add(feature_ref) if not feature.tasks: raise PlanValidationError(f"Feature {feature.key} must contain tasks") for task in feature.tasks: if task.task_id in task_ids: raise PlanValidationError(f"Duplicate task id: {task.task_id}") if not task.acceptance_criteria: raise PlanValidationError(f"Task {task.task_id} missing acceptance criteria") task_ids.add(task.task_id) dependencies[task.task_id] = task.dependencies for task_id, deps in dependencies.items(): for dep in deps: if dep not in task_ids: raise PlanValidationError(f"Task {task_id} depends on nonexistent task {dep}") _assert_acyclic(dependencies) def _assert_acyclic(dependencies: dict[str, list[str]]) -> None: visiting: set[str] = set() visited: set[str] = set() def visit(task_id: str) -> None: if task_id in visited: return if task_id in visiting: raise PlanValidationError("Task dependencies contain a cycle") visiting.add(task_id) for dependency in dependencies.get(task_id, []): visit(dependency) visiting.remove(task_id) visited.add(task_id) for task_id in dependencies: visit(task_id) def _required_str(value: dict[str, object], key: str) -> str: item = value.get(key) if not isinstance(item, str) or not item.strip(): raise PlanValidationError(f"Missing required string: {key}") return item def _required_list(value: dict[str, object], key: str) -> list[dict[str, object]]: item = value.get(key) if not isinstance(item, list) or not item: raise PlanValidationError(f"Missing required non-empty list: {key}") if not all(isinstance(entry, dict) for entry in item): raise PlanValidationError(f"{key} entries must be objects") return item def _required_nonempty_str_list(value: dict[str, object], key: str) -> list[str]: item = value.get(key) if not isinstance(item, list) or not item or not all(isinstance(entry, str) and entry.strip() for entry in item): raise PlanValidationError(f"Missing required non-empty string list: {key}") return [str(entry) for entry in item] class ProjectPlanBuilder: def __init__(self, bus: EventBus | None = None) -> None: self.bus = bus or EventBus() def apply(self, project: Project, contract: ProjectPlanContract) -> ProjectPlan: validate_project_plan(contract) with transaction.atomic(): return self._apply(project, contract) def _apply(self, project: Project, contract: ProjectPlanContract) -> ProjectPlan: version = project.current_plan_version + 1 plan = ProjectPlan.objects.create( project=project, version=version, goal=contract.goal, scope=contract.scope, stack=contract.stack, architecture=contract.architecture, constraints=contract.constraints, acceptance_criteria=contract.acceptance_criteria, permissions=contract.permissions, budget=contract.budget, open_decisions=contract.open_decisions, approved_at=timezone.now(), ) project.current_plan_version = version project.goal = contract.goal project.status = ProjectStatus.READY project.save(update_fields=["current_plan_version", "goal", "status", "updated_at"]) self.bus.publish(EventType.PLAN_APPROVED, project=project, payload={"plan_id": str(plan.id), "version": version}) task_by_external_id: dict[str, Task] = {} dependency_specs: list[tuple[Task, list[str]]] = [] for order, milestone_contract in enumerate(contract.milestones): milestone = Milestone.objects.create( project=project, plan=plan, key=milestone_contract.key, title=milestone_contract.title, goal=milestone_contract.goal, verification_contract=milestone_contract.verification_contract, order=order, ) self.bus.publish(EventType.MILESTONE_CREATED, project=project, payload={"milestone_id": str(milestone.id), "key": milestone.key}) for feature_contract in milestone_contract.features: feature = Feature.objects.create( project=project, milestone=milestone, title=feature_contract.title, description=feature_contract.description, acceptance_criteria=feature_contract.acceptance_criteria, ) for task_contract in feature_contract.tasks: task = Task.objects.create( project=project, milestone=milestone, feature=feature, task_type=task_contract.task_type, status=TaskStatus.READY, priority=task_contract.priority, goal=task_contract.goal, acceptance_criteria=task_contract.acceptance_criteria, ) task_by_external_id[task_contract.task_id] = task dependency_specs.append((task, task_contract.dependencies)) self.bus.publish(EventType.TASK_CREATED, project=project, task=task, payload={"task_id": str(task.id)}) self.bus.publish(EventType.TASK_READY, project=project, task=task, payload={"task_id": str(task.id)}) for task, dependencies in dependency_specs: for dependency in dependencies: TaskDependency.objects.create(task=task, depends_on=task_by_external_id[dependency]) return plan