76375134f1
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 21s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 35s
CI / unit_tests (pull_request) Successful in 7m26s
CI / docker (pull_request) Successful in 1m0s
CI / benchmark-regression (pull_request) Successful in 20m7s
CI / coverage (pull_request) Successful in 48m7s
CI / integration_tests (pull_request) Successful in 2m21s
CI / quality (push) Successful in 18s
CI / lint (push) Successful in 21s
CI / build (push) Successful in 29s
CI / security (push) Successful in 36s
CI / typecheck (push) Successful in 59s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 4m8s
CI / benchmark-publish (push) Successful in 11m33s
CI / unit_tests (push) Successful in 13m16s
CI / docker (push) Successful in 59s
CI / coverage (push) Successful in 23m28s
458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""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
|