268 lines
11 KiB
Python
268 lines
11 KiB
Python
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
|