Files
cleveragents-core/features/steps/plan_lifecycle_coverage_steps.py

759 lines
25 KiB
Python

"""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"