"""Step definitions for phase reversion state machine feature.""" from __future__ import annotations from behave import given, then, when from behave.runner import Context from cleveragents.application.services.plan_lifecycle_service import ( InvalidPhaseTransitionError, PlanLifecycleService, ) from cleveragents.config.settings import Settings from cleveragents.core.exceptions import PlanError from cleveragents.domain.models.core.action import ActionArgument from cleveragents.domain.models.core.plan import ( AutomationProfileProvenance, AutomationProfileRef, InvariantSource, PlanInvariant, PlanPhase, ProcessingState, ProjectLink, ) @given("a plan lifecycle service is initialized") def step_init_service(context: Context) -> None: """Initialize a PlanLifecycleService for testing.""" settings = Settings() context.service = PlanLifecycleService(settings=settings) @given('an action "{action_name}" exists') def step_create_action(context: Context, action_name: str) -> None: """Create a test action.""" service: PlanLifecycleService = context.service service.create_action( name=action_name, description=f"Test action {action_name}", definition_of_done="Tests pass", strategy_actor="local/test-strategist", execution_actor="local/test-executor", ) context.action_name = action_name @given('a plan is created from action "{action_name}"') def step_create_plan(context: Context, action_name: str) -> None: """Use an action to create a plan.""" service: PlanLifecycleService = context.service plan = service.use_action(action_name) context.plan = plan context.plan_id = plan.identity.plan_id @given('a plan is created from action "{action_name}" with arguments') def step_create_plan_with_args(context: Context, action_name: str) -> None: """Use an action to create a plan with arguments and project links.""" service: PlanLifecycleService = context.service # Re-create the action with an argument definition service._actions.pop(str(action_name), None) service.create_action( name=action_name, description=f"Test action {action_name}", definition_of_done="Tests pass", strategy_actor="local/test-strategist", execution_actor="local/test-executor", arguments=[ ActionArgument(name="target_coverage", arg_type="integer"), ], ) plan = service.use_action( action_name, project_links=[ProjectLink(project_name="local/test-project")], arguments={"target_coverage": 80}, invariants=[ PlanInvariant(text="No regressions", source=InvariantSource.PLAN), ], ) context.plan = plan context.plan_id = plan.identity.plan_id @given("the plan is advanced to Apply phase with constrained state") def step_advance_to_apply_constrained(context: Context) -> None: """Advance the plan through Strategize and Execute to Apply/constrained.""" service: PlanLifecycleService = context.service plan_id: str = context.plan_id service.start_strategize(plan_id) service.complete_strategize(plan_id) plan = service.get_plan(plan_id) # If auto-progressed past strategize, still need to handle execute if plan.phase == PlanPhase.EXECUTE: service.start_execute(plan_id) service.complete_execute(plan_id) plan = service.get_plan(plan_id) elif plan.phase == PlanPhase.STRATEGIZE: service.execute_plan(plan_id) service.start_execute(plan_id) service.complete_execute(plan_id) plan = service.get_plan(plan_id) if plan.phase == PlanPhase.EXECUTE: service.apply_plan(plan_id) if plan.phase != PlanPhase.APPLY: # Force phase for testing plan = service.get_plan(plan_id) plan.phase = PlanPhase.APPLY plan.processing_state = ProcessingState.CONSTRAINED plan = service.get_plan(plan_id) if plan.processing_state != ProcessingState.CONSTRAINED: service.start_apply(plan_id) plan = service.get_plan(plan_id) plan.processing_state = ProcessingState.CONSTRAINED plan.error_message = "Constraints too restrictive" context.plan = service.get_plan(plan_id) @given('the plan has automation profile "{profile_name}" allowing auto-reversion') def step_set_profile_allowing(context: Context, profile_name: str) -> None: """Set a profile that allows auto-reversion.""" plan = context.service.get_plan(context.plan_id) plan.automation_profile = AutomationProfileRef( profile_name=profile_name, provenance=AutomationProfileProvenance.PLAN, ) context.plan = plan @given('the plan has automation profile "{profile_name}" blocking auto-reversion') def step_set_profile_blocking(context: Context, profile_name: str) -> None: """Set a profile that blocks auto-reversion.""" plan = context.service.get_plan(context.plan_id) plan.automation_profile = AutomationProfileRef( profile_name=profile_name, provenance=AutomationProfileProvenance.PLAN, ) context.plan = plan @given("the plan is advanced to Execute phase") def step_advance_to_execute(context: Context) -> None: """Advance the plan through Strategize to Execute phase.""" service: PlanLifecycleService = context.service plan_id: str = context.plan_id # Set manual profile to prevent auto-progress plan = service.get_plan(plan_id) plan.automation_profile = AutomationProfileRef( profile_name="manual", provenance=AutomationProfileProvenance.PLAN, ) service.start_strategize(plan_id) service.complete_strategize(plan_id) service.execute_plan(plan_id) context.plan = service.get_plan(plan_id) @given("the plan has been reverted 3 times") def step_set_reversion_count(context: Context) -> None: """Set the reversion count to the maximum.""" plan = context.service.get_plan(context.plan_id) plan.reversion_count = 3 # Advance to execute so reversion is structurally valid plan.automation_profile = AutomationProfileRef( profile_name="manual", provenance=AutomationProfileProvenance.PLAN, ) service: PlanLifecycleService = context.service plan_id = context.plan_id if ( plan.phase == PlanPhase.STRATEGIZE and plan.processing_state == ProcessingState.QUEUED ): service.start_strategize(plan_id) service.complete_strategize(plan_id) service.execute_plan(plan_id) plan = service.get_plan(plan_id) plan.reversion_count = 3 context.plan = plan @given("the plan has reached applied terminal state") def step_set_applied(context: Context) -> None: """Move the plan to the applied terminal state.""" service: PlanLifecycleService = context.service plan_id: str = context.plan_id plan = service.get_plan(plan_id) plan.automation_profile = AutomationProfileRef( profile_name="manual", provenance=AutomationProfileProvenance.PLAN, ) service.start_strategize(plan_id) service.complete_strategize(plan_id) service.execute_plan(plan_id) service.start_execute(plan_id) service.complete_execute(plan_id) service.apply_plan(plan_id) service.start_apply(plan_id) service.complete_apply(plan_id) context.plan = service.get_plan(plan_id) @given("the plan is in Strategize phase") def step_plan_in_strategize(context: Context) -> None: """Verify plan is in Strategize phase.""" plan = context.service.get_plan(context.plan_id) assert plan.phase == PlanPhase.STRATEGIZE @given("the plan has been cancelled") def step_cancel_plan(context: Context) -> None: """Cancel the plan.""" service: PlanLifecycleService = context.service service.cancel_plan(context.plan_id, reason="test cancellation") context.plan = service.get_plan(context.plan_id) @when('auto-reversion from apply is attempted with reason "{reason}"') def step_try_auto_revert_apply(context: Context, reason: str) -> None: """Try auto-reversion from apply.""" service: PlanLifecycleService = context.service context.plan = service.try_auto_revert_from_apply(context.plan_id, reason) @when('the plan is manually reverted to Strategize with reason "{reason}"') def step_manual_revert(context: Context, reason: str) -> None: """Manually revert the plan to Strategize.""" service: PlanLifecycleService = context.service context.plan = service.revert_plan( context.plan_id, PlanPhase.STRATEGIZE, reason=reason ) @when("a reversion is attempted on the maxed-out plan") def step_attempt_reversion_maxed(context: Context) -> None: """Attempt reversion on a plan that has hit the max.""" service: PlanLifecycleService = context.service try: context.plan = service.revert_plan( context.plan_id, PlanPhase.STRATEGIZE, reason="should fail" ) context.raised_error = None except PlanError as e: context.raised_error = e @when('auto-reversion from execute is attempted with reason "{reason}"') def step_try_auto_revert_execute(context: Context, reason: str) -> None: """Try auto-reversion from execute.""" service: PlanLifecycleService = context.service context.plan = service.try_auto_revert_from_execute(context.plan_id, reason) @when("a reversion is attempted on the terminal plan") def step_attempt_reversion_terminal(context: Context) -> None: """Attempt reversion on a terminal plan.""" service: PlanLifecycleService = context.service try: context.plan = service.revert_plan( context.plan_id, PlanPhase.STRATEGIZE, reason="should fail" ) context.raised_error = None except PlanError as e: context.raised_error = e @when("a reversion to Apply phase is attempted") def step_attempt_reversion_wrong_phase(context: Context) -> None: """Attempt reversion to an invalid target phase.""" service: PlanLifecycleService = context.service try: context.plan = service.revert_plan( context.plan_id, PlanPhase.APPLY, reason="should fail" ) context.raised_error = None except InvalidPhaseTransitionError as e: context.raised_error = e @when("the plan is re-advanced to Execute phase") def step_re_advance_to_execute(context: Context) -> None: """Re-advance the plan to Execute after reversion.""" service: PlanLifecycleService = context.service plan_id: str = context.plan_id service.start_strategize(plan_id) service.complete_strategize(plan_id) service.execute_plan(plan_id) context.plan = service.get_plan(plan_id) @when("a reversion is attempted on the cancelled plan") def step_attempt_reversion_cancelled(context: Context) -> None: """Attempt reversion on a cancelled plan.""" service: PlanLifecycleService = context.service try: context.plan = service.revert_plan( context.plan_id, PlanPhase.STRATEGIZE, reason="should fail" ) context.raised_error = None except PlanError as e: context.raised_error = e @then("the plan should be in Strategize phase") def step_check_strategize(context: Context) -> None: """Verify plan is in Strategize phase.""" assert context.plan.phase == PlanPhase.STRATEGIZE @then("the plan processing state should be queued") def step_check_queued(context: Context) -> None: """Verify plan processing state is QUEUED.""" assert context.plan.processing_state == ProcessingState.QUEUED @then("the plan reversion count should be {count:d}") def step_check_reversion_count(context: Context, count: int) -> None: """Verify plan reversion count.""" assert context.plan.reversion_count == count @then( 'a reversion decision should be recorded with source "{source}" and target "{target}"' ) def step_check_reversion_decision(context: Context, source: str, target: str) -> None: """Verify a reversion decision was recorded.""" decisions = context.plan.decisions reversion_decisions = [d for d in decisions if d["type"] == "reversion"] assert len(reversion_decisions) > 0 latest = reversion_decisions[-1] assert latest["source_phase"] == source assert latest["target_phase"] == target assert "timestamp" in latest @then('a reversion decision should be recorded with reason "{reason}"') def step_check_reversion_reason(context: Context, reason: str) -> None: """Verify a reversion decision with specific reason was recorded.""" decisions = context.plan.decisions reversion_decisions = [d for d in decisions if d["type"] == "reversion"] assert len(reversion_decisions) > 0 latest = reversion_decisions[-1] assert reason in latest["reason"] @then("a PlanError should be raised about max reversions") def step_check_max_reversion_error(context: Context) -> None: """Verify a PlanError was raised about max reversions.""" assert context.raised_error is not None assert isinstance(context.raised_error, PlanError) assert ( "maximum reversion count" in str(context.raised_error).lower() or "max" in str(context.raised_error).lower() ) @then("the plan should remain in Apply phase") def step_check_still_apply(context: Context) -> None: """Verify plan is still in Apply phase.""" assert context.plan.phase == PlanPhase.APPLY @then("the plan processing state should be constrained") def step_check_constrained(context: Context) -> None: """Verify plan processing state is CONSTRAINED.""" assert context.plan.processing_state == ProcessingState.CONSTRAINED @then("a PlanError should be raised about terminal state") def step_check_terminal_error(context: Context) -> None: """Verify a PlanError was raised about terminal state.""" assert context.raised_error is not None assert isinstance(context.raised_error, PlanError) assert "terminal" in str(context.raised_error).lower() @then("an InvalidPhaseTransitionError should be raised") def step_check_invalid_transition_error(context: Context) -> None: """Verify an InvalidPhaseTransitionError was raised.""" assert context.raised_error is not None assert isinstance(context.raised_error, InvalidPhaseTransitionError) @then("the plan arguments should be preserved") def step_check_arguments_preserved(context: Context) -> None: """Verify plan arguments are preserved after reversion.""" assert context.plan.arguments == {"target_coverage": 80} @then("the plan project links should be preserved") def step_check_project_links_preserved(context: Context) -> None: """Verify plan project links are preserved after reversion.""" assert len(context.plan.project_links) == 1 assert context.plan.project_links[0].project_name == "local/test-project" @then("the plan invariants should be preserved") def step_check_invariants_preserved(context: Context) -> None: """Verify plan invariants are preserved after reversion.""" assert len(context.plan.invariants) >= 1 texts = [inv.text for inv in context.plan.invariants] assert "No regressions" in texts @then("there should be {count:d} reversion decisions recorded") def step_check_decision_count(context: Context, count: int) -> None: """Verify the number of reversion decisions.""" decisions = context.plan.decisions reversion_decisions = [d for d in decisions if d["type"] == "reversion"] assert len(reversion_decisions) == count @then("the plan in Strategize phase cannot revert to Apply") def step_check_no_revert_strat_to_apply(context: Context) -> None: """Verify Strategize cannot revert to Apply.""" plan = context.service.get_plan(context.plan_id) plan.phase = PlanPhase.STRATEGIZE assert not plan.can_revert_to(PlanPhase.APPLY) @then("the plan in Strategize phase cannot revert to Action") def step_check_no_revert_strat_to_action(context: Context) -> None: """Verify Strategize cannot revert to Action.""" plan = context.service.get_plan(context.plan_id) plan.phase = PlanPhase.STRATEGIZE assert not plan.can_revert_to(PlanPhase.ACTION) @then("the plan in Execute phase can revert to Strategize") def step_check_revert_exec_to_strat(context: Context) -> None: """Verify Execute can revert to Strategize.""" plan = context.service.get_plan(context.plan_id) plan.phase = PlanPhase.EXECUTE assert plan.can_revert_to(PlanPhase.STRATEGIZE) @then("the plan in Apply phase can revert to Strategize") def step_check_revert_apply_to_strat(context: Context) -> None: """Verify Apply can revert to Strategize.""" plan = context.service.get_plan(context.plan_id) plan.phase = PlanPhase.APPLY plan.processing_state = ProcessingState.CONSTRAINED assert plan.can_revert_to(PlanPhase.STRATEGIZE) @then("the plan should remain in Execute phase") def step_check_still_execute(context: Context) -> None: """Verify plan is still in Execute phase.""" assert context.plan.phase == PlanPhase.EXECUTE