forked from HAL9000/cleveragents-core
9e316b1a3e
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
254 lines
8.3 KiB
Python
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}"
|
|
)
|