Files
temp/features/steps/plan_lifecycle_model_validation_steps.py
Luis Mendes 9e316b1a3e fix(domain): align plan lifecycle model validation with specification
Aligned the plan lifecycle model with the specification:

1. ERRORED is now treated as terminal in is_terminal property,
   matching the spec table where errored is marked "Terminal? Yes"
   for all processing phases.

2. Added per-phase state validation via model_validator: APPLIED
   and CONSTRAINED are only valid in APPLY phase; COMPLETE is only
   valid in STRATEGIZE or EXECUTE phases. Invalid combinations
   now raise ValueError at construction time.

3. Updated ProcessingState.COMPLETE docstring to clarify phase-level
   terminality semantics.

4. Fixed assignment ordering in execute_plan() to set
   processing_state before phase, consistent with the state-first
   pattern used in apply_plan() and _perform_reversion().

5. Added defensive coercion in LifecyclePlanModel.to_domain() to
   handle legacy DB rows with invalid phase/state combinations
   (e.g. APPLY/COMPLETE -> APPLY/APPLIED) with warning-level
   logging for observability.

6. Updated module docstrings: ERRORED description now reflects
   terminal semantics, terminal outcomes location clarified for
   all phases, can_revert_to docstring notes ERRORED/CONSTRAINED
   are terminal but revertable, is_terminal docstring explains
   the distinction between terminal and permanently irrecoverable
   and documents why COMPLETE is not plan-terminal despite the
   spec marking it "Terminal? Yes" (phase-level vs plan-level).

7. Updated PlanResumeService.validate_eligibility() docstring to
   reflect that ERRORED is now terminal but still eligible for
   resume.

8. Added CHANGELOG entry.

ISSUES CLOSED: #918
2026-03-23 23:33:33 +00:00

254 lines
8.3 KiB
Python

