Files
temp/features/steps/database_models_lifecycle_coverage_steps.py
T
CoreRasurae a4a6b061a6 fix(db): align v3_plans schema with specification DDL
Aligned v3_plans table with specification DDL:

1. Added effective_profile_snapshot column (TEXT NOT NULL) for
   storing frozen JSON snapshot of automation profile at plan
   creation time.  Added Pydantic field_validator ensuring the
   value is well-formed JSON.  Validator catches RecursionError
   for deeply nested JSON, consistent with automation_profile
   deserialization hardening.  Validator error message uses
   length-only to avoid potential information disclosure.
   Documented that the default "{}" exists for backward
   compatibility; new plans should explicitly set the snapshot.

2. Made root_plan_id NOT NULL — root plans self-reference their
   own plan_id, child plans reference the root ancestor.  Added
   explicit ondelete="RESTRICT" FK policy for consistency with
   other FKs in the model.  Documented known FK policy drift
   between ORM model (RESTRICT) and migrated databases (retained
   SET NULL) in the migration; data integrity is preserved by
   the NOT NULL constraint regardless.  Moved root_plan_id
   self-reference resolution into a PlanIdentity model_validator
   so the domain model is consistent with the DB NOT NULL
   constraint before and after persistence (previously the
   resolution only happened in from_domain(), creating an
   asymmetry where root_plan_id was None in-memory but non-null
   after round-tripping through the database).

3. Made automation_profile NOT NULL with default "balanced".

4. Documented intentional deviation: phase default is "action"
   (code) vs "strategize" (spec) because the Action phase was
   added as a pre-Strategize setup step.

5. Created Alembic migration with backfill logic for existing
   rows.  Root-ancestor backfill uses level-by-level propagation
   with a parent-readiness guard to correctly resolve plans at
   arbitrary hierarchy depth (3+ levels).  Added safety bound
   (max 100 iterations) with logged error on exhaustion to guard
   against cycles in parent_plan_id.  Merged batch_alter_table
   operations to avoid redundant full-table copies in SQLite
   batch mode.  Migration backfill also handles empty-string
   automation_profile values.  Documented downgrade limitation
   (backfill is not reversible).  Orphan-row fallback now logs
   affected row count at WARNING level.  Migration cycle-detection
   now logs affected plan_id values before the orphan fallback
   overwrites them.  All migration SQL uses sa.text() for
   consistency with SQLAlchemy best practices.

6. Hardened automation_profile deserialization in to_domain() to
   catch ValueError (invalid StrEnum provenance), Pydantic
   ValidationError, and RecursionError (deeply nested JSON) in
   addition to JSONDecodeError and KeyError, preventing
   unreadable plans from corrupted DB rows.  Applied the same
   defensive deserialization pattern to effective_profile_snapshot
   in to_domain(): corrupted JSON falls back to '{}' with a
   WARNING log instead of crashing the read path.  Added TypeError
   to the effective_profile_snapshot exception list in to_domain()
   for consistency with the Pydantic validator.  Logging of
   unparseable values uses length only to avoid potential
   information disclosure.

7. Used explicit None check (is not None) instead of truthiness
   for root_plan_id resolution in from_domain(), for
   effective_profile_snapshot in to_domain(), and in
   _serialize_automation_profile() for consistency.

8. Documented intentional column naming conventions vs spec DDL
   (e.g. automation_profile vs automation_profile_name, *_actor
   vs *_actor_name, processing_state vs state, v3_plans vs
   plans).  Documented the semantic difference: automation_profile
   stores either a bare name or structured JSON with provenance,
   whereas the spec automation_profile_name stores a plain name.

9. Fixed benchmark plan constructors
   (plan_phase_migration_bench.py) that were missing the now-
   required root_plan_id and effective_profile_snapshot fields.

10. Replaced defensive getattr() with direct attribute access for
    effective_profile_snapshot in from_domain() and update(),
    since the field is now defined on the Plan domain model.

11. Fixed Any type annotation in test helper _make_plan() to use
    AutomationProfileRef | None for proper type safety.

12. Added BDD scenarios for PlanIdentity self-reference
    resolution, NULL effective_profile_snapshot constraint
    enforcement, valid-JSON-missing-profile_name-key
    deserialization, invalid-JSON and empty-string snapshot
    rejection by Pydantic validator, and corrupted
    effective_profile_snapshot DB fallback in to_domain().

13. Extracted default automation profile name to a module-level
    constant (DEFAULT_AUTOMATION_PROFILE) to reduce sentinel
    duplication across models.py and repositories.py.

14. Centralised automation-profile serialisation into
    LifecyclePlanModel._serialize_automation_profile() to
    eliminate duplication between from_domain() and
    LifecyclePlanRepository.update().

15. Fixed to_domain() root_plan_id type cast from str | None
    to str, reflecting the NOT NULL column constraint.

16. Added PlanIdentity model_validator that resolves None
    root_plan_id to plan_id at domain construction time, ensuring
    the domain model honours the spec DDL NOT NULL constraint
    regardless of persistence state.  Simplified from_domain()
    root resolution accordingly.

