9e316b1a3e
CI / build (pull_request) Successful in 22s
CI / typecheck (pull_request) Successful in 47s
CI / lint (pull_request) Successful in 3m17s
CI / quality (pull_request) Successful in 3m33s
CI / security (pull_request) Successful in 3m59s
CI / unit_tests (pull_request) Successful in 5m31s
CI / docker (pull_request) Successful in 57s
CI / coverage (pull_request) Successful in 12m32s
CI / status-check (pull_request) Successful in 1s
CI / integration_tests (pull_request) Successful in 2m25s
CI / e2e_tests (pull_request) Successful in 7m9s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (push) Successful in 26s
CI / lint (push) Successful in 3m18s
CI / typecheck (push) Successful in 3m54s
CI / security (push) Successful in 4m2s
CI / quality (push) Successful in 3m33s
CI / integration_tests (push) Successful in 5m47s
CI / unit_tests (push) Successful in 7m6s
CI / docker (push) Successful in 39s
CI / benchmark-regression (pull_request) Failing after 21m11s
CI / coverage (push) Failing after 19m3s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Failing after 21m14s
CI / status-check (push) Failing after 1s
CI / benchmark-publish (push) Failing after 18m51s
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
281 lines
9.6 KiB
Python
281 lines
9.6 KiB
Python
"""Robot Framework helper for plan lifecycle model validation tests.
|
|
|
|
Each function is invoked by the corresponding Robot test case via
|
|
``Run Process python <this-file> <command>``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
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-robot"
|
|
),
|
|
description="Plan lifecycle model validation robot test",
|
|
action_name="local/plmv-robot-action",
|
|
phase=phase,
|
|
processing_state=processing_state,
|
|
)
|
|
|
|
|
|
def _errored_is_terminal() -> None:
|
|
"""Verify ERRORED state is terminal in all applicable phases."""
|
|
for phase in (
|
|
PlanPhase.ACTION,
|
|
PlanPhase.STRATEGIZE,
|
|
PlanPhase.EXECUTE,
|
|
PlanPhase.APPLY,
|
|
):
|
|
plan = _make_plan(phase, ProcessingState.ERRORED)
|
|
assert plan.is_terminal, f"ERRORED should be terminal in {phase.value} phase"
|
|
assert plan.is_errored, (
|
|
f"ERRORED plan should report is_errored in {phase.value} phase"
|
|
)
|
|
print("errored-is-terminal-ok")
|
|
|
|
|
|
def _processing_any_phase() -> None:
|
|
"""Verify PROCESSING is accepted in all phases."""
|
|
for phase in PlanPhase:
|
|
plan = _make_plan(phase, ProcessingState.PROCESSING)
|
|
assert plan.processing_state == ProcessingState.PROCESSING, (
|
|
f"PROCESSING not set in {phase.value}"
|
|
)
|
|
print("processing-any-phase-ok")
|
|
|
|
|
|
def _applied_only_apply() -> None:
|
|
"""Verify APPLIED is rejected outside APPLY phase."""
|
|
# Valid in APPLY
|
|
plan = _make_plan(PlanPhase.APPLY, ProcessingState.APPLIED)
|
|
assert plan.is_terminal
|
|
|
|
# Invalid in STRATEGIZE, EXECUTE, and ACTION
|
|
for phase in (PlanPhase.STRATEGIZE, PlanPhase.EXECUTE, PlanPhase.ACTION):
|
|
try:
|
|
_make_plan(phase, ProcessingState.APPLIED)
|
|
raise AssertionError(
|
|
f"Expected ValidationError for APPLIED in {phase.value}"
|
|
)
|
|
except ValidationError as exc:
|
|
assert "APPLIED is only valid in the APPLY phase" in str(exc)
|
|
|
|
print("applied-only-apply-ok")
|
|
|
|
|
|
def _constrained_only_apply() -> None:
|
|
"""Verify CONSTRAINED is rejected outside APPLY phase."""
|
|
# Valid in APPLY
|
|
plan = _make_plan(PlanPhase.APPLY, ProcessingState.CONSTRAINED)
|
|
assert plan.is_terminal
|
|
|
|
# Invalid in STRATEGIZE, EXECUTE, and ACTION
|
|
for phase in (PlanPhase.STRATEGIZE, PlanPhase.EXECUTE, PlanPhase.ACTION):
|
|
try:
|
|
_make_plan(phase, ProcessingState.CONSTRAINED)
|
|
raise AssertionError(
|
|
f"Expected ValidationError for CONSTRAINED in {phase.value}"
|
|
)
|
|
except ValidationError as exc:
|
|
assert "CONSTRAINED is only valid in the APPLY phase" in str(exc)
|
|
|
|
print("constrained-only-apply-ok")
|
|
|
|
|
|
def _complete_strategize_execute() -> None:
|
|
"""Verify COMPLETE is only valid in STRATEGIZE and EXECUTE phases."""
|
|
# Valid
|
|
for phase in (PlanPhase.STRATEGIZE, PlanPhase.EXECUTE):
|
|
plan = _make_plan(phase, ProcessingState.COMPLETE)
|
|
assert not plan.is_terminal, f"COMPLETE should not be terminal in {phase.value}"
|
|
|
|
# Invalid in APPLY
|
|
try:
|
|
_make_plan(PlanPhase.APPLY, ProcessingState.COMPLETE)
|
|
raise AssertionError("Expected ValidationError for COMPLETE in APPLY")
|
|
except ValidationError as exc:
|
|
assert "COMPLETE is only valid in STRATEGIZE or EXECUTE" in str(exc)
|
|
|
|
# Invalid in ACTION
|
|
try:
|
|
_make_plan(PlanPhase.ACTION, ProcessingState.COMPLETE)
|
|
raise AssertionError("Expected ValidationError for COMPLETE in ACTION")
|
|
except ValidationError as exc:
|
|
assert "COMPLETE is only valid in STRATEGIZE or EXECUTE" in str(exc)
|
|
|
|
print("complete-strategize-execute-ok")
|
|
|
|
|
|
def _queued_any_phase() -> None:
|
|
"""Verify QUEUED is accepted in all phases."""
|
|
for phase in PlanPhase:
|
|
plan = _make_plan(phase, ProcessingState.QUEUED)
|
|
assert plan.processing_state == ProcessingState.QUEUED, (
|
|
f"QUEUED not set in {phase.value}"
|
|
)
|
|
print("queued-any-phase-ok")
|
|
|
|
|
|
def _assignment_order_safety() -> None:
|
|
"""Verify that state-first assignment order avoids validator errors.
|
|
|
|
With ``validate_assignment=True`` on the Plan model, each field
|
|
set triggers the phase-state validator. Setting ``processing_state``
|
|
(to a universally valid value like QUEUED) before ``phase`` avoids
|
|
transient invalid intermediate states.
|
|
"""
|
|
plan = _make_plan(PlanPhase.STRATEGIZE, ProcessingState.COMPLETE)
|
|
# State-first transition: COMPLETE → QUEUED then STRATEGIZE → EXECUTE
|
|
plan.processing_state = ProcessingState.QUEUED
|
|
plan.phase = PlanPhase.EXECUTE
|
|
assert plan.phase == PlanPhase.EXECUTE
|
|
assert plan.processing_state == ProcessingState.QUEUED
|
|
print("assignment-order-safety-ok")
|
|
|
|
|
|
def _deserialization_coercion() -> None:
|
|
"""Verify that to_domain() coerces legacy invalid phase/state combos.
|
|
|
|
The defensive coercion in LifecyclePlanModel.to_domain() should
|
|
map APPLY/COMPLETE → APPLY/APPLIED without crashing.
|
|
"""
|
|
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 = "apply"
|
|
model.processing_state = "complete" # legacy invalid combo
|
|
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
|
|
|
|
plan = model.to_domain()
|
|
# Should have coerced COMPLETE → APPLIED in APPLY phase
|
|
assert plan.processing_state.value == "applied", (
|
|
f"Expected 'applied', got '{plan.processing_state.value}'"
|
|
)
|
|
assert plan.phase.value == "apply"
|
|
print("deserialization-coercion-ok")
|
|
|
|
|
|
def _deserialization_coercion_all_branches() -> None:
|
|
"""Verify all three coercion branches in to_domain().
|
|
|
|
Branch 1: APPLY/COMPLETE → APPLY/APPLIED
|
|
Branch 2: non-APPLY/APPLIED → non-APPLY/ERRORED
|
|
Branch 3: ACTION/COMPLETE → ACTION/QUEUED
|
|
"""
|
|
from cleveragents.infrastructure.database.models import (
|
|
LifecyclePlanModel,
|
|
)
|
|
|
|
def _make_legacy_model(phase: str, state: str, suffix: str) -> LifecyclePlanModel:
|
|
model = LifecyclePlanModel()
|
|
model.plan_id = _ULID
|
|
model.namespaced_name = f"local/plmv-deser-{suffix}"
|
|
model.description = "deserialization coercion all-branches 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
|
|
return model
|
|
|
|
# Branch 1: APPLY/COMPLETE → APPLY/APPLIED
|
|
plan1 = _make_legacy_model("apply", "complete", "b1").to_domain()
|
|
assert plan1.processing_state.value == "applied", (
|
|
f"Branch 1: expected 'applied', got '{plan1.processing_state.value}'"
|
|
)
|
|
|
|
# Branch 2: STRATEGIZE/APPLIED → STRATEGIZE/ERRORED
|
|
plan2 = _make_legacy_model("strategize", "applied", "b2").to_domain()
|
|
assert plan2.processing_state.value == "errored", (
|
|
f"Branch 2: expected 'errored', got '{plan2.processing_state.value}'"
|
|
)
|
|
|
|
# Branch 2b: EXECUTE/CONSTRAINED → EXECUTE/ERRORED
|
|
plan2b = _make_legacy_model("execute", "constrained", "b2b").to_domain()
|
|
assert plan2b.processing_state.value == "errored", (
|
|
f"Branch 2b: expected 'errored', got '{plan2b.processing_state.value}'"
|
|
)
|
|
|
|
# Branch 3: ACTION/COMPLETE → ACTION/QUEUED
|
|
plan3 = _make_legacy_model("action", "complete", "b3").to_domain()
|
|
assert plan3.processing_state.value == "queued", (
|
|
f"Branch 3: expected 'queued', got '{plan3.processing_state.value}'"
|
|
)
|
|
|
|
print("deserialization-coercion-all-branches-ok")
|
|
|
|
|
|
_COMMANDS: dict[str, object] = {
|
|
"errored-is-terminal": _errored_is_terminal,
|
|
"processing-any-phase": _processing_any_phase,
|
|
"applied-only-apply": _applied_only_apply,
|
|
"constrained-only-apply": _constrained_only_apply,
|
|
"complete-strategize-execute": _complete_strategize_execute,
|
|
"queued-any-phase": _queued_any_phase,
|
|
"assignment-order-safety": _assignment_order_safety,
|
|
"deserialization-coercion": _deserialization_coercion,
|
|
"deserialization-coercion-all-branches": _deserialization_coercion_all_branches,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch to the requested test function."""
|
|
if len(sys.argv) < 2:
|
|
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
|
|
print(f"Commands: {', '.join(_COMMANDS)}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
cmd = sys.argv[1]
|
|
fn = _COMMANDS.get(cmd)
|
|
if fn is None:
|
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
assert callable(fn)
|
|
fn()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|