"""Step definitions for plan lifecycle model validation tests.
Covers ERRORED terminality, per-phase state constraints, rejection
of invalid phase/state combinations, deserialization coercion for
legacy DB rows, and terminal-state guard behaviour per the
specification.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
)
_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
def _make_plan(
phase: PlanPhase,
processing_state: ProcessingState,
) -> Plan:
"""Build a Plan with the given phase and processing state."""
return Plan(
identity=PlanIdentity(plan_id=_ULID),
namespaced_name=NamespacedName(
server=None, namespace="local", name="plmv-test"
),
description="Plan lifecycle model validation test",
action_name="local/plmv-action",
phase=phase,
processing_state=processing_state,
)
_PHASE_MAP: dict[str, PlanPhase] = {
"ACTION": PlanPhase.ACTION,
"STRATEGIZE": PlanPhase.STRATEGIZE,
"EXECUTE": PlanPhase.EXECUTE,
"APPLY": PlanPhase.APPLY,
}
_STATE_MAP: dict[str, ProcessingState] = {
"QUEUED": ProcessingState.QUEUED,
"PROCESSING": ProcessingState.PROCESSING,
"ERRORED": ProcessingState.ERRORED,
"COMPLETE": ProcessingState.COMPLETE,
"CANCELLED": ProcessingState.CANCELLED,
"APPLIED": ProcessingState.APPLIED,
"CONSTRAINED": ProcessingState.CONSTRAINED,
}
# ---- Given steps ----
@given("a plmv plan in {phase} phase with {state} state")
def step_create_plmv_plan(context: Context, phase: str, state: str) -> None:
"""Create a plan with the given phase and processing state."""
plan = _make_plan(_PHASE_MAP[phase], _STATE_MAP[state])
if not hasattr(context, "plmv_plans"):
context.plmv_plans = []
context.plmv_plans.append(plan)
context.plmv_plan = plan
# ---- When steps ----
@when("I try to create a plmv plan with phase {phase} and state {state}")
def step_try_create_invalid_plmv_plan(context: Context, phase: str, state: str) -> None:
"""Attempt to create a plan that should fail validation."""
context.plmv_error = None
try:
_make_plan(_PHASE_MAP[phase], _STATE_MAP[state])
except (ValidationError, ValueError) as exc:
context.plmv_error = exc
# ---- Then steps ----
@then("the plmv plan should be terminal")
def step_plmv_plan_terminal(context: Context) -> None:
"""Assert the plan is in a terminal state."""
assert context.plmv_plan.is_terminal, (
f"Expected plan to be terminal, but is_terminal=False "
f"(phase={context.plmv_plan.phase.value}, "
f"state={context.plmv_plan.processing_state.value})"
)
@then("the plmv plan should not be terminal")
def step_plmv_plan_not_terminal(context: Context) -> None:
"""Assert the plan is NOT in a terminal state."""
assert not context.plmv_plan.is_terminal, (
f"Expected plan to NOT be terminal, but is_terminal=True "
f"(phase={context.plmv_plan.phase.value}, "
f"state={context.plmv_plan.processing_state.value})"
)
@then('a plmv validation error should be raised containing "{fragment}"')
def step_plmv_validation_error(context: Context, fragment: str) -> None:
"""Assert that a validation error was raised with the expected message."""
assert context.plmv_error is not None, (
"Expected a validation error, but none was raised"
)
msg = str(context.plmv_error)
assert fragment in msg, f"Expected error to contain {fragment!r}, got: {msg}"
@then("all plmv plans should be valid")
def step_all_plmv_plans_valid(context: Context) -> None:
"""Assert that all previously created plans are valid."""
assert hasattr(context, "plmv_plans"), "No plmv_plans found on context"
assert len(context.plmv_plans) > 0, "plmv_plans list is empty"
for plan in context.plmv_plans:
assert plan is not None, "Plan should not be None"
# ---- Assignment-order regression steps (T6) ----
@when(
"I plmv transition the plan to {phase} phase with {state} state"
" using state-first order"
)
def step_plmv_transition_state_first(context: Context, phase: str, state: str) -> None:
"""Mutate an existing plan's phase and state using the safe
state-first ordering. Verifies that ``validate_assignment=True``
does not trigger the phase-state validator on the intermediate
state.
"""
plan = context.plmv_plan
plan.processing_state = _STATE_MAP[state]
plan.phase = _PHASE_MAP[phase]
context.plmv_plan = plan
@then("the plmv plan should have phase {phase} and state {state}")
def step_plmv_plan_phase_state(context: Context, phase: str, state: str) -> None:
"""Assert the plan's current phase and processing state."""
plan = context.plmv_plan
assert plan.phase == _PHASE_MAP[phase], (
f"Expected phase {phase}, got {plan.phase.value}"
)
assert plan.processing_state == _STATE_MAP[state], (
f"Expected state {state}, got {plan.processing_state.value}"
)
# ---- Phase-first assignment negative test ----
@when(
"I plmv transition the plan to {phase} phase with {state} state"
" using phase-first order"
)
def step_plmv_transition_phase_first(context: Context, phase: str, state: str) -> None:
"""Attempt phase-first mutation which should fail the validator
when the intermediate phase/state combination is invalid.
"""
context.plmv_error = None
try:
plan = context.plmv_plan
plan.phase = _PHASE_MAP[phase]
plan.processing_state = _STATE_MAP[state]
context.plmv_plan = plan
except (ValidationError, ValueError) as exc:
context.plmv_error = exc
# ---- Terminal-state guard steps ----
@then("the plmv plan should not be cancellable")
def step_plmv_not_cancellable(context: Context) -> None:
"""Assert the plan is terminal and therefore not cancellable."""
assert context.plmv_plan.is_terminal, (
"Expected plan to be terminal (not cancellable)"
)
@then("the plmv plan should not be pausable")
def step_plmv_not_pausable(context: Context) -> None:
"""Assert the plan is terminal and therefore not pausable."""
assert context.plmv_plan.is_terminal, "Expected plan to be terminal (not pausable)"
@then("the plmv plan should not be resumable via lifecycle")
def step_plmv_not_resumable_lifecycle(context: Context) -> None:
"""Assert the plan is terminal and therefore not resumable via
``PlanLifecycleService.resume_plan()``.
"""
assert context.plmv_plan.is_terminal, (
"Expected plan to be terminal (not resumable via lifecycle)"
)
# ---- Deserialization coercion steps ----
@when('I plmv deserialize a plan with phase "{phase}" and state "{state}"')
def step_plmv_deserialize(context: Context, phase: str, state: str) -> None:
"""Build a ``LifecyclePlanModel`` ORM row with the given phase/state
and call ``to_domain()`` to exercise the defensive coercion logic.
"""
from cleveragents.infrastructure.database.models import (
LifecyclePlanModel,
)
model = LifecyclePlanModel()
model.plan_id = _ULID
model.namespaced_name = "local/plmv-deser"
model.description = "deserialization coercion test"
model.action_name = "local/plmv-action"
model.phase = phase
model.processing_state = state
model.attempt = 1
model.created_at = "2025-01-01T00:00:00"
model.updated_at = "2025-01-01T00:00:00"
model.reusable = True
model.read_only = False
model.sandbox_refs_json = "[]"
model.tags_json = "[]"
model.reversion_count = 0
model.last_completed_step = -1
context.plmv_deserialized_plan = model.to_domain()
@then("the plmv deserialized plan should have phase {phase} and state {state}")
def step_plmv_deserialized_phase_state(
context: Context, phase: str, state: str
) -> None:
"""Assert the deserialized plan's phase and state after coercion."""
plan: Any = context.plmv_deserialized_plan
assert plan.phase == _PHASE_MAP[phase], (
f"Expected phase {phase}, got {plan.phase.value}"
)
assert plan.processing_state == _STATE_MAP[state], (
f"Expected state {state}, got {plan.processing_state.value}"
)