ISSUES CLOSED: #921
2026-03-30 23:40:36 +01:00

1155 lines
42 KiB
Python

"""Step definitions for lifecycle data persistence and retrieval tests."""
import json
from datetime import datetime
from behave import given, then, when
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import (
ActionArgumentModel,
Base,
LifecycleActionModel,
LifecyclePlanModel,
PlanProjectModel,
)
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("the lifecycle database is ready")
def step_import_lifecycle_modules(context):
"""Set up an in-memory database with lifecycle tables."""
context.database_url = "sqlite:///:memory:"
context.engine = create_engine(context.database_url)
context.SessionLocal = sessionmaker(
bind=context.engine, autoflush=False, autocommit=False
)
Base.metadata.create_all(context.engine)
@given("a lifecycle database session is open")
def step_create_lifecycle_test_session(context):
"""Open a database session for the lifecycle tests."""
if not hasattr(context, "SessionLocal"):
step_import_lifecycle_modules(context)
context.db_session = context.SessionLocal()
# ---------------------------------------------------------------------------
# Helper: create a fully populated LifecycleActionModel
# ---------------------------------------------------------------------------
VALID_ULID_1 = "01HGZ6FE0AQDYTR4BXVQZ6E001"
VALID_ULID_2 = "01HGZ6FE0AQDYTR4BXVQZ6E002"
VALID_ULID_3 = "01HGZ6FE0AQDYTR4BXVQZ6E003"
VALID_ULID_4 = "01HGZ6FE0AQDYTR4BXVQZ6E004"
VALID_ULID_5 = "01HGZ6FE0AQDYTR4BXVQZ6E005"
def _make_action_model(
name="local/test-action",
namespace="local",
short_name="test-action",
description="A short description",
long_description="A long description of the action",
definition_of_done="All tests pass and coverage > 80%",
strategy_actor="local/strategy-actor",
execution_actor="local/execution-actor",
estimation_actor="local/estimation-actor",
review_actor="local/review-actor",
inputs_schema="[]",
state="available",
reusable=True,
read_only=False,
created_at="2025-06-01T12:00:00",
updated_at="2025-06-01T13:00:00",
created_by="test-user",
tags="[]",
):
"""Create a LifecycleActionModel with sensible defaults.
Maps the legacy helper parameters to the new spec-aligned column names:
- ``name`` -> ``namespaced_name`` (PK)
- ``short_name`` -> ``name``
- ``description`` -> ``description``
- ``inputs_schema`` -> ``inputs_schema_json``
- ``tags`` -> ``tags_json``
"""
return LifecycleActionModel(
namespaced_name=name,
namespace=namespace,
name=short_name,
description=description,
long_description=long_description,
definition_of_done=definition_of_done,
strategy_actor=strategy_actor,
execution_actor=execution_actor,
estimation_actor=estimation_actor,
review_actor=review_actor,
inputs_schema_json=inputs_schema if inputs_schema != "[]" else None,
state=state,
reusable=reusable,
read_only=read_only,
created_at=created_at,
updated_at=updated_at,
created_by=created_by,
tags_json=tags,
)
def _make_plan_model(
plan_id=VALID_ULID_1,
parent_plan_id=None,
root_plan_id=None,
action_name="local/test-action",
phase="strategize",
state="queued",
attempt=1,
namespaced_name="local/test-plan",
namespace="local",
description="A test plan description",
definition_of_done="Tests pass",
project_names=None,
strategy_actor="local/strategy-actor",
execution_actor="local/execution-actor",
error_message=None,
created_at="2025-06-01T12:00:00",
updated_at="2025-06-01T13:00:00",
completed_at=None,
strategize_started_at=None,
strategize_completed_at=None,
execute_started_at=None,
execute_completed_at=None,
apply_started_at=None,
applied_at=None,
created_by="test-user",
tags="[]",
reusable=True,
read_only=False,
):
"""Create a LifecyclePlanModel with sensible defaults.
Maps legacy helper parameters to the new spec-aligned column names:
- ``action_id`` -> ``action_name`` (FK to actions.namespaced_name)
- ``state`` -> ``processing_state``
- ``project_ids`` removed (now uses ``plan_projects`` child table)
- ``tags`` -> ``tags_json``
- Added ``namespace`` column
"""
# Root plans self-reference; default when not explicitly provided.
resolved_root = root_plan_id if root_plan_id is not None else plan_id
model = LifecyclePlanModel(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=resolved_root,
action_name=action_name,
phase=phase,
processing_state=state,
attempt=attempt,
namespaced_name=namespaced_name,
namespace=namespace,
description=description,
definition_of_done=definition_of_done,
strategy_actor=strategy_actor,
execution_actor=execution_actor,
error_message=error_message,
created_at=created_at,
updated_at=updated_at,
completed_at=completed_at,
strategize_started_at=strategize_started_at,
strategize_completed_at=strategize_completed_at,
execute_started_at=execute_started_at,
execute_completed_at=execute_completed_at,
apply_started_at=apply_started_at,
applied_at=applied_at,
created_by=created_by,
tags_json=tags,
reusable=reusable,
read_only=read_only,
effective_profile_snapshot="{}",
)
# Populate project links via child table
if project_names:
for pname in json.loads(project_names):
model.project_links_rel.append(
PlanProjectModel(
project_name=pname,
created_at=created_at,
)
)
return model
# ---------------------------------------------------------------------------
# Action: loading a stored record as a domain object
# ---------------------------------------------------------------------------
@given("an action record exists with valid attributes and tags")
def step_create_action_model_valid(context):
"""Persist a valid action record with tags."""
context.action_model = _make_action_model(
tags='["tag1", "tag2"]',
)
context.db_session.add(context.action_model)
context.db_session.commit()
@when("the action record is loaded as a domain object")
def step_call_to_domain_on_action(context):
"""Load the persisted action record as a domain object."""
context.action_domain = context.action_model.to_domain()
@then("the loaded action should preserve its original identifier")
def step_verify_action_domain_id(context):
"""Verify the loaded action retains the original identifier (namespaced_name)."""
assert str(context.action_domain.namespaced_name) == "local/test-action"
@then("the loaded action should preserve its namespace and short name")
def step_verify_action_domain_namespaced_name(context):
"""Verify the loaded action retains its namespace and short name."""
assert context.action_domain.namespaced_name.namespace == "local"
assert context.action_domain.namespaced_name.name == "test-action"
@then("the loaded action should preserve its description fields")
def step_verify_action_domain_descriptions(context):
"""Verify the loaded action retains all description fields."""
assert context.action_domain.description == "A short description"
assert context.action_domain.long_description == "A long description of the action"
assert (
context.action_domain.definition_of_done == "All tests pass and coverage > 80%"
)
@then("the loaded action should preserve its actor assignments")
def step_verify_action_domain_actors(context):
"""Verify the loaded action retains all actor assignments."""
assert context.action_domain.strategy_actor == "local/strategy-actor"
assert context.action_domain.execution_actor == "local/execution-actor"
assert context.action_domain.estimation_actor == "local/estimation-actor"
assert context.action_domain.review_actor == "local/review-actor"
@then("the loaded action should preserve its state and flags")
def step_verify_action_domain_state_flags(context):
"""Verify the loaded action retains its state and boolean flags."""
from cleveragents.domain.models.core.action import ActionState
assert context.action_domain.state == ActionState.AVAILABLE
assert context.action_domain.reusable is True
assert context.action_domain.read_only is False
@then("the loaded action should preserve its timestamps")
def step_verify_action_domain_timestamps(context):
"""Verify the loaded action retains its timestamps."""
assert context.action_domain.created_at == datetime.fromisoformat(
"2025-06-01T12:00:00"
)
assert context.action_domain.updated_at == datetime.fromisoformat(
"2025-06-01T13:00:00"
)
@then("the loaded action should preserve its tags")
def step_verify_action_domain_tags(context):
"""Verify the loaded action retains its tags."""
assert context.action_domain.tags == ["tag1", "tag2"]
# ---------------------------------------------------------------------------
# Action: loading a stored record with arguments
# ---------------------------------------------------------------------------
@given("an action record exists with a structured arguments schema")
def step_create_action_model_with_args(context):
"""Persist an action record that has structured input arguments via child table."""
context.action_model = _make_action_model()
# Add arguments via the child table
context.action_model.arguments_rel.append(
ActionArgumentModel(
name="target_coverage",
arg_type="integer",
requirement="required",
description="Target coverage percentage",
position=0,
)
)
context.action_model.arguments_rel.append(
ActionArgumentModel(
name="framework",
arg_type="string",
requirement="optional",
description="Test framework to use",
position=1,
)
)
context.db_session.add(context.action_model)
context.db_session.commit()
@then("the loaded action should contain the expected argument entries")
def step_verify_action_arguments_parsed(context):
"""Verify the loaded action contains the expected number of arguments."""
assert len(context.action_domain.arguments) == 2
@then("each argument entry should have the correct name and type")
def step_verify_action_argument_details(context):
"""Verify each argument entry has the correct name."""
args = context.action_domain.arguments
assert args[0].name == "target_coverage"
assert args[1].name == "framework"
# ---------------------------------------------------------------------------
# Action: loading a record with no inputs or tags
# ---------------------------------------------------------------------------
@given("an action record exists with no inputs and no tags")
def step_create_action_model_empty_inputs_tags(context):
"""Persist an action record with empty inputs and tags."""
context.action_model = _make_action_model(inputs_schema="[]", tags="[]")
context.db_session.add(context.action_model)
context.db_session.commit()
@then("the loaded action should have an empty arguments collection")
def step_verify_empty_arguments(context):
"""Verify the loaded action has no arguments."""
assert context.action_domain.arguments == []
@then("the loaded action should have an empty tags collection")
def step_verify_empty_tags(context):
"""Verify the loaded action has no tags."""
assert context.action_domain.tags == []
# ---------------------------------------------------------------------------
# Action: storing a domain object as a database record
# ---------------------------------------------------------------------------
@given("a complete action domain object is prepared")
def step_create_action_domain_object(context):
"""Prepare a fully populated action domain object."""
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
)
from cleveragents.domain.models.core.plan import NamespacedName
context.action_domain_input = Action(
namespaced_name=NamespacedName(namespace="local", name="my-action"),
description="Short desc",
long_description="Long desc",
definition_of_done="All tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
estimation_actor="local/estimator",
review_actor="local/reviewer",
arguments=[
ActionArgument(
name="coverage",
arg_type="int",
requirement="required",
description="Coverage target",
),
],
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
created_by="test-user",
tags=["ci", "testing"],
)
@when("the action domain object is stored as a database record")
def step_call_from_domain_on_action(context):
"""Store the action domain object as a database record."""
context.action_model_result = LifecycleActionModel.from_domain(
context.action_domain_input
)
@then("the stored record should preserve the action identifier")
def step_verify_from_domain_action_id(context):
"""Verify the stored record retains the action identifier (namespaced_name)."""
assert context.action_model_result.namespaced_name == "local/my-action"
@then("the stored record should preserve the name components")
def step_verify_from_domain_name_fields(context):
"""Verify the stored record retains name, namespace, and short name."""
assert context.action_model_result.namespaced_name == "local/my-action"
assert context.action_model_result.namespace == "local"
assert context.action_model_result.name == "my-action"
@then("the stored record should preserve the description fields")
def step_verify_from_domain_descriptions(context):
"""Verify the stored record retains all description fields."""
assert context.action_model_result.description == "Short desc"
assert context.action_model_result.long_description == "Long desc"
assert context.action_model_result.definition_of_done == "All tests pass"
@then("the stored record should preserve the actor assignments")
def step_verify_from_domain_actors(context):
"""Verify the stored record retains all actor assignments."""
assert context.action_model_result.strategy_actor == "local/strategy"
assert context.action_model_result.execution_actor == "local/executor"
assert context.action_model_result.estimation_actor == "local/estimator"
assert context.action_model_result.review_actor == "local/reviewer"
@then("the stored record should serialize the arguments as JSON")
def step_verify_from_domain_inputs_schema(context):
"""Verify the stored record has arguments stored via child table."""
args = context.action_model_result.arguments_rel
assert len(args) == 1
assert args[0].name == "coverage"
@then("the stored record should serialize the tags as JSON")
def step_verify_from_domain_tags_json(context):
"""Verify the stored record has tags serialized as JSON."""
parsed = json.loads(context.action_model_result.tags_json)
assert parsed == ["ci", "testing"]
@then("the stored record should preserve the state value")
def step_verify_from_domain_state(context):
"""Verify the stored record retains the state value."""
assert context.action_model_result.state == "available"
@then("the stored record should format timestamps as ISO strings")
def step_verify_from_domain_timestamps(context):
"""Verify the stored record formats timestamps as ISO strings."""
assert context.action_model_result.created_at == "2025-06-01T12:00:00"
assert context.action_model_result.updated_at == "2025-06-01T13:00:00"
# ---------------------------------------------------------------------------
# Action: storing with an enumerated state
# ---------------------------------------------------------------------------
@given("an action domain object is prepared with an enumerated state")
def step_create_action_with_enum_state(context):
"""Prepare an action domain object that uses an ActionState enum value."""
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
context.action_domain_input = Action(
namespaced_name=NamespacedName(namespace="local", name="enum-action"),
description="Enum state action",
definition_of_done="Tests pass",
strategy_actor="local/strategy",
execution_actor="local/executor",
state=ActionState.AVAILABLE,
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
@then("the stored record state should equal the enum value")
def step_verify_enum_state_value(context):
"""Verify the stored record state is the string value of the enum."""
assert context.action_model_result.state == "available"
# ---------------------------------------------------------------------------
# Action: storing with a custom string state
# ---------------------------------------------------------------------------
@given("an action-like object is prepared with a custom string state")
def step_create_action_with_string_state(context):
"""Prepare an action-like object with a plain string state."""
from types import SimpleNamespace
from cleveragents.domain.models.core.plan import NamespacedName
context.action_domain_string_state = SimpleNamespace(
namespaced_name=NamespacedName(namespace="local", name="str-action"),
description="Desc",
long_description="Long",
definition_of_done="Done",
strategy_actor="local/strategy",
execution_actor="local/executor",
estimation_actor=None,
review_actor=None,
apply_actor=None,
invariant_actor=None,
arguments=[],
inputs_schema=None,
invariants=[],
automation_profile=None,
reusable=True,
read_only=False,
state="custom-state",
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
created_by=None,
tags=[],
)
@when("the custom-state action is stored as a database record")
def step_call_from_domain_string_state(context):
"""Store the custom-state action as a database record."""
context.action_model_result = LifecycleActionModel.from_domain(
context.action_domain_string_state
)
@then("the stored record state should equal the custom string")
def step_verify_plain_string_state(context):
"""Verify the stored record state is the plain custom string."""
assert context.action_model_result.state == "custom-state"
# ---------------------------------------------------------------------------
# Plan: parsing ISO timestamps
# ---------------------------------------------------------------------------
@when("a valid ISO-8601 string is parsed as a timestamp")
def step_call_parse_iso_valid(context):
"""Parse a valid ISO-8601 string as a timestamp."""
context.parse_iso_result = LifecyclePlanModel._parse_iso("2025-06-01T12:00:00")
@then("the parsed timestamp should be the expected datetime value")
def step_verify_parse_iso_datetime(context):
"""Verify the parsed timestamp matches the expected datetime."""
assert isinstance(context.parse_iso_result, datetime)
assert context.parse_iso_result == datetime(2025, 6, 1, 12, 0, 0)
@when("an absent value is parsed as a timestamp")
def step_call_parse_iso_none(context):
"""Parse an absent (None) value as a timestamp."""
context.parse_iso_result = LifecyclePlanModel._parse_iso(None)
@then("the parsed timestamp should be absent")
def step_verify_parse_iso_none(context):
"""Verify parsing an absent value returns nothing."""
assert context.parse_iso_result is None
# ---------------------------------------------------------------------------
# Plan: formatting datetimes as ISO strings
# ---------------------------------------------------------------------------
@when("a datetime value is formatted as an ISO string")
def step_call_to_iso_datetime(context):
"""Format a datetime value as an ISO string."""
context.to_iso_result = LifecyclePlanModel._to_iso(datetime(2025, 6, 1, 12, 0, 0))
@then("the formatted string should match ISO-8601 format")
def step_verify_to_iso_string(context):
"""Verify the formatted string matches ISO-8601 format."""
assert context.to_iso_result == "2025-06-01T12:00:00"
@when("an absent datetime is formatted as an ISO string")
def step_call_to_iso_none(context):
"""Format an absent (None) datetime as an ISO string."""
context.to_iso_result = LifecyclePlanModel._to_iso(None)
@then("the formatted value should be absent")
def step_verify_to_iso_none(context):
"""Verify formatting an absent datetime returns nothing."""
assert context.to_iso_result is None
# ---------------------------------------------------------------------------
# Plan: loading a stored record in the strategize phase
# ---------------------------------------------------------------------------
@given("a plan record exists in the strategize phase with queued state")
def step_create_plan_model_strategize_queued(context):
"""Persist a plan record in the strategize phase with queued state."""
action_model = _make_action_model()
context.db_session.add(action_model)
context.db_session.commit()
context.plan_model = _make_plan_model(
phase="strategize",
state="queued",
project_names='["proj-1", "proj-2"]',
tags='["important"]',
)
context.db_session.add(context.plan_model)
context.db_session.commit()
@when("the plan record is loaded as a domain object")
def step_call_to_domain_on_plan(context):
"""Load the persisted plan record as a domain object."""
context.plan_domain = context.plan_model.to_domain()
@then("the loaded plan should preserve its identity")
def step_verify_plan_identity(context):
"""Verify the loaded plan retains its identifier and attempt."""
assert context.plan_domain.identity.plan_id == VALID_ULID_1
assert context.plan_domain.identity.attempt == 1
@then("the loaded plan processing state should be queued")
def step_verify_plan_processing_state_queued(context):
"""Verify the loaded plan has QUEUED processing state."""
from cleveragents.domain.models.core.plan import ProcessingState
assert context.plan_domain.processing_state == ProcessingState.QUEUED
@then("the loaded plan should preserve its description")
def step_verify_plan_description(context):
"""Verify the loaded plan retains its description."""
assert context.plan_domain.description == "A test plan description"
@then("the loaded plan should preserve its timestamps")
def step_verify_plan_timestamps(context):
"""Verify the loaded plan retains its timestamps."""
assert context.plan_domain.timestamps.created_at == datetime.fromisoformat(
"2025-06-01T12:00:00"
)
assert context.plan_domain.timestamps.updated_at == datetime.fromisoformat(
"2025-06-01T13:00:00"
)
@then("the loaded plan should preserve its metadata")
def step_verify_plan_metadata(context):
"""Verify the loaded plan retains its metadata fields."""
assert context.plan_domain.created_by == "test-user"
assert context.plan_domain.reusable is True
assert context.plan_domain.read_only is False
# ---------------------------------------------------------------------------
# Plan: loading a stored record in the strategize phase
# ---------------------------------------------------------------------------
@given("a plan record exists in the strategize phase with processing state")
def step_create_plan_model_strategize(context):
"""Persist a plan record in the strategize phase."""
existing = (
context.db_session.query(LifecycleActionModel)
.filter_by(namespaced_name="local/test-action")
.first()
)
if not existing:
action_model = _make_action_model()
context.db_session.add(action_model)
context.db_session.commit()
context.plan_model = _make_plan_model(
plan_id=VALID_ULID_2,
phase="strategize",
state="processing",
)
context.db_session.add(context.plan_model)
context.db_session.commit()
@then("the loaded plan should be in the strategize phase")
def step_verify_plan_strategize_phase(context):
"""Verify the loaded plan is in the strategize phase."""
from cleveragents.domain.models.core.plan import PlanPhase
assert context.plan_domain.phase == PlanPhase.STRATEGIZE
@then("the loaded plan processing state should be processing")
def step_verify_plan_processing_state(context):
"""Verify the loaded plan processing state is processing."""
from cleveragents.domain.models.core.plan import ProcessingState
assert context.plan_domain.state == ProcessingState.PROCESSING
# ---------------------------------------------------------------------------
# Plan: loading a record with all phase timestamps
# ---------------------------------------------------------------------------
@given("a plan record exists with all phase timestamps populated")
def step_create_plan_model_all_timestamps(context):
"""Persist a plan record with all phase timestamps filled."""
existing = (
context.db_session.query(LifecycleActionModel)
.filter_by(namespaced_name="local/test-action")
.first()
)
if not existing:
action_model = _make_action_model()
context.db_session.add(action_model)
context.db_session.commit()
context.plan_model = _make_plan_model(
plan_id=VALID_ULID_3,
phase="apply",
state="applied",
strategize_started_at="2025-06-01T14:00:00",
strategize_completed_at="2025-06-01T14:30:00",
execute_started_at="2025-06-01T15:00:00",
execute_completed_at="2025-06-01T15:30:00",
apply_started_at="2025-06-01T16:00:00",
applied_at="2025-06-01T16:30:00",
)
context.db_session.add(context.plan_model)
context.db_session.commit()
@then("the loaded plan timestamps should include the strategize window")
def step_verify_strategize_timestamps(context):
"""Verify the loaded plan has correct strategize timestamps."""
ts = context.plan_domain.timestamps
assert ts.strategize_started_at == datetime(2025, 6, 1, 14, 0, 0)
assert ts.strategize_completed_at == datetime(2025, 6, 1, 14, 30, 0)
@then("the loaded plan timestamps should include the execute window")
def step_verify_execute_timestamps(context):
"""Verify the loaded plan has correct execute timestamps."""
ts = context.plan_domain.timestamps
assert ts.execute_started_at == datetime(2025, 6, 1, 15, 0, 0)
assert ts.execute_completed_at == datetime(2025, 6, 1, 15, 30, 0)
@then("the loaded plan timestamps should include the apply window")
def step_verify_apply_timestamps(context):
"""Verify the loaded plan has correct apply timestamps."""
ts = context.plan_domain.timestamps
assert ts.apply_started_at == datetime(2025, 6, 1, 16, 0, 0)
assert ts.applied_at == datetime(2025, 6, 1, 16, 30, 0)
# ---------------------------------------------------------------------------
# Plan: loading a record with project IDs and tags
# ---------------------------------------------------------------------------
@given("a plan record exists with associated project identifiers and tags")
def step_create_plan_model_with_ids_tags(context):
"""Persist a plan record with project identifiers and tags."""
existing = (
context.db_session.query(LifecycleActionModel)
.filter_by(namespaced_name="local/test-action")
.first()
)
if not existing:
action_model = _make_action_model()
context.db_session.add(action_model)
context.db_session.commit()
context.plan_model = _make_plan_model(
plan_id=VALID_ULID_4,
phase="strategize",
state="queued",
project_names='["proj-a", "proj-b", "proj-c"]',
tags='["urgent", "backend"]',
)
context.db_session.add(context.plan_model)
context.db_session.commit()
@then("the loaded plan should contain the expected project identifiers")
def step_verify_plan_project_ids(context):
"""Verify the loaded plan contains the expected project identifiers."""
project_names = [link.project_name for link in context.plan_domain.project_links]
assert project_names == ["proj-a", "proj-b", "proj-c"]
@then("the loaded plan should contain the expected tags")
def step_verify_plan_tags(context):
"""Verify the loaded plan contains the expected tags."""
assert context.plan_domain.tags == ["urgent", "backend"]
# ---------------------------------------------------------------------------
# Plan: storing a domain object with processing state
# ---------------------------------------------------------------------------
def _make_plan_domain(
phase="strategize",
processing_state=None,
plan_id=VALID_ULID_1,
project_links=None,
tags=None,
timestamps=None,
):
"""Create a Plan domain object for testing storage."""
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
if project_links is None:
project_links = [ProjectLink(project_name="proj-1")]
if tags is None:
tags = ["test"]
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
)
resolved_processing = (
processing_state if processing_state is not None else ProcessingState.QUEUED
)
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
action_name="local/test-action",
description="Test plan description",
definition_of_done="Tests pass",
phase=PlanPhase(phase),
processing_state=resolved_processing,
strategy_actor="local/strategy",
execution_actor="local/executor",
project_links=project_links,
timestamps=timestamps,
created_by="test-user",
tags=tags,
reusable=True,
read_only=False,
effective_profile_snapshot="{}",
)
@given("a plan domain object is prepared in the action phase with an available state")
def step_create_plan_domain_action_available(context):
"""Prepare a plan domain object in the strategize phase with queued state."""
from cleveragents.domain.models.core.plan import ProcessingState, ProjectLink
context.plan_domain_input = _make_plan_domain(
phase="strategize",
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-1"),
ProjectLink(project_name="proj-2"),
],
tags=["ci", "test"],
)
@when("the plan domain object is stored as a database record")
def step_call_from_domain_on_plan(context):
"""Store the plan domain object as a database record."""
context.plan_model_result = LifecyclePlanModel.from_domain(
context.plan_domain_input
)
@then('the stored plan record should have state "{expected_state}"')
def step_verify_from_domain_plan_state(context, expected_state):
"""Verify the stored plan record has the expected processing_state."""
assert context.plan_model_result.processing_state == expected_state
@then("the stored plan record should preserve the plan identifier")
def step_verify_from_domain_plan_id(context):
"""Verify the stored plan record retains the plan identifier."""
assert context.plan_model_result.plan_id == VALID_ULID_1
@then("the stored plan record should preserve the phase")
def step_verify_from_domain_plan_phase(context):
"""Verify the stored plan record retains the phase."""
assert context.plan_model_result.phase == "strategize"
@then("the stored plan record should serialize the project identifiers as JSON")
def step_verify_from_domain_project_ids(context):
"""Verify the stored plan record has project links via child table."""
project_names = [
pl.project_name for pl in context.plan_model_result.project_links_rel
]
assert project_names == ["proj-1", "proj-2"]
@then("the stored plan record should serialize the plan tags as JSON")
def step_verify_from_domain_plan_tags(context):
"""Verify the stored plan record has tags serialized as JSON."""
parsed = json.loads(context.plan_model_result.tags_json)
assert parsed == ["ci", "test"]
@then("the stored plan record should format plan timestamps as ISO strings")
def step_verify_from_domain_plan_timestamps(context):
"""Verify the stored plan record has timestamps as ISO strings."""
assert context.plan_model_result.created_at == "2025-06-01T12:00:00"
assert context.plan_model_result.updated_at == "2025-06-01T13:00:00"
# ---------------------------------------------------------------------------
# Plan: storing a domain object with processing state
# ---------------------------------------------------------------------------
@given("a plan domain object is prepared in the strategize phase with a queued state")
def step_create_plan_domain_strategize_queued(context):
"""Prepare a plan domain object in the strategize phase with queued state."""
from cleveragents.domain.models.core.plan import ProcessingState, ProjectLink
context.plan_domain_input = _make_plan_domain(
phase="strategize",
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-1"),
ProjectLink(project_name="proj-2"),
],
tags=["ci", "test"],
)
@then('the stored plan record phase should be "{expected_phase}"')
def step_verify_from_domain_plan_phase_value(context, expected_phase):
"""Verify the stored plan record has the expected phase."""
assert context.plan_model_result.phase == expected_phase
# ---------------------------------------------------------------------------
# Plan: storing a domain object with no explicit state
# ---------------------------------------------------------------------------
@given("a plan domain object is prepared with neither action nor processing state")
def step_create_plan_domain_null_states(context):
"""Prepare a plan-like object with no explicit state to test the fallback."""
from types import SimpleNamespace
from cleveragents.domain.models.core.plan import (
NamespacedName,
PlanIdentity,
PlanPhase,
PlanTimestamps,
)
context.plan_domain_stateless = SimpleNamespace(
identity=PlanIdentity(plan_id=VALID_ULID_1),
namespaced_name=NamespacedName(namespace="local", name="null-plan"),
action_name="local/test-action",
description="Null state plan",
definition_of_done="Done",
phase=PlanPhase.STRATEGIZE,
processing_state=None,
automation_profile=None,
strategy_actor="local/strategy",
execution_actor="local/executor",
review_actor=None,
apply_actor=None,
estimation_actor=None,
invariant_actor=None,
execution_environment=None,
execution_env_priority=None,
project_links=[],
invariants=[],
arguments={},
arguments_order=[],
changeset_id=None,
sandbox_refs=[],
validation_summary=None,
decision_root_id=None,
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
error_message=None,
created_by=None,
tags=[],
reusable=True,
read_only=False,
effective_profile_snapshot="{}",
)
@when("the stateless plan domain object is stored as a database record")
def step_call_from_domain_stateless(context):
"""Store the stateless plan as a database record."""
context.plan_model_result = LifecyclePlanModel.from_domain(
context.plan_domain_stateless
)
# ---------------------------------------------------------------------------
# Plan: storing a domain object with all phase timestamps
# ---------------------------------------------------------------------------
@given("a plan domain object is prepared with all phase timestamps")
def step_create_plan_domain_all_timestamps(context):
"""Prepare a plan domain object with all phase timestamps."""
from cleveragents.domain.models.core.plan import PlanTimestamps
timestamps = PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
strategize_started_at=datetime(2025, 6, 1, 14, 0, 0),
strategize_completed_at=datetime(2025, 6, 1, 14, 30, 0),
execute_started_at=datetime(2025, 6, 1, 15, 0, 0),
execute_completed_at=datetime(2025, 6, 1, 15, 30, 0),
apply_started_at=datetime(2025, 6, 1, 16, 0, 0),
applied_at=datetime(2025, 6, 1, 16, 30, 0),
)
context.plan_domain_input = _make_plan_domain(
phase="strategize",
timestamps=timestamps,
)
@then("the stored plan record should have all phase timestamps as ISO strings")
def step_verify_from_domain_all_timestamps(context):
"""Verify all phase timestamp columns are formatted as ISO strings."""
m = context.plan_model_result
assert m.strategize_started_at == "2025-06-01T14:00:00"
assert m.strategize_completed_at == "2025-06-01T14:30:00"
assert m.execute_started_at == "2025-06-01T15:00:00"
assert m.execute_completed_at == "2025-06-01T15:30:00"
assert m.apply_started_at == "2025-06-01T16:00:00"
assert m.applied_at == "2025-06-01T16:30:00"
assert m.completed_at == "2025-06-01T16:30:00"
# ---------------------------------------------------------------------------
# Plan: storing a domain object with non-default automation level
# ---------------------------------------------------------------------------
@given('a plan domain object is prepared with automation level "{level}"')
def step_create_plan_domain_with_automation_level(context, level):
"""Prepare a plan domain object (automation_level removed, uses profile)."""
context.plan_domain_input = _make_plan_domain(
phase="strategize",
)
@then('the stored plan record automation level should be "{expected_level}"')
def step_verify_from_domain_automation_level(context, expected_level):
"""Verify the stored plan record (automation_level column removed)."""
# automation_level column was removed; this step is a no-op
pass
# ---------------------------------------------------------------------------
# Plan: storing a domain object with an explicit action_id
# ---------------------------------------------------------------------------
VALID_ULID_ACTION = "01JACTION000000000000ACTION"
@when("the plan domain object is stored with an explicit action identifier")
def step_call_from_domain_with_action_id(context):
"""Store the plan domain object with an explicit action_name."""
context.plan_model_result = LifecyclePlanModel.from_domain(
context.plan_domain_input,
action_name=VALID_ULID_ACTION,
)
@then("the stored plan record should preserve the supplied action identifier")
def step_verify_from_domain_action_id(context):
"""Verify the stored plan record retains the supplied action_id."""
assert (
context.plan_model_result.action_name == VALID_ULID_ACTION
) # still passed explicitly
# ---------------------------------------------------------------------------
# Round-trip persistence tests
# ---------------------------------------------------------------------------
@when("the action is saved to the database and reloaded as a domain object")
def step_convert_and_persist_action(context):
"""Save the action to the database and reload it as a domain object."""
context.action_model_persisted = LifecycleActionModel.from_domain(
context.action_domain_input
)
context.db_session.add(context.action_model_persisted)
context.db_session.commit()
context.action_model_retrieved = (
context.db_session.query(LifecycleActionModel)
.filter_by(namespaced_name=context.action_model_persisted.namespaced_name)
.first()
)
assert context.action_model_retrieved is not None
context.action_round_tripped = context.action_model_retrieved.to_domain()
@then("the reloaded action should match the original action")
def step_verify_round_trip_action(context):
"""Verify the reloaded action matches the original."""
original = context.action_domain_input
result = context.action_round_tripped
assert str(result.namespaced_name) == str(original.namespaced_name)
assert result.definition_of_done == original.definition_of_done
assert result.strategy_actor == original.strategy_actor
assert result.execution_actor == original.execution_actor
assert result.reusable == original.reusable
assert result.read_only == original.read_only
assert result.tags == original.tags
assert len(result.arguments) == len(original.arguments)
@when("the plan record is saved and then reloaded as a domain object")
def step_persist_and_reload_plan(context):
"""Save the plan record and reload it as a domain object."""
# Plan was already persisted in the Given step
context.plan_model_retrieved = (
context.db_session.query(LifecyclePlanModel)
.filter_by(plan_id=context.plan_model.plan_id)
.first()
)
assert context.plan_model_retrieved is not None
context.plan_round_tripped = context.plan_model_retrieved.to_domain()
@then("the reloaded plan should preserve its identity and phase")
def step_verify_round_trip_plan(context):
"""Verify the reloaded plan retains its identity and phase."""
from cleveragents.domain.models.core.plan import PlanPhase
result = context.plan_round_tripped
assert result.identity.plan_id == VALID_ULID_1
assert result.phase == PlanPhase.STRATEGIZE
assert result.description == "A test plan description"
assert result.created_by == "test-user"