"""Step definitions for plan persistence feature. Tests LifecyclePlanRepository CRUD, phase/state transitions, list filters, plan tree links, and cross-restart durability. """ from __future__ import annotations import tempfile from datetime import UTC, datetime from pathlib import Path from typing import Any 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, ActionState from cleveragents.domain.models.core.plan import ( InvariantSource, NamespacedName, Plan, PlanIdentity, PlanInvariant, PlanPhase, PlanTimestamps, ProcessingState, ProjectLink, ) from cleveragents.infrastructure.database.models import ( Base, ) from cleveragents.infrastructure.database.repositories import ( ActionRepository, LifecyclePlanRepository, ) _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" _counter = 0 def _next_ulid() -> str: """Generate a monotonically-increasing ULID for test isolation.""" global _counter _counter += 1 n = _counter suffix = "" for _ in range(8): suffix = _CB32[n % 32] + suffix n //= 32 return f"01HGZ6FE0AQDYTR4BX{suffix}" def _make_plan( plan_id: str, action_name: str = "local/persist-action", phase: PlanPhase = PlanPhase.ACTION, processing_state: ProcessingState = ProcessingState.QUEUED, parent_plan_id: str | None = None, root_plan_id: str | None = None, project_links: list[ProjectLink] | None = None, arguments: dict[str, Any] | None = None, arguments_order: list[str] | None = None, invariants: list[PlanInvariant] | None = None, ) -> Plan: """Build a Plan domain object for testing.""" now = datetime(2026, 3, 1, tzinfo=UTC) return Plan( identity=PlanIdentity( plan_id=plan_id, parent_plan_id=parent_plan_id, root_plan_id=root_plan_id, attempt=1, ), namespaced_name=NamespacedName(namespace="local", name="persist-plan"), action_name=action_name, description="Persistence test plan", definition_of_done="All assertions pass", phase=phase, processing_state=processing_state, strategy_actor="local/s", execution_actor="local/e", project_links=project_links or [], invariants=invariants or [], arguments=arguments or {}, arguments_order=arguments_order or [], timestamps=PlanTimestamps(created_at=now, updated_at=now), created_by="test-persist", tags=[], reusable=True, read_only=False, ) def _setup_db(context: Context, *, file_based: bool = False) -> None: """Create a SQLite DB and attach repos to context. By default uses in-memory SQLite (fast). Pass ``file_based=True`` for cross-restart scenarios that need to close and reopen the same database file. """ if file_based: tmp = tempfile.mktemp(suffix=".db") db_url = f"sqlite:///{tmp}" context._pp_db_path = tmp else: db_url = "sqlite://" context._pp_db_path = None engine = create_engine(db_url, echo=False) Base.metadata.create_all(engine) sm = sessionmaker(bind=engine) session = sm() context._pp_db_url = db_url context._pp_engine = engine context._pp_session = session context._pp_session_factory = lambda: session context._pp_plan_repo = LifecyclePlanRepository( session_factory=context._pp_session_factory, ) context._pp_action_repo = ActionRepository( session_factory=context._pp_session_factory, ) def _teardown_db(context: Context) -> None: """Close the session and clean up temp DB file.""" if hasattr(context, "_pp_session"): context._pp_session.close() if hasattr(context, "_pp_engine"): context._pp_engine.dispose() if getattr(context, "_pp_db_path", None): Path(context._pp_db_path).unlink(missing_ok=True) def _create_action(context: Context, action_name: str = "local/persist-action") -> None: """Ensure an action exists for FK constraint.""" ns = NamespacedName.parse(action_name) action = Action( namespaced_name=ns, description="Prerequisite action for plan persistence tests", definition_of_done="Done", strategy_actor="local/s", execution_actor="local/e", state=ActionState.AVAILABLE, created_at=datetime(2026, 1, 1, tzinfo=UTC), updated_at=datetime(2026, 1, 1, tzinfo=UTC), ) context._pp_action_repo.create(action) context._pp_session.commit() # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a fresh in-memory plan persistence database") def step_fresh_plan_persistence_db(context: Context) -> None: """Set up a clean in-memory SQLite database for plan persistence tests.""" _setup_db(context) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _teardown_db(context)) @given("the plan persistence database is file-based") def step_file_based_plan_persistence_db(context: Context) -> None: """Re-create the plan persistence DB on disk for cross-restart tests. The Background already creates an in-memory DB. This step replaces it with a file-backed DB so the "close and reopen" step can reopen the same file after engine disposal. """ _teardown_db(context) _setup_db(context, file_based=True) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _teardown_db(context)) @given('a prerequisite action "{action_name}" exists in the database') def step_prerequisite_action_exists(context: Context, action_name: str) -> None: """Create a prerequisite action for FK constraints.""" _create_action(context, action_name) # --------------------------------------------------------------------------- # Create scenarios # --------------------------------------------------------------------------- @given('a new lifecycle plan with ID "{plan_id}"') def step_new_lifecycle_plan(context: Context, plan_id: str) -> None: """Build a new Plan domain object.""" context._pp_plan = _make_plan(plan_id) @given( 'a new lifecycle plan with parent tree ID "{plan_id}" parent "{parent_id}" root "{root_id}"' ) def step_new_lifecycle_plan_with_tree( context: Context, plan_id: str, parent_id: str, root_id: str ) -> None: """Build a new Plan with parent/root IDs.""" # Ensure parent plan exists first parent = _make_plan(parent_id) context._pp_plan_repo.create(parent) context._pp_session.commit() context._pp_plan = _make_plan( plan_id, parent_plan_id=parent_id, root_plan_id=root_id ) @when("I persist the plan via the plan repository") def step_persist_plan(context: Context) -> None: """Persist the plan through the repository.""" context._pp_plan_repo.create(context._pp_plan) context._pp_session.commit() @then('I can retrieve the plan by ID "{plan_id}"') def step_retrieve_plan_by_id(context: Context, plan_id: str) -> None: """Retrieve the plan and store for further assertions.""" plan = context._pp_plan_repo.get(plan_id) assert plan is not None, f"Plan {plan_id} not found" context._pp_retrieved = plan @then('the persisted plan description should be "{description}"') def step_plan_description(context: Context, description: str) -> None: """Verify the plan's description.""" assert context._pp_retrieved.description == description @then('the plan parent_plan_id should be "{parent_id}"') def step_plan_parent_id(context: Context, parent_id: str) -> None: """Verify the plan's parent_plan_id.""" assert context._pp_retrieved.identity.parent_plan_id == parent_id @then('the plan root_plan_id should be "{root_id}"') def step_plan_root_id(context: Context, root_id: str) -> None: """Verify the plan's root_plan_id.""" assert context._pp_retrieved.identity.root_plan_id == root_id # --------------------------------------------------------------------------- # Phase/state transition scenarios # --------------------------------------------------------------------------- @given('a persisted plan in phase "{phase}" with state "{state}"') def step_persisted_plan_phase_state(context: Context, phase: str, state: str) -> None: """Create and persist a plan in the given phase/state.""" plan_id = _next_ulid() plan = _make_plan( plan_id, phase=PlanPhase(phase), processing_state=ProcessingState(state), ) context._pp_plan_repo.create(plan) context._pp_session.commit() context._pp_current_plan_id = plan_id @when('I update the plan phase to "{phase}" with state "{state}"') def step_update_plan_phase_state(context: Context, phase: str, state: str) -> None: """Update the plan's phase and processing state. Sets processing_state before phase so that the per-phase state validator sees a universally valid state (e.g. QUEUED) when the phase assignment triggers re-validation. """ plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None plan.processing_state = ProcessingState(state) plan.phase = PlanPhase(phase) plan.timestamps.updated_at = datetime.now(tz=UTC) context._pp_plan_repo.update(plan) context._pp_session.commit() @then('the plan persistence phase should be "{phase}"') def step_verify_plan_phase(context: Context, phase: str) -> None: """Verify the persisted plan's phase.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None assert plan.phase == PlanPhase(phase), f"Expected {phase}, got {plan.phase}" @then('the persisted plan processing_state should be "{state}"') def step_verify_plan_state(context: Context, state: str) -> None: """Verify the persisted plan's processing state.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None assert plan.processing_state == ProcessingState(state), ( f"Expected {state}, got {plan.processing_state}" ) # --------------------------------------------------------------------------- # List filter scenarios # --------------------------------------------------------------------------- @given('{count:d} persisted plans in phase "{phase}"') def step_n_plans_in_phase(context: Context, count: int, phase: str) -> None: """Create N plans in the given phase.""" for _ in range(count): plan_id = _next_ulid() plan = _make_plan(plan_id, phase=PlanPhase(phase)) context._pp_plan_repo.create(plan) context._pp_session.commit() @given('{count:d} persisted plans with processing state "{state}"') def step_n_plans_in_state(context: Context, count: int, state: str) -> None: """Create N plans with the given processing state.""" for _ in range(count): plan_id = _next_ulid() plan = _make_plan(plan_id, processing_state=ProcessingState(state)) context._pp_plan_repo.create(plan) context._pp_session.commit() @given('{count:d} persisted plan with processing state "{state}"') def step_one_plan_in_state(context: Context, count: int, state: str) -> None: """Create 1 plan with the given processing state (singular).""" for _ in range(count): plan_id = _next_ulid() plan = _make_plan(plan_id, processing_state=ProcessingState(state)) context._pp_plan_repo.create(plan) context._pp_session.commit() @given('{count:d} persisted plans linked to action "{action_name}"') def step_n_plans_linked_to_action( context: Context, count: int, action_name: str ) -> None: """Create N plans linked to the given action.""" for _ in range(count): plan_id = _next_ulid() plan = _make_plan(plan_id, action_name=action_name) context._pp_plan_repo.create(plan) context._pp_session.commit() @when('I list persisted plans filtered by phase "{phase}"') def step_list_plans_by_phase(context: Context, phase: str) -> None: """List plans filtered by phase.""" context._pp_listed = context._pp_plan_repo.list_plans(phase=phase) @when('I list plans filtered by processing_state "{state}"') def step_list_plans_by_state(context: Context, state: str) -> None: """List plans filtered by processing state.""" context._pp_listed = context._pp_plan_repo.list_plans(processing_state=state) @when('I list plans filtered by action_name "{action_name}"') def step_list_plans_by_action(context: Context, action_name: str) -> None: """List plans filtered by action name.""" context._pp_listed = context._pp_plan_repo.list_plans(action_name=action_name) @then("I should get {count:d} plans in the list") def step_verify_plan_count(context: Context, count: int) -> None: """Verify the number of plans returned.""" assert len(context._pp_listed) == count, ( f"Expected {count}, got {len(context._pp_listed)}" ) # --------------------------------------------------------------------------- # Plan tree link scenarios # --------------------------------------------------------------------------- @given('a parent plan with ID "{plan_id}"') def step_parent_plan(context: Context, plan_id: str) -> None: """Create and persist a parent plan.""" plan = _make_plan(plan_id, phase=PlanPhase.EXECUTE) context._pp_plan_repo.create(plan) context._pp_session.commit() @given('a child plan with ID "{child_id}" under parent "{parent_id}"') def step_child_plan(context: Context, child_id: str, parent_id: str) -> None: """Create and persist a child plan referencing the parent.""" plan = _make_plan( child_id, parent_plan_id=parent_id, root_plan_id=parent_id, phase=PlanPhase.STRATEGIZE, ) context._pp_plan_repo.create(plan) context._pp_session.commit() @when('I retrieve the child plan "{plan_id}"') def step_retrieve_child(context: Context, plan_id: str) -> None: """Retrieve a child plan from the repo.""" context._pp_child = context._pp_plan_repo.get(plan_id) assert context._pp_child is not None @then('the child plan should reference parent "{parent_id}"') def step_child_references_parent(context: Context, parent_id: str) -> None: """Verify the child plan's parent reference.""" assert context._pp_child.identity.parent_plan_id == parent_id @then('the child plan should reference root "{root_id}"') def step_child_references_root(context: Context, root_id: str) -> None: """Verify the child plan's root reference.""" assert context._pp_child.identity.root_plan_id == root_id @given('a root plan with ID "{plan_id}"') def step_root_plan(context: Context, plan_id: str) -> None: """Create and persist a root plan.""" plan = _make_plan(plan_id, phase=PlanPhase.EXECUTE) context._pp_plan_repo.create(plan) context._pp_session.commit() @given('a mid plan with ID "{mid_id}" under root "{root_id}"') def step_mid_plan(context: Context, mid_id: str, root_id: str) -> None: """Create and persist a mid-level plan.""" plan = _make_plan( mid_id, parent_plan_id=root_id, root_plan_id=root_id, phase=PlanPhase.STRATEGIZE, ) context._pp_plan_repo.create(plan) context._pp_session.commit() @given( 'a leaf plan with ID "{leaf_id}" under parent "{parent_id}" and root "{root_id}"' ) def step_leaf_plan( context: Context, leaf_id: str, parent_id: str, root_id: str ) -> None: """Create and persist a leaf plan.""" plan = _make_plan( leaf_id, parent_plan_id=parent_id, root_plan_id=root_id, phase=PlanPhase.STRATEGIZE, ) context._pp_plan_repo.create(plan) context._pp_session.commit() @when('I retrieve the leaf plan "{plan_id}"') def step_retrieve_leaf(context: Context, plan_id: str) -> None: """Retrieve a leaf plan from the repo.""" context._pp_leaf = context._pp_plan_repo.get(plan_id) assert context._pp_leaf is not None @then('the leaf plan parent should be "{parent_id}"') def step_leaf_parent(context: Context, parent_id: str) -> None: """Verify the leaf plan's parent reference.""" assert context._pp_leaf.identity.parent_plan_id == parent_id @then('the leaf plan root should be "{root_id}"') def step_leaf_root(context: Context, root_id: str) -> None: """Verify the leaf plan's root reference.""" assert context._pp_leaf.identity.root_plan_id == root_id # --------------------------------------------------------------------------- # Cross-restart scenarios # --------------------------------------------------------------------------- @when("I close and reopen the persistence database") def step_reopen_database(context: Context) -> None: """Close the session and engine, then reopen from the same file.""" db_path = context._pp_db_path context._pp_session.close() context._pp_engine.dispose() engine = create_engine(f"sqlite:///{db_path}", echo=False) sm = sessionmaker(bind=engine) session = sm() context._pp_engine = engine context._pp_session = session context._pp_session_factory = lambda: session context._pp_plan_repo = LifecyclePlanRepository( session_factory=context._pp_session_factory, ) context._pp_action_repo = ActionRepository( session_factory=context._pp_session_factory, ) @then('the plan should still be in phase "{phase}" with state "{state}"') def step_plan_phase_after_restart(context: Context, phase: str, state: str) -> None: """Verify plan phase/state after reconnection.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None, "Plan not found after reconnect" assert plan.phase == PlanPhase(phase) assert plan.processing_state == ProcessingState(state) @given('a persisted plan with project links "{link1}" and "{link2}"') def step_plan_with_project_links(context: Context, link1: str, link2: str) -> None: """Create a plan with project links.""" plan_id = _next_ulid() plan = _make_plan( plan_id, project_links=[ ProjectLink(project_name=link1), ProjectLink(project_name=link2), ], ) context._pp_plan_repo.create(plan) context._pp_session.commit() context._pp_current_plan_id = plan_id @then("the plan should still have {count:d} project links") def step_plan_project_links_count(context: Context, count: int) -> None: """Verify the plan still has the expected number of project links.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None assert len(plan.project_links) == count @given('a persisted plan with arguments "{arg1}" and "{arg2}"') def step_plan_with_arguments(context: Context, arg1: str, arg2: str) -> None: """Create a plan with arguments.""" plan_id = _next_ulid() plan = _make_plan( plan_id, arguments={arg1: "value1", arg2: "value2"}, arguments_order=[arg1, arg2], ) context._pp_plan_repo.create(plan) context._pp_session.commit() context._pp_current_plan_id = plan_id @then("the plan should still have {count:d} arguments in order") def step_plan_arguments_count(context: Context, count: int) -> None: """Verify the plan still has the expected number of arguments.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None assert len(plan.arguments) == count assert len(plan.arguments_order) == count @given('a persisted plan with invariant "{text}"') def step_plan_with_invariant(context: Context, text: str) -> None: """Create a plan with an invariant.""" plan_id = _next_ulid() plan = _make_plan( plan_id, invariants=[PlanInvariant(text=text, source=InvariantSource.PLAN)], ) context._pp_plan_repo.create(plan) context._pp_session.commit() context._pp_current_plan_id = plan_id @then('the plan should still have invariant "{text}"') def step_plan_has_invariant(context: Context, text: str) -> None: """Verify the plan still has the invariant.""" plan = context._pp_plan_repo.get(context._pp_current_plan_id) assert plan is not None invariant_texts = [inv.text for inv in plan.invariants] assert text in invariant_texts, f"Expected '{text}' in {invariant_texts}"