"""Step definitions for plan lifecycle coverage scenarios. Targets uncovered lines in: - repositories.py: get_by_name, update (children), delete, list_plans filters, count, error paths, action update with inputs_schema/arguments/invariants - plan_lifecycle_service.py: get_plan from persistence fallback, constrain_apply, auto_progress no-op, _persist_action_update (archive) - unit_of_work.py: lifecycle_plans lazy property, add/flush/refresh """ from __future__ import annotations import contextlib from typing import Any from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.config.settings import Settings from cleveragents.domain.models.core.action import ( ActionArgument, ArgumentRequirement, ArgumentType, ) from cleveragents.domain.models.core.plan import ( InvariantSource, PlanInvariant, PlanPhase, ProcessingState, ProjectLink, ) from cleveragents.infrastructure.database.models import ( Base, LifecyclePlanModel, ) from cleveragents.infrastructure.database.repositories import ( DuplicatePlanError, LifecyclePlanRepository, PlanNotFoundError, ) from cleveragents.infrastructure.database.unit_of_work import UnitOfWork # ── Helpers ── def _build_cov_uow() -> tuple[UnitOfWork, sessionmaker[Session], Any]: """Build an in-memory UoW for coverage testing.""" engine = create_engine( "sqlite:///:memory:", echo=False, future=True, connect_args={"check_same_thread": False}, ) @event.listens_for(engine, "connect") def _fk(dbapi_conn: Any, _rec: Any) -> None: cursor = dbapi_conn.cursor() cursor.execute("PRAGMA foreign_keys=ON") cursor.close() Base.metadata.create_all(engine) sf: sessionmaker[Session] = sessionmaker( bind=engine, expire_on_commit=False, autoflush=False, autocommit=False, class_=Session, ) uow = UnitOfWork.__new__(UnitOfWork) uow.database_url = "sqlite:///:memory:" uow._engine = engine uow._session_factory = sf uow._database_initialized = True uow._prompt_for_migration = None return uow, sf, engine # ── Background ── @given("a coverage test service backed by SQLite") def step_cov_bg(context: Context) -> None: uow, sf, engine = _build_cov_uow() settings = Settings() service = PlanLifecycleService(settings=settings, unit_of_work=uow) context.cov_service = service context.cov_uow = uow context.cov_sf = sf context.cov_engine = engine context.cov_plan = None context.cov_error = None context.cov_result = None context.cov_action_name = None # ── Helpers for creating plans ── def _create_cov_plan(context: Context) -> Any: """Create a plan via the service and store it on context.""" svc: PlanLifecycleService = context.cov_service action_name = "local/cov-basic" with contextlib.suppress(Exception): svc.create_action( name=action_name, description="Coverage basic action", definition_of_done="Tests pass", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", reusable=True, ) plan = svc.use_action(action_name=action_name) context.cov_plan = plan context.cov_action_name = action_name return plan @given("a coverage plan exists via the service") def step_cov_plan_exists(context: Context) -> None: _create_cov_plan(context) @given("a coverage plan with project links and invariants exists") def step_cov_plan_with_children(context: Context) -> None: svc: PlanLifecycleService = context.cov_service action_name = "local/cov-children" with contextlib.suppress(Exception): svc.create_action( name=action_name, description="Action with children", definition_of_done="All green", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", invariants=["Code must compile", "No regressions"], reusable=True, ) links = [ ProjectLink(project_name="local/api-service", alias="api", read_only=False), ProjectLink(project_name="local/frontend", alias="web", read_only=True), ] invariants = [ PlanInvariant(text="Security audit required", source=InvariantSource.PLAN), ] plan = svc.use_action( action_name=action_name, project_links=links, invariants=invariants, arguments={}, ) context.cov_plan = plan context.cov_action_name = action_name @given("a coverage plan in apply-queued state") def step_cov_plan_apply_queued(context: Context) -> None: svc: PlanLifecycleService = context.cov_service action_name = "local/cov-apply-q" with contextlib.suppress(Exception): svc.create_action( name=action_name, description="Apply test action", definition_of_done="Done", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", reusable=True, ) plan = svc.use_action(action_name=action_name) pid = plan.identity.plan_id svc.start_strategize(pid) svc.complete_strategize(pid) svc.execute_plan(pid) svc.start_execute(pid) svc.complete_execute(pid) svc.apply_plan(pid) context.cov_plan = svc.get_plan(pid) @given('a coverage action "{name}" exists') def step_cov_action_exists(context: Context, name: str) -> None: svc: PlanLifecycleService = context.cov_service svc.create_action( name=name, description=f"Coverage action {name}", definition_of_done="Tests pass", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", reusable=True, ) @given("a coverage action with inputs_schema and arguments exists") def step_cov_action_with_inputs(context: Context) -> None: svc: PlanLifecycleService = context.cov_service args = [ ActionArgument( name="target_dir", arg_type=ArgumentType.STRING, requirement=ArgumentRequirement.REQUIRED, description="Target directory", default_value="/tmp", ), ActionArgument( name="max_retries", arg_type=ArgumentType.INTEGER, requirement=ArgumentRequirement.OPTIONAL, description="Max retries", default_value=3, ), ] svc.create_action( name="local/cov-inputs-act", description="Action with inputs", definition_of_done="Done", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", arguments=args, invariants=["Must not break API", "Must be backwards compatible"], reusable=True, tags=["test", "coverage"], ) # Manually set inputs_schema on the action to cover line 924 action = svc.get_action("local/cov-inputs-act") action.inputs_schema = { "type": "object", "properties": {"target_dir": {"type": "string"}}, } context.cov_action_name = "local/cov-inputs-act" # ── Get plan by name ── @when("I retrieve the plan by its namespaced name from the repository") def step_get_by_name(context: Context) -> None: plan = context.cov_plan name = str(plan.namespaced_name) sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.get_by_name(name) session.close() @then("the plan retrieved by name should match the original") def step_verify_get_by_name(context: Context) -> None: result = context.cov_result assert result is not None, "get_by_name returned None" assert result.identity.plan_id == context.cov_plan.identity.plan_id @when('I retrieve a plan by name "{name}" from the repository') def step_get_by_name_unknown(context: Context, name: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.get_by_name(name) session.close() @then("the plan-by-name result should be None") def step_verify_get_by_name_none(context: Context) -> None: assert context.cov_result is None, f"Expected None, got {context.cov_result}" # ── Update plan with children ── @when("I update the plan description and phase through the repository") def step_update_plan(context: Context) -> None: plan = context.cov_plan plan.description = "Updated description for coverage" sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) repo.update(plan) session.commit() session.close() @then("the updated plan should have the new description in the DB") def step_verify_updated_desc(context: Context) -> None: plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) loaded = repo.get(plan_id) session.close() assert loaded is not None assert loaded.description == "Updated description for coverage" @then("the updated plan should retain project links") def step_verify_project_links(context: Context) -> None: plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) loaded = repo.get(plan_id) session.close() assert loaded is not None links = getattr(loaded, "project_links", []) or [] assert len(links) >= 2, f"Expected >= 2 project links, got {len(links)}" @then("the updated plan should retain arguments") def step_verify_arguments(context: Context) -> None: plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) loaded = repo.get(plan_id) session.close() assert loaded is not None # Arguments dict may be empty, but it should be present args = getattr(loaded, "arguments", None) assert args is not None, "arguments should not be None" @then("the updated plan should retain invariants") def step_verify_invariants(context: Context) -> None: plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) loaded = repo.get(plan_id) session.close() assert loaded is not None invs = getattr(loaded, "invariants", []) or [] # Should have action invariants + plan invariants assert len(invs) >= 1, f"Expected >= 1 invariants, got {len(invs)}" # ── Delete plan ── @when("I delete the plan from the repository") def step_delete_plan(context: Context) -> None: plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.delete(plan_id) session.commit() session.close() @then("the plan should no longer be retrievable from the DB") def step_verify_deleted(context: Context) -> None: assert context.cov_result is True, "delete should return True" plan_id = context.cov_plan.identity.plan_id sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) loaded = repo.get(plan_id) session.close() assert loaded is None, "Plan should be deleted" @when('I delete a non-existent plan "{plan_id}" from the repository') def step_delete_nonexistent(context: Context, plan_id: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.delete(plan_id) session.close() @then("the delete result should be false") def step_verify_delete_false(context: Context) -> None: assert context.cov_result is False, f"Expected False, got {context.cov_result}" # ── List plans with filters ── @when('I list plans filtered by phase "{phase}"') def step_list_by_phase(context: Context, phase: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.list_plans(phase=phase) session.close() @when('I list plans filtered by namespace "{namespace}"') def step_list_by_namespace(context: Context, namespace: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.list_plans(namespace=namespace) session.close() @when("I list plans filtered by action_name") def step_list_by_action_name(context: Context) -> None: action_name = context.cov_action_name or str(context.cov_plan.action_name) sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.list_plans(action_name=action_name) session.close() @then("the listed plans should include the coverage plan") def step_verify_listed(context: Context) -> None: plans = context.cov_result assert len(plans) >= 1, f"Expected >= 1 plans, got {len(plans)}" plan_ids = [p.identity.plan_id for p in plans] assert context.cov_plan.identity.plan_id in plan_ids # ── Count plans ── @when("I count plans with no filters") def step_count_no_filter(context: Context) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.count() session.close() @then("the count should be at least 1") def step_verify_count(context: Context) -> None: assert context.cov_result >= 1, f"Expected >= 1, got {context.cov_result}" @when('I count plans filtered by phase "{phase}"') def step_count_by_phase(context: Context, phase: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.count(phase=phase) session.close() @when('I count plans filtered by processing_state "{state}"') def step_count_by_state(context: Context, state: str) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) context.cov_result = repo.count(processing_state=state) session.close() @then("the filtered count should be at least 1") def step_verify_filtered_count(context: Context) -> None: assert context.cov_result >= 1, f"Expected >= 1, got {context.cov_result}" # ── Duplicate plan error ── @when("I try to create a duplicate plan with the same ID") def step_create_duplicate_plan(context: Context) -> None: plan = context.cov_plan sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) try: repo.create(plan) session.commit() context.cov_error = None except DuplicatePlanError as exc: context.cov_error = exc finally: session.close() @then("a DuplicatePlanError should be raised") def step_verify_duplicate_error(context: Context) -> None: assert context.cov_error is not None, "Expected DuplicatePlanError" assert isinstance(context.cov_error, DuplicatePlanError) # ── Update non-existent plan error ── @when("I try to update a plan that does not exist in the DB") def step_update_nonexistent_plan(context: Context) -> None: from datetime import datetime from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, PlanIdentity, PlanTimestamps, ) fake_plan = Plan( identity=PlanIdentity(plan_id="01ZZZZZZZZZZZZZZZZZZZZZZZZ"), namespaced_name=NamespacedName( server=None, namespace="local", name="fake-plan" ), action_name="local/fake-action", description="Fake plan", definition_of_done="Never", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.QUEUED, strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", timestamps=PlanTimestamps( created_at=datetime.now(), updated_at=datetime.now(), ), reusable=True, read_only=False, ) sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) try: repo.update(fake_plan) context.cov_error = None except PlanNotFoundError as exc: context.cov_error = exc finally: session.close() @then("a PlanNotFoundError should be raised") def step_verify_not_found_error(context: Context) -> None: assert context.cov_error is not None, "Expected PlanNotFoundError" assert isinstance(context.cov_error, PlanNotFoundError) # ── Archive action (triggers ActionRepository.update) ── @when('I archive the coverage action "{name}"') def step_archive_action(context: Context, name: str) -> None: svc: PlanLifecycleService = context.cov_service context.cov_result = svc.archive_action(name) @then('the action "{name}" should be archived in the DB') def step_verify_archived(context: Context, name: str) -> None: from cleveragents.infrastructure.database.repositories import ActionRepository sf = context.cov_sf session = sf() repo = ActionRepository(session_factory=lambda: session) action = repo.get_by_name(name) session.close() assert action is not None, f"Action {name} not found" assert action.state.value == "archived" or str(action.state) == "archived" # ── Action update with inputs_schema/arguments/invariants ── @when("I update the coverage action with new description") def step_update_action(context: Context) -> None: svc: PlanLifecycleService = context.cov_service action = svc.get_action("local/cov-inputs-act") action.description = "Updated description" # Persist via archive (which calls update in the repository) # Instead, let's call update directly via UoW to hit the exact lines with context.cov_uow.transaction() as ctx: ctx.actions.update(action) @then("the action should retain its inputs_schema in the DB") def step_verify_inputs_schema(context: Context) -> None: from cleveragents.infrastructure.database.repositories import ActionRepository sf = context.cov_sf session = sf() repo = ActionRepository(session_factory=lambda: session) action = repo.get_by_name("local/cov-inputs-act") session.close() assert action is not None assert action.inputs_schema is not None, "inputs_schema should be set" @then("the action should retain its arguments with defaults in the DB") def step_verify_action_args(context: Context) -> None: from cleveragents.infrastructure.database.repositories import ActionRepository sf = context.cov_sf session = sf() repo = ActionRepository(session_factory=lambda: session) action = repo.get_by_name("local/cov-inputs-act") session.close() assert action is not None assert len(action.arguments) >= 2, ( f"Expected >= 2 args, got {len(action.arguments)}" ) # Check default values were preserved defaults = [ a.default_value for a in action.arguments if a.default_value is not None ] assert len(defaults) >= 1, "Should have at least 1 argument with default" @then("the action should retain its invariants in the DB") def step_verify_action_invariants(context: Context) -> None: from cleveragents.infrastructure.database.repositories import ActionRepository sf = context.cov_sf session = sf() repo = ActionRepository(session_factory=lambda: session) action = repo.get_by_name("local/cov-inputs-act") session.close() assert action is not None assert len(action.invariants) >= 2, ( f"Expected >= 2 invariants, got {len(action.invariants)}" ) # ── Service get_plan from persistence fallback ── @when("I clear the in-memory plan cache") def step_clear_cache(context: Context) -> None: svc: PlanLifecycleService = context.cov_service # Clear in-memory plans dict to force fallback to persistence svc._plans.clear() @when("I retrieve the plan by ID through the service") def step_get_plan_by_id(context: Context) -> None: svc: PlanLifecycleService = context.cov_service plan_id = context.cov_plan.identity.plan_id context.cov_result = svc.get_plan(plan_id) @then("the plan should be loaded from the persistence layer") def step_verify_from_persistence(context: Context) -> None: result = context.cov_result assert result is not None, "Plan not loaded from persistence" assert result.identity.plan_id == context.cov_plan.identity.plan_id # ── Constrain apply ── @when('I constrain the apply with reason "{reason}"') def step_constrain_apply(context: Context, reason: str) -> None: svc: PlanLifecycleService = context.cov_service plan_id = context.cov_plan.identity.plan_id # start the apply phase first svc.start_apply(plan_id) context.cov_plan = svc.constrain_apply(plan_id, reason) @then('the coverage plan processing state should be "{state}"') def step_cov_check_proc_state(context: Context, state: str) -> None: assert context.cov_plan.processing_state.value == state, ( f"Expected {state}, got {context.cov_plan.processing_state.value}" ) @then('the coverage plan error message should be "{message}"') def step_cov_check_error_msg(context: Context, message: str) -> None: assert context.cov_plan.error_message == message, ( f"Expected '{message}', got '{context.cov_plan.error_message}'" ) # ── Auto progress no-op ── @when("I call auto_progress on the plan") def step_auto_progress(context: Context) -> None: svc: PlanLifecycleService = context.cov_service plan_id = context.cov_plan.identity.plan_id context.cov_result = svc.auto_progress(plan_id) @then("the plan should be returned unchanged") def step_verify_unchanged(context: Context) -> None: result = context.cov_result assert result is not None assert result.identity.plan_id == context.cov_plan.identity.plan_id # Phase should still be strategize/queued assert result.phase == PlanPhase.STRATEGIZE # ── UoW lifecycle_plans property ── @when("I open a UoW transaction and access lifecycle_plans") def step_uow_lifecycle_plans(context: Context) -> None: uow = context.cov_uow with uow.transaction() as ctx: context.cov_result = ctx.lifecycle_plans @then("the lifecycle_plans property should return a LifecyclePlanRepository") def step_verify_lifecycle_plans_type(context: Context) -> None: assert isinstance(context.cov_result, LifecyclePlanRepository) # ── UoW add/flush ── @when("I use UoW context to add a raw model entity and flush") def step_uow_add_flush(context: Context) -> None: from datetime import datetime # First, create an action (to satisfy FK constraint on v3_plans.action_name) svc: PlanLifecycleService = context.cov_service with contextlib.suppress(Exception): svc.create_action( name="local/uow-flush-test", description="UoW flush test action", definition_of_done="Done", strategy_actor="openai/gpt-4", execution_actor="openai/gpt-4", reusable=True, ) uow = context.cov_uow with uow.transaction() as ctx: # Create a raw model and add it via the context model = LifecyclePlanModel() model.plan_id = "01TESTADD00000000000000000" # type: ignore[assignment] model.namespaced_name = "local/uow-add-test" # type: ignore[assignment] model.namespace = "local" # type: ignore[assignment] model.name = "uow-add-test" # type: ignore[assignment] model.action_name = "local/uow-flush-test" # type: ignore[assignment] model.description = "UoW add test" # type: ignore[assignment] model.definition_of_done = "Pass" # type: ignore[assignment] model.phase = "strategize" # type: ignore[assignment] model.processing_state = "queued" # type: ignore[assignment] model.attempt = 1 # type: ignore[assignment] model.strategy_actor = "openai/gpt-4" # type: ignore[assignment] model.execution_actor = "openai/gpt-4" # type: ignore[assignment] model.reusable = True # type: ignore[assignment] model.read_only = False # type: ignore[assignment] model.tags_json = "[]" # type: ignore[assignment] model.sandbox_refs_json = "[]" # type: ignore[assignment] now = datetime.now().isoformat() model.created_at = now # type: ignore[assignment] model.updated_at = now # type: ignore[assignment] ctx.add(model) ctx.flush() context.cov_result = model.plan_id @then("the entity should be queryable in the same session") def step_verify_entity_added(context: Context) -> None: sf = context.cov_sf session = sf() repo = LifecyclePlanRepository(session_factory=lambda: session) plan = repo.get("01TESTADD00000000000000000") session.close() assert plan is not None, "Added entity should be queryable"