fix(decision_service): stabilize dependency persistence tests
CI / lint (pull_request) Successful in 57s
CI / quality (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 1m0s
CI / security (pull_request) Successful in 1m30s
CI / push-validation (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 31s
CI / build (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 5m32s
CI / docker (pull_request) Successful in 1m48s
CI / integration_tests (pull_request) Successful in 8m32s
CI / coverage (pull_request) Failing after 9m55s
CI / status-check (pull_request) Failing after 3s

Split the decision dependency Behave steps below the 500-line limit, add the missing workflow/setup steps, and preserve self-loop constraint failures while keeping duplicate dependency edges idempotent.

ISSUES CLOSED: #7926
This commit is contained in:
2026-06-08 16:41:38 -04:00
parent d2c337af56
commit 6df2db74fa
6 changed files with 762 additions and 679 deletions
@@ -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}"
)
@@ -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"
)
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Shared Behave step helpers."""
@@ -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
@@ -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