"""Step definitions for action persistence feature. Tests ActionRepository CRUD, listing, archiving, argument/invariant ordering, and terminal state storage for plans created from actions. """ from __future__ import annotations from datetime import UTC, datetime from pathlib import Path from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from cleveragents.domain.models.core.action import ( Action, ActionArgument, ActionState, ArgumentRequirement, ArgumentType, ) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, PlanIdentity, PlanPhase, PlanTimestamps, ProcessingState, ) from cleveragents.infrastructure.database.models import ( Base, ) from cleveragents.infrastructure.database.repositories import ( ActionRepository, LifecyclePlanRepository, ) _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" _ap_counter = 0 def _ap_ulid() -> str: """Generate a monotonically-increasing ULID for action persistence tests.""" global _ap_counter _ap_counter += 1 n = _ap_counter suffix = "" for _ in range(8): suffix = _CB32[n % 32] + suffix n //= 32 return f"01HGZACT10QDYTR4AP{suffix}" def _make_action( name: str, state: ActionState = ActionState.AVAILABLE, arguments: list[ActionArgument] | None = None, invariants: list[str] | None = None, ) -> Action: """Build an Action domain object for testing.""" return Action( namespaced_name=NamespacedName.parse(name), description="Persistence test action", long_description="Detailed description", definition_of_done="All tests pass", strategy_actor="local/strategist", execution_actor="local/executor", review_actor="local/reviewer", arguments=arguments or [], invariants=invariants or [], reusable=True, read_only=False, state=state, created_at=datetime(2026, 1, 1, tzinfo=UTC), updated_at=datetime(2026, 1, 1, tzinfo=UTC), created_by="test-user", tags=["test"], ) def _ap_setup_db(context: Context) -> None: """Create an in-memory SQLite DB for action persistence tests.""" engine = create_engine("sqlite://", echo=False) Base.metadata.create_all(engine) sm = sessionmaker(bind=engine) session = sm() context._ap_db_path = None context._ap_engine = engine context._ap_session = session context._ap_session_factory = lambda: session context._ap_action_repo = ActionRepository( session_factory=context._ap_session_factory, ) context._ap_plan_repo = LifecyclePlanRepository( session_factory=context._ap_session_factory, ) def _ap_teardown_db(context: Context) -> None: """Clean up session and engine.""" if hasattr(context, "_ap_session"): context._ap_session.close() if hasattr(context, "_ap_engine"): context._ap_engine.dispose() if getattr(context, "_ap_db_path", None): Path(context._ap_db_path).unlink(missing_ok=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a fresh in-memory action persistence database") def step_fresh_action_persistence_db(context: Context) -> None: """Set up a clean SQLite database for action persistence tests.""" _ap_setup_db(context) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _ap_teardown_db(context)) # --------------------------------------------------------------------------- # Create scenarios # --------------------------------------------------------------------------- @given('a new action with namespaced name "{name}"') def step_new_action(context: Context, name: str) -> None: """Build a new Action domain object.""" context._ap_action = _make_action(name) @given('a fully-populated action "{name}"') def step_fully_populated_action(context: Context, name: str) -> None: """Build a fully-populated Action domain object.""" args = [ ActionArgument( name="coverage", arg_type=ArgumentType.INTEGER, requirement=ArgumentRequirement.REQUIRED, description="Target coverage percentage", ), ] context._ap_action = _make_action( name, arguments=args, invariants=["No regressions"] ) @when("I persist the action via the action repository") def step_persist_action(context: Context) -> None: """Persist the action through the repository.""" context._ap_action_repo.create(context._ap_action) context._ap_session.commit() @then('I can retrieve the action by name "{name}"') def step_retrieve_action_by_name(context: Context, name: str) -> None: """Retrieve the action and store for further assertions.""" action = context._ap_action_repo.get_by_name(name) assert action is not None, f"Action {name} not found" context._ap_retrieved = action @then('the retrieved action description should be "{desc}"') def step_action_description(context: Context, desc: str) -> None: """Verify the action's description.""" assert context._ap_retrieved.description == desc @then('the action strategy_actor should be "{actor}"') def step_action_strategy_actor(context: Context, actor: str) -> None: """Verify the action's strategy_actor.""" assert context._ap_retrieved.strategy_actor == actor @then('the action execution_actor should be "{actor}"') def step_action_execution_actor(context: Context, actor: str) -> None: """Verify the action's execution_actor.""" assert context._ap_retrieved.execution_actor == actor @then('the persisted action state should be "{state}"') def step_action_state_value(context: Context, state: str) -> None: """Verify the persisted action's state.""" assert context._ap_retrieved.state == ActionState(state) @then("the action reusable flag should be true") def step_action_reusable_true(context: Context) -> None: """Verify the action's reusable flag.""" assert context._ap_retrieved.reusable is True # --------------------------------------------------------------------------- # List and archive scenarios # --------------------------------------------------------------------------- @given("{count:d} persisted actions in available state") def step_n_persisted_actions(context: Context, count: int) -> None: """Create N actions in available state.""" for i in range(count): action = _make_action(f"local/bulk-action-{i}") context._ap_action_repo.create(action) context._ap_session.commit() @given('a persisted action "{name}" in available state') def step_persisted_action_available(context: Context, name: str) -> None: """Create and persist an available action.""" action = _make_action(name) context._ap_action_repo.create(action) context._ap_session.commit() @when("I list available actions from the action repository") def step_list_available_actions(context: Context) -> None: """List all available actions.""" context._ap_listed = context._ap_action_repo.list_available() @when('I list actions in namespace "{namespace}" from the action repository') def step_list_actions_namespace(context: Context, namespace: str) -> None: """List actions filtered by namespace.""" context._ap_listed = context._ap_action_repo.get_by_namespace(namespace) @then("I should get {count:d} actions in the action list") def step_action_list_count(context: Context, count: int) -> None: """Verify the number of actions returned.""" assert len(context._ap_listed) == count, ( f"Expected {count}, got {len(context._ap_listed)}" ) @then("I should get {count:d} action in the action list") def step_action_list_count_singular(context: Context, count: int) -> None: """Verify the number of actions returned (singular).""" assert len(context._ap_listed) == count @when('I archive the action "{name}" via the action repository') def step_archive_action(context: Context, name: str) -> None: """Archive an action by updating its state.""" action = context._ap_action_repo.get_by_name(name) assert action is not None action.state = ActionState.ARCHIVED action.updated_at = datetime.now(tz=UTC) context._ap_action_repo.update(action) context._ap_session.commit() @then('the action "{name}" should have state "{state}"') def step_action_has_state(context: Context, name: str, state: str) -> None: """Verify the action's state.""" action = context._ap_action_repo.get_by_name(name) assert action is not None assert action.state == ActionState(state) @when('I delete the action "{name}" via the action repository') def step_delete_action(context: Context, name: str) -> None: """Delete an action.""" result = context._ap_action_repo.delete(name) assert result is True context._ap_session.commit() @then('the action "{name}" should not exist') def step_action_not_exist(context: Context, name: str) -> None: """Verify the action no longer exists.""" action = context._ap_action_repo.get_by_name(name) assert action is None # --------------------------------------------------------------------------- # Arguments ordering scenarios # --------------------------------------------------------------------------- @given( 'an action "{name}" with arguments "{arg1}" at position 0 and "{arg2}" at position 1 and "{arg3}" at position 2' ) def step_action_with_ordered_args( context: Context, name: str, arg1: str, arg2: str, arg3: str ) -> None: """Create an action with ordered arguments.""" args = [ ActionArgument( name=arg1, arg_type=ArgumentType.STRING, requirement=ArgumentRequirement.REQUIRED, description=f"Arg {arg1}", ), ActionArgument( name=arg2, arg_type=ArgumentType.INTEGER, requirement=ArgumentRequirement.OPTIONAL, description=f"Arg {arg2}", ), ActionArgument( name=arg3, arg_type=ArgumentType.BOOLEAN, requirement=ArgumentRequirement.OPTIONAL, description=f"Arg {arg3}", ), ] context._ap_action = _make_action(name, arguments=args) @when('I retrieve the action "{name}" from the action repository') def step_retrieve_action(context: Context, name: str) -> None: """Retrieve an action from the repo.""" context._ap_retrieved = context._ap_action_repo.get_by_name(name) assert context._ap_retrieved is not None @then("the persisted action should have {count:d} arguments") def step_action_argument_count(context: Context, count: int) -> None: """Verify the persisted action argument count.""" assert len(context._ap_retrieved.arguments) == count @then('the persisted argument at position {pos:d} should be "{name}"') def step_argument_at_position(context: Context, pos: int, name: str) -> None: """Verify the argument at the given position.""" assert context._ap_retrieved.arguments[pos].name == name # --------------------------------------------------------------------------- # Invariants ordering scenarios # --------------------------------------------------------------------------- @given('an action "{name}" with invariants "{inv1}" and "{inv2}" and "{inv3}"') def step_action_with_ordered_invariants( context: Context, name: str, inv1: str, inv2: str, inv3: str ) -> None: """Create an action with ordered invariants.""" context._ap_action = _make_action(name, invariants=[inv1, inv2, inv3]) @then("the persisted action should have {count:d} invariants") def step_action_invariant_count(context: Context, count: int) -> None: """Verify the invariant count.""" assert len(context._ap_retrieved.invariants) == count @then('the persisted invariant at position {pos:d} should be "{text}"') def step_invariant_at_position(context: Context, pos: int, text: str) -> None: """Verify the invariant text at the given position.""" assert context._ap_retrieved.invariants[pos] == text # --------------------------------------------------------------------------- # Action state persistence # --------------------------------------------------------------------------- @given('a new action "{name}" in archived state') def step_new_action_archived(context: Context, name: str) -> None: """Build a new action in archived state.""" context._ap_action = _make_action(name, state=ActionState.ARCHIVED) # --------------------------------------------------------------------------- # Apply terminal state storage for plans from actions # --------------------------------------------------------------------------- @given('a plan from action "{action_name}" in apply phase with state "{state}"') def step_plan_from_action_terminal( context: Context, action_name: str, state: str ) -> None: """Create a plan from the given action in apply phase with terminal state.""" plan_id = _ap_ulid() now = datetime(2026, 3, 1, tzinfo=UTC) plan = Plan( identity=PlanIdentity(plan_id=plan_id, attempt=1), namespaced_name=NamespacedName(namespace="local", name="terminal-plan"), action_name=action_name, description="Terminal state plan", definition_of_done="Done", phase=PlanPhase.APPLY, processing_state=ProcessingState(state), strategy_actor="local/s", execution_actor="local/e", timestamps=PlanTimestamps(created_at=now, updated_at=now), created_by="test", tags=[], reusable=True, read_only=False, ) context._ap_plan_repo.create(plan) context._ap_session.commit() context._ap_terminal_plan_id = plan_id @when("I retrieve the terminal plan from the persistence database") def step_retrieve_terminal_plan(context: Context) -> None: """Retrieve the terminal plan.""" context._ap_terminal_plan = context._ap_plan_repo.get(context._ap_terminal_plan_id) assert context._ap_terminal_plan is not None @then('the terminal plan processing_state should be "{state}"') def step_terminal_plan_state(context: Context, state: str) -> None: """Verify the terminal plan's processing state.""" assert context._ap_terminal_plan.processing_state == ProcessingState(state)