diff --git a/features/steps/decision_dependency_basic_steps.py b/features/steps/decision_dependency_basic_steps.py new file mode 100644 index 000000000..4740c26cf --- /dev/null +++ b/features/steps/decision_dependency_basic_steps.py @@ -0,0 +1,350 @@ +"""Basic steps for decision dependency persistence feature.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.core.exceptions import DatabaseError +from cleveragents.infrastructure.database.models import DecisionDependencyModel + +from features.steps.helpers.decision_dependency_helpers import ( + PLAN_ID_1, + PLAN_ID_2, + create_child, + create_prerequisite_action, + create_prerequisite_plan, + create_root, + get_edges, + record_dependency, + setup_db, +) + + +@given("a fresh database for decision dependencies") +def step_fresh_db(context: Context) -> None: + """Set up a fresh database.""" + setup_db(context) + + +@given('a prerequisite action "{action_name}" exists for dependencies') +def step_prereq_action(context: Context, action_name: str) -> None: + """Create a prerequisite action.""" + create_prerequisite_action(context, action_name) + + +@given("a prerequisite plan exists for dependencies") +def step_prereq_plan(context: Context) -> None: + """Create a prerequisite plan.""" + create_prerequisite_plan(context, PLAN_ID_1) + + +@given("a persisted root decision for dependencies") +def step_persisted_root_dep(context: Context) -> None: + """Create and persist a root decision.""" + root = create_root(context) + context._ddp_root_id = root.decision_id + context._ddp_root_decision = root + + +@given("a persisted child decision for dependencies") +def step_persisted_child_dep(context: Context) -> None: + """Create and persist a child decision.""" + root_id = getattr(context, "_ddp_root_id", None) + if root_id is None: + root_id = context._ddp_root_ids[0] + child = create_child(context, root_id) + context._ddp_child_id = child.decision_id + context._ddp_child_decision = child + + +@given("{count:d} persisted sibling decisions for dependencies") +def step_persisted_siblings_dep(context: Context, count: int) -> None: + """Create and persist multiple sibling decisions.""" + context._ddp_sibling_ids = [] + for index in range(count): + sibling = create_child( + context, + context._ddp_root_id, + sequence_number=index + 1, + ) + context._ddp_sibling_ids.append(sibling.decision_id) + + +@given("{count:d} persisted root decisions for dependencies") +def step_persisted_roots_dep(context: Context, count: int) -> None: + """Create and persist multiple root decisions.""" + context._ddp_root_ids = [] + for index in range(count): + root = create_root(context, index=index) + context._ddp_root_ids.append(root.decision_id) + context._ddp_root_id = context._ddp_root_ids[0] + + +@given("{count:d} persisted plans for dependencies") +def step_persisted_plans_dep(context: Context, count: int) -> None: + """Create and persist multiple plans.""" + context._ddp_plan_ids = [PLAN_ID_1] + if count > 1: + create_prerequisite_plan(context, PLAN_ID_2) + context._ddp_plan_ids.append(PLAN_ID_2) + + +@given("a decision tree in plan 1 with dependencies") +def step_decision_tree_plan1_dep(context: Context) -> None: + """Create a decision tree in plan 1 with dependencies.""" + root = create_root(context, PLAN_ID_1) + child = create_child(context, root.decision_id, PLAN_ID_1) + record_dependency(context, root.decision_id, child.decision_id) + context._ddp_plan1_root_id = root.decision_id + context._ddp_plan1_child_id = child.decision_id + + +@given("a decision tree in plan 2 with dependencies") +def step_decision_tree_plan2_dep(context: Context) -> None: + """Create a decision tree in plan 2 with dependencies.""" + root = create_root(context, PLAN_ID_2) + child = create_child(context, root.decision_id, PLAN_ID_2) + record_dependency(context, root.decision_id, child.decision_id) + context._ddp_plan2_root_id = root.decision_id + context._ddp_plan2_child_id = child.decision_id + + +@given("a decision in plan 1 and a decision in plan 2") +def step_cross_plan_decisions_dep(context: Context) -> None: + """Create decisions in different plans.""" + d1 = create_root(context, PLAN_ID_1) + d2 = create_root(context, PLAN_ID_2) + context._ddp_cross_plan_id1 = d1.decision_id + context._ddp_cross_plan_id2 = d2.decision_id + + +@given("a recorded dependency from root to child") +def step_given_recorded_dependency_root_child(context: Context) -> None: + """Record a dependency from root to child during scenario setup.""" + record_dependency(context, context._ddp_root_id, context._ddp_child_id) + + +@given("recorded dependencies from root to both siblings") +def step_given_recorded_dependencies_root_siblings(context: Context) -> None: + """Record dependencies from root to all siblings during setup.""" + for sibling_id in context._ddp_sibling_ids: + record_dependency(context, context._ddp_root_id, sibling_id) + + +@given("recorded dependencies from both roots to the child") +def step_given_recorded_dependencies_both_roots(context: Context) -> None: + """Record dependencies from each root to the child during setup.""" + for root_id in context._ddp_root_ids: + record_dependency(context, root_id, context._ddp_child_id) + + +@when("I record a dependency from the root to the child") +def step_record_dependency_root_child(context: Context) -> None: + """Record a dependency from root to child.""" + record_dependency(context, context._ddp_root_id, context._ddp_child_id) + + +@when("I record dependencies from the root to both siblings") +def step_record_dependencies_root_siblings(context: Context) -> None: + """Record dependencies from root to all siblings.""" + for sibling_id in context._ddp_sibling_ids: + record_dependency(context, context._ddp_root_id, sibling_id) + + +@when('I record a dependency with relationship_type "{rel_type}"') +def step_record_dependency_custom_type(context: Context, rel_type: str) -> None: + """Record a dependency with custom relationship type.""" + record_dependency( + context, + context._ddp_root_id, + context._ddp_child_id, + relationship_type=rel_type, + ) + context._ddp_custom_rel_type = rel_type + + +@when("I try to record a self-loop dependency") +def step_try_self_loop_dependency(context: Context) -> None: + """Try to record a self-loop dependency.""" + try: + record_dependency(context, context._ddp_root_id, context._ddp_root_id) + context._ddp_self_loop_error = None + except DatabaseError as exc: + context._ddp_self_loop_error = exc + + +@when("I try to record the same dependency again") +def step_try_duplicate_dependency(context: Context) -> None: + """Try to record the same dependency again.""" + try: + record_dependency(context, context._ddp_root_id, context._ddp_child_id) + context._ddp_duplicate_error = None + except DatabaseError as exc: + context._ddp_duplicate_error = exc + + +@when("I try to record a cross-plan dependency") +def step_try_cross_plan_dependency(context: Context) -> None: + """Try to record a cross-plan dependency.""" + try: + record_dependency( + context, + context._ddp_cross_plan_id1, + context._ddp_cross_plan_id2, + ) + context._ddp_cross_plan_error = None + except DatabaseError as exc: + context._ddp_cross_plan_error = exc + + +@when("I get influence edges for the plan") +def step_get_influence_edges(context: Context) -> None: + """Get influence edges for the plan.""" + context._ddp_influence_edges = get_edges(context, PLAN_ID_1) + + +@when("I get influence edges for plan 1") +def step_get_influence_edges_plan1(context: Context) -> None: + """Get influence edges for plan 1.""" + context._ddp_influence_edges_plan1 = get_edges(context, PLAN_ID_1) + + +@when("I get influence edges for plan 2") +def step_get_influence_edges_plan2(context: Context) -> None: + """Get influence edges for plan 2.""" + context._ddp_influence_edges_plan2 = get_edges(context, PLAN_ID_2) + + +@then("the dependency should be persisted to the database") +def step_assert_dependency_persisted(context: Context) -> None: + """Assert that the dependency was persisted.""" + edges = get_edges(context) + target_id = getattr(context, "_ddp_new_decision_id", None) + if target_id is None: + target_id = context._ddp_child_id + assert context._ddp_root_id in edges, "Root should be in edges" + assert target_id in edges[context._ddp_root_id], "Child should be in root's targets" + + +@then('the dependency should have relationship_type "{rel_type}"') +def step_assert_dependency_rel_type(context: Context, rel_type: str) -> None: + """Assert that the dependency has the correct relationship type.""" + _assert_dependency_relationship_type(context, rel_type) + + +@then('the dependency should be persisted with relationship_type "{rel_type}"') +def step_assert_dependency_persisted_with_type(context: Context, rel_type: str) -> None: + """Assert that the dependency was persisted with the correct type.""" + _assert_dependency_relationship_type(context, rel_type) + + +@then("both dependencies should be persisted to the database") +def step_assert_both_dependencies_persisted(context: Context) -> None: + """Assert that both dependencies were persisted.""" + edges = get_edges(context) + if hasattr(context, "_ddp_new_decision_id"): + for root_id in context._ddp_root_ids: + assert root_id in edges, f"Root {root_id} should be in edges" + assert context._ddp_new_decision_id in edges[root_id], ( + f"New decision should be in {root_id}'s targets" + ) + return + + assert context._ddp_root_id in edges, "Root should be in edges" + assert len(edges[context._ddp_root_id]) == 2, "Root should have 2 targets" + for sibling_id in context._ddp_sibling_ids: + assert sibling_id in edges[context._ddp_root_id], ( + f"Sibling {sibling_id} should be in root's targets" + ) + + +@then("the influence edges should be empty") +def step_assert_influence_edges_empty(context: Context) -> None: + """Assert that influence edges are empty.""" + assert context._ddp_influence_edges == {}, "Influence edges should be empty" + + +@then("the influence edges should contain {count:d} source") +def step_assert_influence_edges_source_count(context: Context, count: int) -> None: + """Assert the number of sources in influence edges.""" + assert len(context._ddp_influence_edges) == count, ( + f"Should have {count} source(s), got {len(context._ddp_influence_edges)}" + ) + + +@then("the influence edges should contain {count:d} sources") +def step_assert_influence_edges_sources_count(context: Context, count: int) -> None: + """Assert the number of sources in influence edges.""" + step_assert_influence_edges_source_count(context, count) + + +@then("the influence edges should map root to {targets}") +def step_assert_influence_edges_mapping(context: Context, targets: str) -> None: + """Assert that influence edges map root to the specified targets.""" + if targets == "[child]": + expected = [context._ddp_child_id] + elif targets == "[sibling1, sibling2]": + expected = context._ddp_sibling_ids + else: + expected = [] + + assert context._ddp_root_id in context._ddp_influence_edges, ( + "Root should be in edges" + ) + actual = context._ddp_influence_edges[context._ddp_root_id] + assert set(actual) == set(expected), f"Expected {expected}, got {actual}" + + +@then("the influence edges should only include decisions from plan 1") +def step_assert_influence_edges_plan1_only(context: Context) -> None: + """Assert that influence edges only include plan 1 decisions.""" + edges = context._ddp_influence_edges_plan1 + plan1_ids = { + context._ddp_plan1_root_id, + context._ddp_plan1_child_id, + } + for source, targets in edges.items(): + assert source in plan1_ids, f"Source {source} should be in plan 1" + for target in targets: + assert target in plan1_ids, f"Target {target} should be in plan 1" + + +@then("the cross-plan dependency should not appear in influence edges") +def step_assert_no_cross_plan_dependency(context: Context) -> None: + """Assert that cross-plan dependencies don't appear in edges.""" + edges = get_edges(context) + if context._ddp_cross_plan_id1 in edges: + assert context._ddp_cross_plan_id2 not in edges[context._ddp_cross_plan_id1], ( + "Cross-plan dependency should not appear" + ) + + +@then("a database constraint error should be raised") +def step_assert_constraint_error(context: Context) -> None: + """Assert that a constraint error was raised.""" + assert context._ddp_self_loop_error is not None, ( + "Should have raised a constraint error" + ) + + +@then("the operation should complete without error") +def step_assert_no_error(context: Context) -> None: + """Assert that the operation completed without error.""" + assert context._ddp_duplicate_error is None, "Should not have raised an error" + + +def _assert_dependency_relationship_type(context: Context, rel_type: str) -> None: + session = context._ddp_factory() + dep = ( + session.query(DecisionDependencyModel) + .filter_by( + source_decision_id=context._ddp_root_id, + target_decision_id=context._ddp_child_id, + ) + .first() + ) + assert dep is not None, "Dependency should exist" + assert dep.relationship_type == rel_type, ( + f"Relationship type should be {rel_type}, got {dep.relationship_type}" + ) diff --git a/features/steps/decision_dependency_integration_steps.py b/features/steps/decision_dependency_integration_steps.py new file mode 100644 index 000000000..3704287ca --- /dev/null +++ b/features/steps/decision_dependency_integration_steps.py @@ -0,0 +1,184 @@ +"""DecisionService and correction workflow steps for dependency persistence.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.decision_service import DecisionService +from cleveragents.domain.models.core.decision import DecisionType + +from features.steps.helpers.decision_dependency_helpers import ( + PLAN_ID_1, + affected_subtree, + create_child, + create_three_level_tree, + get_edges, + record_dependency, + record_three_level_edges, +) + + +@given("a persisted decision tree with 3 levels for dependencies") +def step_decision_tree_3levels_dep(context: Context) -> None: + """Create a 3-level decision tree.""" + create_three_level_tree(context) + + +@given("recorded dependencies forming a DAG") +def step_recorded_dag_dep(context: Context) -> None: + """Record dependencies forming a DAG.""" + record_three_level_edges(context) + + +@given("a persisted decision tree with dependencies") +def step_persisted_decision_tree_with_dependencies(context: Context) -> None: + """Create a persisted decision tree and matching dependency edges.""" + create_three_level_tree(context) + record_three_level_edges(context) + context._ddp_root_id = context._ddp_tree_root_id + context._ddp_child_id = context._ddp_tree_child_id + + +@given("a persisted child decision depending on the root") +def step_persisted_child_decision_depending_on_root(context: Context) -> None: + """Create a child decision and persist a root -> child dependency.""" + child = create_child(context, context._ddp_root_id) + context._ddp_child_id = child.decision_id + context._ddp_child_decision = child + record_dependency(context, context._ddp_root_id, context._ddp_child_id) + + +@when("I create a new DecisionService instance with the same database") +def step_new_decision_service(context: Context) -> None: + """Create a new DecisionService with the same database.""" + context._ddp_new_service = DecisionService(unit_of_work=context._ddp_uow) + + +@when("I record a new decision with dependency_decision_ids={deps}") +def step_record_decision_with_deps(context: Context, deps: str) -> None: + """Record a new decision with dependencies.""" + service = DecisionService(unit_of_work=context._ddp_uow) + + if deps == "[]": + dep_ids = [] + elif deps == "[root]": + dep_ids = [context._ddp_root_id] + elif deps == "[root1, root2]": + dep_ids = context._ddp_root_ids + else: + raise AssertionError(f"Unsupported dependency list literal: {deps}") + + decision = service.record_decision( + plan_id=PLAN_ID_1, + decision_type=DecisionType.STRATEGY_CHOICE, + question="What strategy?", + chosen_option="Strategy A", + dependency_decision_ids=dep_ids, + ) + context._ddp_new_decision_id = decision.decision_id + context._ddp_new_decision = decision + + +@when("I compute affected subtree using influence edges") +def step_compute_affected_subtree(context: Context) -> None: + """Compute all downstream decisions from the persisted influence DAG.""" + context._ddp_influence_edges = get_edges(context) + context._ddp_affected_subtree = affected_subtree( + context._ddp_tree_root_id, + context._ddp_influence_edges, + ) + + +@when("I record a correction decision with the same dependencies") +def step_record_correction_decision_same_dependencies(context: Context) -> None: + """Record a correction decision that depends on the same root.""" + service = DecisionService(unit_of_work=context._ddp_uow) + correction = service.record_decision( + plan_id=PLAN_ID_1, + decision_type=DecisionType.USER_INTERVENTION, + question="How should the previous decision be corrected?", + chosen_option="Apply corrected approach", + parent_decision_id=context._ddp_child_id, + dependency_decision_ids=[context._ddp_root_id], + ) + context._ddp_correction_decision_id = correction.decision_id + + +@then("the new service should retrieve the same dependency edges") +def step_assert_new_service_same_edges(context: Context) -> None: + """Assert that a new service retrieves the same edges.""" + new_edges = context._ddp_new_service.get_influence_edges(PLAN_ID_1) + assert context._ddp_root_id in new_edges, "Root should be in new edges" + assert context._ddp_child_id in new_edges[context._ddp_root_id], ( + "Child should be in new root's targets" + ) + + +@then("the new service should retrieve all dependency edges") +def step_assert_new_service_all_edges(context: Context) -> None: + """Assert that a new service retrieves all edges.""" + new_edges = context._ddp_new_service.get_influence_edges(PLAN_ID_1) + assert len(new_edges) == 2, f"Should have 2 sources, got {len(new_edges)}" + assert context._ddp_tree_root_id in new_edges, "Root should be in edges" + assert context._ddp_tree_child_id in new_edges, "Child should be in edges" + + +@then("no dependencies should be created") +def step_assert_no_dependencies_created(context: Context) -> None: + """Assert that no dependencies were created.""" + edges = get_edges(context) + assert edges == {}, "No dependencies should be created" + + +@then("get_influence_edges should return the dependency") +def step_assert_get_influence_edges_returns_dep(context: Context) -> None: + """Assert that get_influence_edges returns the dependency.""" + edges = get_edges(context) + assert context._ddp_root_id in edges, "Root should be in edges" + assert context._ddp_new_decision_id in edges[context._ddp_root_id], ( + "New decision should be in root's targets" + ) + + +@then("get_influence_edges should return both dependencies") +def step_assert_get_influence_edges_returns_both(context: Context) -> None: + """Assert that get_influence_edges returns both dependencies.""" + edges = get_edges(context) + for root_id in context._ddp_root_ids: + assert root_id in edges, f"Root {root_id} should be in edges" + assert context._ddp_new_decision_id in edges[root_id], ( + f"New decision should be in {root_id}'s targets" + ) + + +@then("get_influence_edges should be empty") +def step_assert_get_influence_edges_empty(context: Context) -> None: + """Assert that get_influence_edges returns empty dict.""" + edges = get_edges(context) + assert edges == {}, "Influence edges should be empty" + + +@then("the affected subtree should include all transitive dependents") +def step_assert_affected_subtree_transitive(context: Context) -> None: + """Assert transitive dependents are included in the affected subtree.""" + expected = { + context._ddp_tree_child_id, + context._ddp_tree_grandchild_id, + } + assert expected <= context._ddp_affected_subtree, ( + f"Expected {expected}, got {context._ddp_affected_subtree}" + ) + + +@then("the correction decision should have the same influence edges") +def step_assert_correction_decision_has_same_edges(context: Context) -> None: + """Assert the correction decision is linked to the same upstream root.""" + edges = get_edges(context) + assert context._ddp_root_id in edges, "Root should be in influence edges" + assert context._ddp_child_id in edges[context._ddp_root_id], ( + "Original child dependency should remain" + ) + assert context._ddp_correction_decision_id in edges[context._ddp_root_id], ( + "Correction decision should depend on the same root" + ) diff --git a/features/steps/decision_dependency_persistence_steps.py b/features/steps/decision_dependency_persistence_steps.py deleted file mode 100644 index b85401de7..000000000 --- a/features/steps/decision_dependency_persistence_steps.py +++ /dev/null @@ -1,670 +0,0 @@ -"""Step definitions for decision_dependency_persistence.feature. - -Tests decision dependency persistence, influence DAG retrieval, -and integration with DecisionService. -""" - -from __future__ import annotations - -from datetime import UTC, datetime - -from behave import given, then, when -from behave.runner import Context -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from cleveragents.application.services.decision_service import DecisionService -from cleveragents.core.exceptions import DatabaseError -from cleveragents.domain.models.core.action import Action, ActionState -from cleveragents.domain.models.core.decision import Decision, DecisionType -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, - DecisionRepository, - LifecyclePlanRepository, -) -from cleveragents.infrastructure.database.unit_of_work import UnitOfWork - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_PLAN_ID_1 = "01HV000000000000000000DP01" -_PLAN_ID_2 = "01HV000000000000000000DP02" - - -def _setup_db(context: Context) -> None: - """Create an in-memory SQLite DB and attach repos.""" - engine = create_engine("sqlite:///:memory:", echo=False) - Base.metadata.create_all(engine) - sm = sessionmaker(bind=engine) - session = sm() - context._ddp_engine = engine - context._ddp_session = session - context._ddp_factory = lambda: session - context._ddp_repo = DecisionRepository(session_factory=context._ddp_factory) - context._ddp_action_repo = ActionRepository(session_factory=context._ddp_factory) - context._ddp_plan_repo = LifecyclePlanRepository( - session_factory=context._ddp_factory, - ) - context._ddp_uow = UnitOfWork( - engine=engine, - session_factory=context._ddp_factory, - ) - - -def _make_decision( - plan_id: str = _PLAN_ID_1, - parent_decision_id: str | None = None, - sequence_number: int = 0, - decision_type: DecisionType = DecisionType.PROMPT_DEFINITION, - question: str = "What approach should we take?", - chosen_option: str = "Build a REST API", -) -> Decision: - """Create a Decision domain model.""" - return Decision( - plan_id=plan_id, - sequence_number=sequence_number, - decision_type=decision_type, - question=question, - chosen_option=chosen_option, - parent_decision_id=parent_decision_id, - ) - - -def _create_prerequisite_action(context: Context, action_name: str) -> None: - """Create a prerequisite action for testing.""" - ns = NamespacedName.parse(action_name) - action = Action( - namespaced_name=ns, - description="Prerequisite action for dependency 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._ddp_action_repo.create(action) - context._ddp_session.commit() - - -def _create_prerequisite_plan(context: Context, plan_id: str) -> None: - """Create a prerequisite plan for testing.""" - now = datetime(2026, 3, 1, tzinfo=UTC) - plan = Plan( - identity=PlanIdentity(plan_id=plan_id, attempt=1), - namespaced_name=NamespacedName(namespace="local", name="decision-plan"), - action_name="local/decision-action", - description="Decision test plan", - definition_of_done="All assertions pass", - phase=PlanPhase.STRATEGIZE, - processing_state=ProcessingState.PROCESSING, - strategy_actor="local/s", - execution_actor="local/e", - timestamps=PlanTimestamps(created_at=now, updated_at=now), - created_by="test-dependency", - tags=[], - reusable=True, - read_only=False, - ) - context._ddp_plan_repo.create(plan) - context._ddp_session.commit() - - -# --------------------------------------------------------------------------- -# Background -# --------------------------------------------------------------------------- - - -@given("a fresh database for decision dependencies") -def step_fresh_db(context: Context) -> None: - """Set up a fresh in-memory database.""" - _setup_db(context) - - -@given('a prerequisite action "{action_name}" exists for dependencies') -def step_prereq_action(context: Context, action_name: str) -> None: - """Create a prerequisite action.""" - _create_prerequisite_action(context, action_name) - - -@given("a prerequisite plan exists for dependencies") -def step_prereq_plan(context: Context) -> None: - """Create a prerequisite plan.""" - _create_prerequisite_plan(context, _PLAN_ID_1) - - -# --------------------------------------------------------------------------- -# Decision creation -# --------------------------------------------------------------------------- - - -@given("a persisted root decision for dependencies") -def step_persisted_root_dep(context: Context) -> None: - """Create and persist a root decision.""" - d = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0) - context._ddp_repo.create(d) - context._ddp_session.commit() - context._ddp_root_id = d.decision_id - context._ddp_root_decision = d - - -@given("a persisted child decision for dependencies") -def step_persisted_child_dep(context: Context) -> None: - """Create and persist a child decision.""" - d = _make_decision( - plan_id=_PLAN_ID_1, - parent_decision_id=context._ddp_root_id, - sequence_number=1, - decision_type=DecisionType.STRATEGY_CHOICE, - ) - context._ddp_repo.create(d) - context._ddp_session.commit() - context._ddp_child_id = d.decision_id - context._ddp_child_decision = d - - -@given("{count:d} persisted sibling decisions for dependencies") -def step_persisted_siblings_dep(context: Context, count: int) -> None: - """Create and persist multiple sibling decisions.""" - context._ddp_sibling_ids = [] - for i in range(count): - d = _make_decision( - plan_id=_PLAN_ID_1, - parent_decision_id=context._ddp_root_id, - sequence_number=i + 1, - decision_type=DecisionType.STRATEGY_CHOICE, - ) - context._ddp_repo.create(d) - context._ddp_sibling_ids.append(d.decision_id) - context._ddp_session.commit() - - -@given("{count:d} persisted root decisions for dependencies") -def step_persisted_roots_dep(context: Context, count: int) -> None: - """Create and persist multiple root decisions.""" - context._ddp_root_ids = [] - for i in range(count): - d = _make_decision( - plan_id=_PLAN_ID_1, - sequence_number=i, - ) - context._ddp_repo.create(d) - context._ddp_root_ids.append(d.decision_id) - context._ddp_session.commit() - - -@given("{count:d} persisted plans for dependencies") -def step_persisted_plans_dep(context: Context, count: int) -> None: - """Create and persist multiple plans.""" - context._ddp_plan_ids = [_PLAN_ID_1] - if count > 1: - _create_prerequisite_plan(context, _PLAN_ID_2) - context._ddp_plan_ids.append(_PLAN_ID_2) - - -@given("a decision tree in plan 1 with dependencies") -def step_decision_tree_plan1_dep(context: Context) -> None: - """Create a decision tree in plan 1 with dependencies.""" - # Create root - root = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0) - context._ddp_repo.create(root) - context._ddp_session.commit() - root_id = root.decision_id - - # Create child - child = _make_decision( - plan_id=_PLAN_ID_1, - parent_decision_id=root_id, - sequence_number=1, - decision_type=DecisionType.STRATEGY_CHOICE, - ) - context._ddp_repo.create(child) - context._ddp_session.commit() - - # Record dependency - context._ddp_repo.record_dependency(root_id, child.decision_id) - context._ddp_session.commit() - - context._ddp_plan1_root_id = root_id - context._ddp_plan1_child_id = child.decision_id - - -@given("a decision tree in plan 2 with dependencies") -def step_decision_tree_plan2_dep(context: Context) -> None: - """Create a decision tree in plan 2 with dependencies.""" - # Create root - root = _make_decision(plan_id=_PLAN_ID_2, sequence_number=0) - context._ddp_repo.create(root) - context._ddp_session.commit() - root_id = root.decision_id - - # Create child - child = _make_decision( - plan_id=_PLAN_ID_2, - parent_decision_id=root_id, - sequence_number=1, - decision_type=DecisionType.STRATEGY_CHOICE, - ) - context._ddp_repo.create(child) - context._ddp_session.commit() - - # Record dependency - context._ddp_repo.record_dependency(root_id, child.decision_id) - context._ddp_session.commit() - - context._ddp_plan2_root_id = root_id - context._ddp_plan2_child_id = child.decision_id - - -@given("a decision in plan 1 and a decision in plan 2") -def step_cross_plan_decisions_dep(context: Context) -> None: - """Create decisions in different plans.""" - d1 = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0) - context._ddp_repo.create(d1) - - d2 = _make_decision(plan_id=_PLAN_ID_2, sequence_number=0) - context._ddp_repo.create(d2) - context._ddp_session.commit() - - context._ddp_cross_plan_id1 = d1.decision_id - context._ddp_cross_plan_id2 = d2.decision_id - - -@given("a persisted decision tree with 3 levels for dependencies") -def step_decision_tree_3levels_dep(context: Context) -> None: - """Create a 3-level decision tree.""" - # Level 0: root - root = _make_decision(plan_id=_PLAN_ID_1, sequence_number=0) - context._ddp_repo.create(root) - context._ddp_session.commit() - root_id = root.decision_id - - # Level 1: child - child = _make_decision( - plan_id=_PLAN_ID_1, - parent_decision_id=root_id, - sequence_number=1, - decision_type=DecisionType.STRATEGY_CHOICE, - ) - context._ddp_repo.create(child) - context._ddp_session.commit() - child_id = child.decision_id - - # Level 2: grandchild - grandchild = _make_decision( - plan_id=_PLAN_ID_1, - parent_decision_id=child_id, - sequence_number=2, - decision_type=DecisionType.IMPLEMENTATION_CHOICE, - ) - context._ddp_repo.create(grandchild) - context._ddp_session.commit() - grandchild_id = grandchild.decision_id - - context._ddp_tree_root_id = root_id - context._ddp_tree_child_id = child_id - context._ddp_tree_grandchild_id = grandchild_id - - -@given("recorded dependencies forming a DAG") -def step_recorded_dag_dep(context: Context) -> None: - """Record dependencies forming a DAG.""" - context._ddp_repo.record_dependency( - context._ddp_tree_root_id, - context._ddp_tree_child_id, - ) - context._ddp_repo.record_dependency( - context._ddp_tree_child_id, - context._ddp_tree_grandchild_id, - ) - context._ddp_session.commit() - - -# --------------------------------------------------------------------------- -# Dependency recording -# --------------------------------------------------------------------------- - - -@when("I record a dependency from the root to the child") -def step_record_dependency_root_child(context: Context) -> None: - """Record a dependency from root to child.""" - context._ddp_repo.record_dependency( - context._ddp_root_id, - context._ddp_child_id, - ) - context._ddp_session.commit() - - -@when("I record dependencies from the root to both siblings") -def step_record_dependencies_root_siblings(context: Context) -> None: - """Record dependencies from root to all siblings.""" - for sibling_id in context._ddp_sibling_ids: - context._ddp_repo.record_dependency( - context._ddp_root_id, - sibling_id, - ) - context._ddp_session.commit() - - -@when("I record a dependency with relationship_type {rel_type}") -def step_record_dependency_custom_type(context: Context, rel_type: str) -> None: - """Record a dependency with custom relationship type.""" - context._ddp_repo.record_dependency( - context._ddp_root_id, - context._ddp_child_id, - relationship_type=rel_type, - ) - context._ddp_session.commit() - context._ddp_custom_rel_type = rel_type - - -@when("I try to record a self-loop dependency") -def step_try_self_loop_dependency(context: Context) -> None: - """Try to record a self-loop dependency.""" - try: - context._ddp_repo.record_dependency( - context._ddp_root_id, - context._ddp_root_id, - ) - context._ddp_session.commit() - context._ddp_self_loop_error = None - except DatabaseError as e: - context._ddp_self_loop_error = e - - -@when("I try to record the same dependency again") -def step_try_duplicate_dependency(context: Context) -> None: - """Try to record the same dependency again.""" - try: - context._ddp_repo.record_dependency( - context._ddp_root_id, - context._ddp_child_id, - ) - context._ddp_session.commit() - context._ddp_duplicate_error = None - except DatabaseError as e: - context._ddp_duplicate_error = e - - -@when("I try to record a cross-plan dependency") -def step_try_cross_plan_dependency(context: Context) -> None: - """Try to record a cross-plan dependency.""" - try: - context._ddp_repo.record_dependency( - context._ddp_cross_plan_id1, - context._ddp_cross_plan_id2, - ) - context._ddp_session.commit() - context._ddp_cross_plan_error = None - except DatabaseError as e: - context._ddp_cross_plan_error = e - - -# --------------------------------------------------------------------------- -# Influence edges retrieval -# --------------------------------------------------------------------------- - - -@when("I get influence edges for the plan") -def step_get_influence_edges(context: Context) -> None: - """Get influence edges for the plan.""" - context._ddp_influence_edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - - -@when("I get influence edges for plan 1") -def step_get_influence_edges_plan1(context: Context) -> None: - """Get influence edges for plan 1.""" - context._ddp_influence_edges_plan1 = context._ddp_repo.get_influence_edges( - _PLAN_ID_1 - ) - - -@when("I get influence edges for plan 2") -def step_get_influence_edges_plan2(context: Context) -> None: - """Get influence edges for plan 2.""" - context._ddp_influence_edges_plan2 = context._ddp_repo.get_influence_edges( - _PLAN_ID_2 - ) - - -# --------------------------------------------------------------------------- -# DecisionService integration -# --------------------------------------------------------------------------- - - -@when("I create a new DecisionService instance with the same database") -def step_new_decision_service(context: Context) -> None: - """Create a new DecisionService with the same database.""" - context._ddp_new_service = DecisionService( - unit_of_work=context._ddp_uow, - ) - - -@when("I record a new decision with dependency_decision_ids={deps}") -def step_record_decision_with_deps(context: Context, deps: str) -> None: - """Record a new decision with dependencies.""" - # Parse the deps string (e.g., "[root]" or "[root1, root2]") - service = DecisionService(unit_of_work=context._ddp_uow) - - if deps == "[]": - dep_ids = [] - elif deps == "[root]": - dep_ids = [context._ddp_root_id] - elif deps == "[root1, root2]": - dep_ids = context._ddp_root_ids - else: - dep_ids = [] - - decision = service.record_decision( - plan_id=_PLAN_ID_1, - decision_type=DecisionType.STRATEGY_CHOICE, - question="What strategy?", - chosen_option="Strategy A", - dependency_decision_ids=dep_ids, - ) - context._ddp_new_decision_id = decision.decision_id - context._ddp_new_decision = decision - - -# --------------------------------------------------------------------------- -# Assertions -# --------------------------------------------------------------------------- - - -@then("the dependency should be persisted to the database") -def step_assert_dependency_persisted(context: Context) -> None: - """Assert that the dependency was persisted.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - assert context._ddp_root_id in edges, "Root should be in edges" - assert context._ddp_child_id in edges[context._ddp_root_id], ( - "Child should be in root's targets" - ) - - -@then("the dependency should have relationship_type {rel_type}") -def step_assert_dependency_rel_type(context: Context, rel_type: str) -> None: - """Assert that the dependency has the correct relationship type.""" - # Note: The current implementation doesn't return relationship_type, - # but we can verify it was stored by checking the database directly - from cleveragents.infrastructure.database.models import DecisionDependencyModel - - session = context._ddp_factory() - dep = ( - session.query(DecisionDependencyModel) - .filter_by( - source_decision_id=context._ddp_root_id, - target_decision_id=context._ddp_child_id, - ) - .first() - ) - assert dep is not None, "Dependency should exist" - assert dep.relationship_type == rel_type, ( - f"Relationship type should be {rel_type}, got {dep.relationship_type}" - ) - - -@then("the dependency should be persisted with relationship_type {rel_type}") -def step_assert_dependency_persisted_with_type(context: Context, rel_type: str) -> None: - """Assert that the dependency was persisted with the correct type.""" - from cleveragents.infrastructure.database.models import DecisionDependencyModel - - session = context._ddp_factory() - dep = ( - session.query(DecisionDependencyModel) - .filter_by( - source_decision_id=context._ddp_root_id, - target_decision_id=context._ddp_child_id, - ) - .first() - ) - assert dep is not None, "Dependency should exist" - assert dep.relationship_type == rel_type, ( - f"Relationship type should be {rel_type}, got {dep.relationship_type}" - ) - - -@then("both dependencies should be persisted to the database") -def step_assert_both_dependencies_persisted(context: Context) -> None: - """Assert that both dependencies were persisted.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - assert context._ddp_root_id in edges, "Root should be in edges" - assert len(edges[context._ddp_root_id]) == 2, "Root should have 2 targets" - for sibling_id in context._ddp_sibling_ids: - assert sibling_id in edges[context._ddp_root_id], ( - f"Sibling {sibling_id} should be in root's targets" - ) - - -@then("the influence edges should be empty") -def step_assert_influence_edges_empty(context: Context) -> None: - """Assert that influence edges are empty.""" - assert context._ddp_influence_edges == {}, "Influence edges should be empty" - - -@then("the influence edges should contain {count:d} source") -def step_assert_influence_edges_source_count(context: Context, count: int) -> None: - """Assert the number of sources in influence edges.""" - assert len(context._ddp_influence_edges) == count, ( - f"Should have {count} source(s), got {len(context._ddp_influence_edges)}" - ) - - -@then("the influence edges should map root to {targets}") -def step_assert_influence_edges_mapping(context: Context, targets: str) -> None: - """Assert that influence edges map root to the specified targets.""" - # Parse targets string (e.g., "[child]" or "[sibling1, sibling2]") - if targets == "[child]": - expected = [context._ddp_child_id] - elif targets == "[sibling1, sibling2]": - expected = context._ddp_sibling_ids - else: - expected = [] - - assert context._ddp_root_id in context._ddp_influence_edges, ( - "Root should be in edges" - ) - actual = context._ddp_influence_edges[context._ddp_root_id] - assert set(actual) == set(expected), f"Expected {expected}, got {actual}" - - -@then("the influence edges should only include decisions from plan 1") -def step_assert_influence_edges_plan1_only(context: Context) -> None: - """Assert that influence edges only include plan 1 decisions.""" - edges = context._ddp_influence_edges_plan1 - plan1_ids = { - context._ddp_plan1_root_id, - context._ddp_plan1_child_id, - } - for source, targets in edges.items(): - assert source in plan1_ids, f"Source {source} should be in plan 1" - for target in targets: - assert target in plan1_ids, f"Target {target} should be in plan 1" - - -@then("the cross-plan dependency should not appear in influence edges") -def step_assert_no_cross_plan_dependency(context: Context) -> None: - """Assert that cross-plan dependencies don't appear in edges.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - # The cross-plan dependency should not appear because the target is in a different plan - if context._ddp_cross_plan_id1 in edges: - assert context._ddp_cross_plan_id2 not in edges[context._ddp_cross_plan_id1], ( - "Cross-plan dependency should not appear" - ) - - -@then("the new service should retrieve the same dependency edges") -def step_assert_new_service_same_edges(context: Context) -> None: - """Assert that a new service retrieves the same edges.""" - new_edges = context._ddp_new_service.get_influence_edges(_PLAN_ID_1) - assert context._ddp_root_id in new_edges, "Root should be in new edges" - assert context._ddp_child_id in new_edges[context._ddp_root_id], ( - "Child should be in new root's targets" - ) - - -@then("the new service should retrieve all dependency edges") -def step_assert_new_service_all_edges(context: Context) -> None: - """Assert that a new service retrieves all edges.""" - new_edges = context._ddp_new_service.get_influence_edges(_PLAN_ID_1) - assert len(new_edges) == 2, f"Should have 2 sources, got {len(new_edges)}" - assert context._ddp_tree_root_id in new_edges, "Root should be in edges" - assert context._ddp_tree_child_id in new_edges, "Child should be in edges" - - -@then("a database constraint error should be raised") -def step_assert_constraint_error(context: Context) -> None: - """Assert that a constraint error was raised.""" - assert context._ddp_self_loop_error is not None, ( - "Should have raised a constraint error" - ) - - -@then("the operation should complete without error") -def step_assert_no_error(context: Context) -> None: - """Assert that the operation completed without error.""" - assert context._ddp_duplicate_error is None, "Should not have raised an error" - - -@then("no dependencies should be created") -def step_assert_no_dependencies_created(context: Context) -> None: - """Assert that no dependencies were created.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - assert edges == {}, "No dependencies should be created" - - -@then("get_influence_edges should return the dependency") -def step_assert_get_influence_edges_returns_dep(context: Context) -> None: - """Assert that get_influence_edges returns the dependency.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - assert context._ddp_root_id in edges, "Root should be in edges" - assert context._ddp_new_decision_id in edges[context._ddp_root_id], ( - "New decision should be in root's targets" - ) - - -@then("get_influence_edges should return both dependencies") -def step_assert_get_influence_edges_returns_both(context: Context) -> None: - """Assert that get_influence_edges returns both dependencies.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - for root_id in context._ddp_root_ids: - assert root_id in edges, f"Root {root_id} should be in edges" - assert context._ddp_new_decision_id in edges[root_id], ( - f"New decision should be in {root_id}'s targets" - ) - - -@then("get_influence_edges should be empty") -def step_assert_get_influence_edges_empty(context: Context) -> None: - """Assert that get_influence_edges returns empty dict.""" - edges = context._ddp_repo.get_influence_edges(_PLAN_ID_1) - assert edges == {}, "Influence edges should be empty" diff --git a/features/steps/helpers/__init__.py b/features/steps/helpers/__init__.py new file mode 100644 index 000000000..103aa2187 --- /dev/null +++ b/features/steps/helpers/__init__.py @@ -0,0 +1 @@ +"""Shared Behave step helpers.""" diff --git a/features/steps/helpers/decision_dependency_helpers.py b/features/steps/helpers/decision_dependency_helpers.py new file mode 100644 index 000000000..f5ec2336d --- /dev/null +++ b/features/steps/helpers/decision_dependency_helpers.py @@ -0,0 +1,224 @@ +"""Shared helpers for decision dependency persistence Behave steps.""" + +from __future__ import annotations + +import tempfile +from collections import deque +from datetime import UTC, datetime +from pathlib import Path + +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.decision import Decision, DecisionType +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, + DecisionRepository, + LifecyclePlanRepository, +) +from cleveragents.infrastructure.database.unit_of_work import UnitOfWork + +PLAN_ID_1 = "01HV000000000000000000DP01" +PLAN_ID_2 = "01HV000000000000000000DP02" + + +def setup_db(context: Context) -> None: + """Create a per-scenario SQLite database and attach repositories.""" + tmp_dir = Path(tempfile.mkdtemp(prefix="ddp-", dir="/tmp")) + db_path = tmp_dir / "decision-dependencies.sqlite" + engine = create_engine(f"sqlite:///{db_path}", echo=False) + Base.metadata.create_all(engine) + session_factory = sessionmaker( + bind=engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + session = session_factory() + + context._ddp_tmp_dir = tmp_dir + context._ddp_db_url = f"sqlite:///{db_path}" + context._ddp_engine = engine + context._ddp_session = session + context._ddp_uow_session_factory = session_factory + context._ddp_factory = lambda: session + context._ddp_repo = DecisionRepository(session_factory=context._ddp_factory) + context._ddp_action_repo = ActionRepository(session_factory=context._ddp_factory) + context._ddp_plan_repo = LifecyclePlanRepository( + session_factory=context._ddp_factory, + ) + context._ddp_uow = UnitOfWork( + context._ddp_db_url, + require_confirmation=False, + ) + context._ddp_uow._engine = engine + context._ddp_uow._session_factory = session_factory + + +def make_decision( + plan_id: str = PLAN_ID_1, + parent_decision_id: str | None = None, + sequence_number: int = 0, + decision_type: DecisionType = DecisionType.PROMPT_DEFINITION, + question: str = "What approach should we take?", + chosen_option: str = "Build a REST API", +) -> Decision: + """Create a Decision domain model.""" + return Decision( + plan_id=plan_id, + sequence_number=sequence_number, + decision_type=decision_type, + question=question, + chosen_option=chosen_option, + parent_decision_id=parent_decision_id, + ) + + +def create_prerequisite_action(context: Context, action_name: str) -> None: + """Create a prerequisite action for testing.""" + ns = NamespacedName.parse(action_name) + action = Action( + namespaced_name=ns, + description="Prerequisite action for dependency 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._ddp_action_repo.create(action) + context._ddp_session.commit() + + +def create_prerequisite_plan(context: Context, plan_id: str) -> None: + """Create a prerequisite plan for testing.""" + now = datetime(2026, 3, 1, tzinfo=UTC) + plan = Plan( + identity=PlanIdentity(plan_id=plan_id, attempt=1), + namespaced_name=NamespacedName(namespace="local", name="decision-plan"), + action_name="local/decision-action", + description="Decision test plan", + definition_of_done="All assertions pass", + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.PROCESSING, + strategy_actor="local/s", + execution_actor="local/e", + timestamps=PlanTimestamps(created_at=now, updated_at=now), + created_by="test-dependency", + tags=[], + reusable=True, + read_only=False, + ) + context._ddp_plan_repo.create(plan) + context._ddp_session.commit() + + +def persist_decision(context: Context, decision: Decision) -> Decision: + """Persist one decision and commit it.""" + context._ddp_repo.create(decision) + context._ddp_session.commit() + return decision + + +def create_root(context: Context, plan_id: str = PLAN_ID_1, index: int = 0) -> Decision: + """Create a persisted root decision.""" + return persist_decision( + context, + make_decision(plan_id=plan_id, sequence_number=index), + ) + + +def create_child( + context: Context, + root_id: str, + plan_id: str = PLAN_ID_1, + sequence_number: int = 1, +) -> Decision: + """Create a persisted child decision.""" + return persist_decision( + context, + make_decision( + plan_id=plan_id, + parent_decision_id=root_id, + sequence_number=sequence_number, + decision_type=DecisionType.STRATEGY_CHOICE, + ), + ) + + +def record_dependency( + context: Context, + source_id: str, + target_id: str, + relationship_type: str = "influences", +) -> None: + """Persist one dependency edge and commit it.""" + context._ddp_repo.record_dependency( + source_id, + target_id, + relationship_type=relationship_type, + ) + context._ddp_session.commit() + + +def get_edges(context: Context, plan_id: str = PLAN_ID_1) -> dict[str, list[str]]: + """Read influence edges after expiring the assertion session cache.""" + context._ddp_session.expire_all() + return context._ddp_repo.get_influence_edges(plan_id) + + +def create_three_level_tree(context: Context) -> None: + """Create a root -> child -> grandchild tree.""" + root = create_root(context) + child = create_child(context, root.decision_id, sequence_number=1) + grandchild = persist_decision( + context, + make_decision( + plan_id=PLAN_ID_1, + parent_decision_id=child.decision_id, + sequence_number=2, + decision_type=DecisionType.IMPLEMENTATION_CHOICE, + ), + ) + context._ddp_tree_root_id = root.decision_id + context._ddp_tree_child_id = child.decision_id + context._ddp_tree_grandchild_id = grandchild.decision_id + + +def record_three_level_edges(context: Context) -> None: + """Record root -> child and child -> grandchild dependencies.""" + record_dependency( + context, + context._ddp_tree_root_id, + context._ddp_tree_child_id, + ) + record_dependency( + context, + context._ddp_tree_child_id, + context._ddp_tree_grandchild_id, + ) + + +def affected_subtree(start_id: str, edges: dict[str, list[str]]) -> set[str]: + """Return all transitive dependents reachable from start_id.""" + affected: set[str] = set() + queue: deque[str] = deque(edges.get(start_id, [])) + while queue: + decision_id = queue.popleft() + if decision_id in affected: + continue + affected.add(decision_id) + queue.extend(edges.get(decision_id, [])) + return affected diff --git a/src/cleveragents/infrastructure/database/repositories.py b/src/cleveragents/infrastructure/database/repositories.py index 6064b06b0..90e8de06f 100644 --- a/src/cleveragents/infrastructure/database/repositories.py +++ b/src/cleveragents/infrastructure/database/repositories.py @@ -111,6 +111,7 @@ from cleveragents.infrastructure.database.models import ( ContextModel, CorrectionAttemptModel, DebugAttemptModel, + DecisionDependencyModel, DecisionModel, LifecycleActionModel, LifecyclePlanModel, @@ -5754,10 +5755,8 @@ class DecisionRepository(DecisionRepositoryProtocol): Raises: DatabaseError: On constraint violation or transient DB errors. """ - from cleveragents.infrastructure.database.models import ( - DecisionDependencyModel, - ) - + if source_decision_id == target_decision_id: + raise DatabaseError("Decision dependency self-loops are not allowed") session = self._session() try: now_iso = datetime.now(UTC).isoformat() @@ -5795,11 +5794,6 @@ class DecisionRepository(DecisionRepositoryProtocol): Raises: DatabaseError: On transient or unexpected DB errors. """ - from cleveragents.infrastructure.database.models import ( - DecisionDependencyModel, - DecisionModel, - ) - session = self._session() try: # Get all decision IDs for the plan