Files
temp/features/steps/repositories_coverage_r2_steps.py
CoreRasurae a4a6b061a6 fix(db): align v3_plans schema with specification DDL
Aligned v3_plans table with specification DDL:

1. Added effective_profile_snapshot column (TEXT NOT NULL) for
   storing frozen JSON snapshot of automation profile at plan
   creation time.  Added Pydantic field_validator ensuring the
   value is well-formed JSON.  Validator catches RecursionError
   for deeply nested JSON, consistent with automation_profile
   deserialization hardening.  Validator error message uses
   length-only to avoid potential information disclosure.
   Documented that the default "{}" exists for backward
   compatibility; new plans should explicitly set the snapshot.

2. Made root_plan_id NOT NULL — root plans self-reference their
   own plan_id, child plans reference the root ancestor.  Added
   explicit ondelete="RESTRICT" FK policy for consistency with
   other FKs in the model.  Documented known FK policy drift
   between ORM model (RESTRICT) and migrated databases (retained
   SET NULL) in the migration; data integrity is preserved by
   the NOT NULL constraint regardless.  Moved root_plan_id
   self-reference resolution into a PlanIdentity model_validator
   so the domain model is consistent with the DB NOT NULL
   constraint before and after persistence (previously the
   resolution only happened in from_domain(), creating an
   asymmetry where root_plan_id was None in-memory but non-null
   after round-tripping through the database).

3. Made automation_profile NOT NULL with default "balanced".

4. Documented intentional deviation: phase default is "action"
   (code) vs "strategize" (spec) because the Action phase was
   added as a pre-Strategize setup step.

5. Created Alembic migration with backfill logic for existing
   rows.  Root-ancestor backfill uses level-by-level propagation
   with a parent-readiness guard to correctly resolve plans at
   arbitrary hierarchy depth (3+ levels).  Added safety bound
   (max 100 iterations) with logged error on exhaustion to guard
   against cycles in parent_plan_id.  Merged batch_alter_table
   operations to avoid redundant full-table copies in SQLite
   batch mode.  Migration backfill also handles empty-string
   automation_profile values.  Documented downgrade limitation
   (backfill is not reversible).  Orphan-row fallback now logs
   affected row count at WARNING level.  Migration cycle-detection
   now logs affected plan_id values before the orphan fallback
   overwrites them.  All migration SQL uses sa.text() for
   consistency with SQLAlchemy best practices.

6. Hardened automation_profile deserialization in to_domain() to
   catch ValueError (invalid StrEnum provenance), Pydantic
   ValidationError, and RecursionError (deeply nested JSON) in
   addition to JSONDecodeError and KeyError, preventing
   unreadable plans from corrupted DB rows.  Applied the same
   defensive deserialization pattern to effective_profile_snapshot
   in to_domain(): corrupted JSON falls back to '{}' with a
   WARNING log instead of crashing the read path.  Added TypeError
   to the effective_profile_snapshot exception list in to_domain()
   for consistency with the Pydantic validator.  Logging of
   unparseable values uses length only to avoid potential
   information disclosure.

7. Used explicit None check (is not None) instead of truthiness
   for root_plan_id resolution in from_domain(), for
   effective_profile_snapshot in to_domain(), and in
   _serialize_automation_profile() for consistency.

8. Documented intentional column naming conventions vs spec DDL
   (e.g. automation_profile vs automation_profile_name, *_actor
   vs *_actor_name, processing_state vs state, v3_plans vs
   plans).  Documented the semantic difference: automation_profile
   stores either a bare name or structured JSON with provenance,
   whereas the spec automation_profile_name stores a plain name.

9. Fixed benchmark plan constructors
   (plan_phase_migration_bench.py) that were missing the now-
   required root_plan_id and effective_profile_snapshot fields.

10. Replaced defensive getattr() with direct attribute access for
    effective_profile_snapshot in from_domain() and update(),
    since the field is now defined on the Plan domain model.

11. Fixed Any type annotation in test helper _make_plan() to use
    AutomationProfileRef | None for proper type safety.

12. Added BDD scenarios for PlanIdentity self-reference
    resolution, NULL effective_profile_snapshot constraint
    enforcement, valid-JSON-missing-profile_name-key
    deserialization, invalid-JSON and empty-string snapshot
    rejection by Pydantic validator, and corrupted
    effective_profile_snapshot DB fallback in to_domain().

13. Extracted default automation profile name to a module-level
    constant (DEFAULT_AUTOMATION_PROFILE) to reduce sentinel
    duplication across models.py and repositories.py.

14. Centralised automation-profile serialisation into
    LifecyclePlanModel._serialize_automation_profile() to
    eliminate duplication between from_domain() and
    LifecyclePlanRepository.update().

15. Fixed to_domain() root_plan_id type cast from str | None
    to str, reflecting the NOT NULL column constraint.

16. Added PlanIdentity model_validator that resolves None
    root_plan_id to plan_id at domain construction time, ensuring
    the domain model honours the spec DDL NOT NULL constraint
    regardless of persistence state.  Simplified from_domain()
    root resolution accordingly.

ISSUES CLOSED: #921
2026-03-30 23:40:36 +01:00

1194 lines
41 KiB
Python

"""Step definitions for repositories_coverage_r2.feature.
Targets uncovered lines in repositories.py for:
- SkillRepository flattened-tool cache methods (lines 4656-4821)
- DecisionRepository full CRUD (lines 4841-5255)
- CheckpointRepository full CRUD (lines 5279-5453)
- ToolRepository legacy wrapper error paths (lines 3568-3595)
"""
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.checkpoint import Checkpoint, CheckpointMetadata
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
)
from cleveragents.infrastructure.database.models import (
Base,
DecisionModel,
LifecycleActionModel,
LifecyclePlanModel,
SkillModel,
)
from cleveragents.infrastructure.database.repositories import (
CheckpointNotFoundError,
CheckpointRepository,
DecisionNotFoundError,
DecisionRepository,
DuplicateDecisionError,
LifecyclePlanRepository,
SkillNotFoundError,
SkillRepository,
ToolNotFoundError,
ToolRepository,
)
# -- Helpers ----------------------------------------------------------------
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_COUNTER = 0
def _next_ulid() -> str:
"""Return a unique, valid ULID string for each call."""
global _COUNTER
_COUNTER += 1
n = _COUNTER
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0AQDYTR4BX{suffix}"
def _make_decision(
plan_id: str,
decision_id: str | None = None,
parent_decision_id: str | None = None,
sequence_number: int = 0,
decision_type: DecisionType = DecisionType.STRATEGY_CHOICE,
superseded_by: str | None = None,
) -> Decision:
"""Create a minimal valid Decision domain object."""
return Decision(
decision_id=decision_id or _next_ulid(),
plan_id=plan_id,
parent_decision_id=parent_decision_id,
sequence_number=sequence_number,
decision_type=decision_type,
question="What approach?",
chosen_option="Option A",
context_snapshot=ContextSnapshot(),
superseded_by=superseded_by,
)
def _insert_decision_model(
session: Session,
plan_id: str,
decision_id: str,
sequence_number: int = 0,
decision_type: str = "strategy_choice",
parent_decision_id: str | None = None,
superseded_by: str | None = None,
) -> None:
"""Insert a decision directly via the ORM model (bypasses retry decorator)."""
now_iso = datetime.now(UTC).isoformat()
session.add(
DecisionModel(
decision_id=decision_id,
plan_id=plan_id,
parent_decision_id=parent_decision_id,
sequence_number=sequence_number,
decision_type=decision_type,
question="What approach?",
chosen_option="Option A",
context_snapshot_json=json.dumps(
{
"hot_context_hash": "",
"hot_context_ref": "",
"relevant_resources": [],
"actor_state_ref": "",
}
),
rationale="",
created_at=now_iso,
is_correction=False,
superseded_by=superseded_by,
)
)
session.flush()
def _make_checkpoint(
plan_id: str,
checkpoint_id: str | None = None,
created_at: datetime | None = None,
) -> Checkpoint:
"""Create a minimal valid Checkpoint domain object."""
return Checkpoint(
checkpoint_id=checkpoint_id or _next_ulid(),
plan_id=plan_id,
sandbox_ref="abc123commit",
checkpoint_type="manual",
created_at=created_at or datetime.now(UTC),
metadata=CheckpointMetadata(reason="test"),
)
_ACTION_INSERTED = False
def _ensure_action(session: Session) -> None:
"""Insert the shared test action if it doesn't exist yet."""
global _ACTION_INSERTED
if _ACTION_INSERTED:
return
existing = (
session.query(LifecycleActionModel)
.filter_by(namespaced_name="local/test-action")
.first()
)
if existing is not None:
_ACTION_INSERTED = True
return
now_iso = datetime.now(UTC).isoformat()
action_row = LifecycleActionModel(
namespaced_name="local/test-action",
namespace="local",
name="test-action",
description="Test action for r2cov",
definition_of_done="All tests pass",
strategy_actor="default",
execution_actor="default",
state="available",
reusable=True,
read_only=False,
created_by="test",
tags_json="[]",
created_at=now_iso,
updated_at=now_iso,
)
session.add(action_row)
session.flush()
_ACTION_INSERTED = True
def _insert_v3_plan(session: Session, plan_id: str) -> None:
"""Insert a minimal v3_plans row so FKs are satisfied."""
_ensure_action(session)
now_iso = datetime.now(UTC).isoformat()
plan_row = LifecyclePlanModel(
plan_id=plan_id,
root_plan_id=plan_id,
namespaced_name=f"local/test-plan-{plan_id[:8]}",
namespace="local",
action_name="local/test-action",
description="Test plan",
definition_of_done="Done",
phase="action",
processing_state="queued",
attempt=1,
reusable=False,
read_only=False,
created_by="test",
tags_json="[]",
effective_profile_snapshot="{}",
created_at=now_iso,
updated_at=now_iso,
)
session.add(plan_row)
session.flush()
def _insert_skill_row(session: Session, name: str) -> None:
"""Insert a minimal skill row."""
now_iso = datetime.now(UTC).isoformat()
parts = name.split("/", 1)
namespace = parts[0]
short_name = parts[1] if len(parts) > 1 else parts[0]
row = SkillModel(
name=name,
namespace=namespace,
short_name=short_name,
description="Test skill",
created_at=now_iso,
updated_at=now_iso,
)
session.add(row)
session.flush()
# ===========================================================================
# Background
# ===========================================================================
@given("r2cov a fresh in-memory database with all tables")
def step_fresh_db(context: Context) -> None:
global _ACTION_INSERTED
_ACTION_INSERTED = False
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
context.r2_engine = engine
# Use scoped_session so that every ``factory()`` call within the
# same thread returns the *same* Session instance. With plain
# ``sessionmaker``, each ``factory()`` call creates a new Session.
# SQLite in-memory uses ``SingletonThreadPool`` (one connection per
# thread), so all sessions share the same connection. When a
# session created inside a repository method goes out of scope,
# Python's garbage collector may close it, issuing an implicit
# ROLLBACK on the shared connection — wiping flushed-but-uncommitted
# rows written by *other* sessions. Under high memory pressure
# (e.g. 32 parallel worker processes) GC fires often enough to
# cause intermittent data loss between ``flush()`` and ``commit()``.
# ``scoped_session`` avoids the problem entirely: one Session lives
# for the whole scenario, so there is no premature close/rollback.
context.r2_session_factory = scoped_session(sessionmaker(bind=engine))
# Pre-create repos used by multiple scenarios
context.r2_skill_repo = SkillRepository(
session_factory=context.r2_session_factory,
)
context.r2_decision_repo = DecisionRepository(
session_factory=context.r2_session_factory,
)
context.r2_checkpoint_repo = CheckpointRepository(
session_factory=context.r2_session_factory,
)
context.r2_plan_repo = LifecyclePlanRepository(
session_factory=context.r2_session_factory,
)
context.r2_error = None
context.r2_result = None
# ===========================================================================
# SkillRepository - setup steps
# ===========================================================================
@given('r2cov a skill "{name}" exists in the database')
def step_skill_exists(context: Context, name: str) -> None:
session = context.r2_session_factory()
_insert_skill_row(session, name)
session.commit()
@given('r2cov the skill "{name}" has flattened tools cached')
def step_skill_has_cache(context: Context, name: str) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name=name).first()
assert row is not None, f"Skill {name} not found"
row.flattened_tools_json = json.dumps([{"tool": "echo"}])
row.includes_json = json.dumps(["local/base"])
row.capability_summary_json = json.dumps({"read": True})
row.yaml_text = "name: cached"
row.flattening_hash = hashlib.sha256(b"name: cached").hexdigest()
session.commit()
@given('r2cov the skill "{name}" has flattening_hash "{hash_val}"')
def step_skill_has_hash(context: Context, name: str, hash_val: str) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name=name).first()
assert row is not None, f"Skill {name} not found"
row.flattening_hash = hash_val
session.commit()
# ===========================================================================
# SkillRepository - update_flattened_tools
# ===========================================================================
@when('r2cov update_flattened_tools is called for "{name}" with valid cache data')
def step_update_flattened_tools(context: Context, name: str) -> None:
context.r2_flat_hash = hashlib.sha256(b"yaml: test").hexdigest()
try:
context.r2_skill_repo.update_flattened_tools(
name=name,
flattened_tools_json=json.dumps([{"tool": "test"}]),
includes_json=json.dumps(["local/inc"]),
capability_summary_json=json.dumps({"write": True}),
yaml_text="yaml: test",
flattening_hash=context.r2_flat_hash,
)
except Exception as exc:
context.r2_error = exc
@then("r2cov the skill row should have non-null flattened_tools_json")
def step_check_flat_tools_not_null(context: Context) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name="local/my-skill").first()
assert row is not None
assert row.flattened_tools_json is not None
@then("r2cov the skill row should have the expected flattening_hash")
def step_check_flat_hash(context: Context) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name="local/my-skill").first()
assert row is not None
assert row.flattening_hash == context.r2_flat_hash
@then("r2cov a SkillNotFoundError should be raised")
def step_skill_not_found_error(context: Context) -> None:
assert context.r2_error is not None, "Expected SkillNotFoundError but no error"
assert isinstance(context.r2_error, SkillNotFoundError), (
f"Expected SkillNotFoundError, got {type(context.r2_error).__name__}: "
f"{context.r2_error}"
)
# ===========================================================================
# SkillRepository - get_flattened_tools
# ===========================================================================
@when('r2cov get_flattened_tools is called for "{name}"')
def step_get_flattened_tools(context: Context, name: str) -> None:
try:
context.r2_result = context.r2_skill_repo.get_flattened_tools(name)
except Exception as exc:
context.r2_error = exc
@then("r2cov the result should be a dict with flattened_tools_json key")
def step_result_is_dict_with_key(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert isinstance(context.r2_result, dict)
assert "flattened_tools_json" in context.r2_result
@then("r2cov the result flattening_hash should not be None")
def step_result_hash_not_none(context: Context) -> None:
assert context.r2_result["flattening_hash"] is not None
# ===========================================================================
# SkillRepository - needs_refresh
# ===========================================================================
@when('r2cov needs_refresh is called for "{name}" with hash "{hash_val}"')
def step_needs_refresh(context: Context, name: str, hash_val: str) -> None:
try:
context.r2_result = context.r2_skill_repo.needs_refresh(name, hash_val)
except Exception as exc:
context.r2_error = exc
@then("r2cov the result should be True")
def step_result_true(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is True, f"Expected True, got {context.r2_result}"
@then("r2cov the result should be False")
def step_result_false(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is False, f"Expected False, got {context.r2_result}"
# ===========================================================================
# SkillRepository - recompute_flattening_hash
# ===========================================================================
@when('r2cov recompute_flattening_hash is called for "{name}" with yaml "{yaml_text}"')
def step_recompute_hash(context: Context, name: str, yaml_text: str) -> None:
try:
context.r2_result = context.r2_skill_repo.recompute_flattening_hash(
name, yaml_text
)
except Exception as exc:
context.r2_error = exc
@then('r2cov the returned hash should be the SHA-256 of "{yaml_text}"')
def step_check_sha256(context: Context, yaml_text: str) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
expected = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
assert context.r2_result == expected, (
f"Expected {expected}, got {context.r2_result}"
)
@then('r2cov the skill row "{name}" should have the recomputed hash stored')
def step_check_stored_hash(context: Context, name: str) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name=name).first()
assert row is not None
assert row.flattening_hash == context.r2_result
# ===========================================================================
# SkillRepository - invalidate_cached_summaries
# ===========================================================================
@when('r2cov invalidate_cached_summaries is called for "{name}"')
def step_invalidate_cache(context: Context, name: str) -> None:
try:
context.r2_skill_repo.invalidate_cached_summaries(name)
except Exception as exc:
context.r2_error = exc
@then('r2cov the skill row "{name}" should have null flattened_tools_json')
def step_check_null_flat_tools(context: Context, name: str) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name=name).first()
assert row is not None
assert row.flattened_tools_json is None
@then('r2cov the skill row "{name}" should have null flattening_hash')
def step_check_null_hash(context: Context, name: str) -> None:
session = context.r2_session_factory()
row = session.query(SkillModel).filter_by(name=name).first()
assert row is not None
assert row.flattening_hash is None
# ===========================================================================
# DecisionRepository - setup steps
# ===========================================================================
@given("r2cov a lifecycle plan exists for decisions")
def step_plan_for_decisions(context: Context) -> None:
context.r2_plan_id = _next_ulid()
session = context.r2_session_factory()
_insert_v3_plan(session, context.r2_plan_id)
session.commit()
@given('r2cov a decision "{decision_id}" exists for that plan')
def step_decision_exists(context: Context, decision_id: str) -> None:
"""Insert a decision directly via the model so it's committed and visible
to subsequent repo calls (avoids retry-decorator complications)."""
now_iso = datetime.now(UTC).isoformat()
session = context.r2_session_factory()
session.add(
DecisionModel(
decision_id=decision_id,
plan_id=context.r2_plan_id,
sequence_number=0,
decision_type="prompt_definition",
question="What approach?",
chosen_option="Option A",
context_snapshot_json=json.dumps(
{
"hot_context_hash": "",
"hot_context_ref": "",
"relevant_resources": [],
"actor_state_ref": "",
}
),
rationale="",
created_at=now_iso,
is_correction=False,
)
)
session.commit()
context.r2_decision_id = decision_id
@given("r2cov decisions with sequence 0, 1, 2 exist for that plan")
def step_three_decisions(context: Context) -> None:
context.r2_decision_ids = []
session = context.r2_session_factory()
for seq in range(3):
did = _next_ulid()
dtype = "prompt_definition" if seq == 0 else "strategy_choice"
parent = None if seq == 0 else context.r2_decision_ids[0]
_insert_decision_model(
session,
plan_id=context.r2_plan_id,
decision_id=did,
sequence_number=seq,
decision_type=dtype,
parent_decision_id=parent,
)
context.r2_decision_ids.append(did)
session.commit()
@given("r2cov a decision tree with root and two children exists")
def step_decision_tree(context: Context) -> None:
root_id = _next_ulid()
child1_id = _next_ulid()
child2_id = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
_insert_decision_model(
session,
context.r2_plan_id,
child1_id,
1,
"strategy_choice",
parent_decision_id=root_id,
)
_insert_decision_model(
session,
context.r2_plan_id,
child2_id,
2,
"strategy_choice",
parent_decision_id=root_id,
)
session.commit()
context.r2_root_decision_id = root_id
context.r2_tree_ids = [root_id, child1_id, child2_id]
@given("r2cov a decision chain root -> mid -> leaf exists")
def step_decision_chain(context: Context) -> None:
root_id = _next_ulid()
mid_id = _next_ulid()
leaf_id = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
_insert_decision_model(
session,
context.r2_plan_id,
mid_id,
1,
"strategy_choice",
parent_decision_id=root_id,
)
_insert_decision_model(
session,
context.r2_plan_id,
leaf_id,
2,
"strategy_choice",
parent_decision_id=mid_id,
)
session.commit()
context.r2_chain_root_id = root_id
context.r2_chain_leaf_id = leaf_id
@given("r2cov a decision that has been superseded exists")
def step_superseded_decision(context: Context) -> None:
original_id = _next_ulid()
replacement_id = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(
session,
context.r2_plan_id,
original_id,
0,
"prompt_definition",
superseded_by=replacement_id,
)
_insert_decision_model(
session, context.r2_plan_id, replacement_id, 1, "strategy_choice"
)
session.commit()
@given("r2cov an original decision and a replacement decision exist")
def step_original_and_replacement(context: Context) -> None:
context.r2_original_id = _next_ulid()
context.r2_replacement_id = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(
session, context.r2_plan_id, context.r2_original_id, 0, "prompt_definition"
)
_insert_decision_model(
session, context.r2_plan_id, context.r2_replacement_id, 1, "strategy_choice"
)
session.commit()
@given('r2cov decisions of type "strategy_choice" and "implementation_choice" exist')
def step_decisions_two_types(context: Context) -> None:
context.r2_strategy_id = _next_ulid()
context.r2_impl_id = _next_ulid()
root_id = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(session, context.r2_plan_id, root_id, 0, "prompt_definition")
_insert_decision_model(
session,
context.r2_plan_id,
context.r2_strategy_id,
1,
"strategy_choice",
parent_decision_id=root_id,
)
_insert_decision_model(
session,
context.r2_plan_id,
context.r2_impl_id,
2,
"implementation_choice",
parent_decision_id=root_id,
)
session.commit()
@given("r2cov a single decision exists for deletion")
def step_decision_for_deletion(context: Context) -> None:
did = _next_ulid()
session = context.r2_session_factory()
_insert_decision_model(session, context.r2_plan_id, did, 0, "prompt_definition")
session.commit()
context.r2_delete_decision_id = did
# ===========================================================================
# DecisionRepository - action steps
# ===========================================================================
@when("r2cov a decision is created for that plan")
def step_create_decision(context: Context) -> None:
context.r2_created_decision_id = _next_ulid()
decision = _make_decision(
plan_id=context.r2_plan_id,
decision_id=context.r2_created_decision_id,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
)
try:
context.r2_decision_repo.create(decision)
session = context.r2_session_factory()
session.commit()
except Exception as exc:
context.r2_error = exc
@when("r2cov creating a decision with the same ID is attempted")
def step_create_duplicate_decision(context: Context) -> None:
"""Attempt to create a decision with an ID that already exists.
Uses PROMPT_DEFINITION (root) type to avoid parent_decision_id constraints."""
decision = _make_decision(
plan_id=context.r2_plan_id,
decision_id=context.r2_decision_id,
sequence_number=99,
decision_type=DecisionType.PROMPT_DEFINITION,
)
try:
context.r2_decision_repo.create(decision)
except Exception as exc:
context.r2_error = exc
@when('r2cov getting decision "{decision_id}" is attempted')
def step_get_decision(context: Context, decision_id: str) -> None:
try:
context.r2_result = context.r2_decision_repo.get(decision_id)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_by_plan is called for that plan")
def step_get_by_plan(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_by_plan(context.r2_plan_id)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_tree is called with the root decision ID")
def step_get_tree(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_tree(
context.r2_root_decision_id
)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_tree is called with a nonexistent root ID")
def step_get_tree_missing(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_tree(
"01HGZ6FE0AQDYTR4BXNOSUCHID"
)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_path_to_root is called with the leaf decision ID")
def step_get_path_to_root(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_path_to_root(
context.r2_chain_leaf_id
)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_path_to_root is called with a nonexistent ID")
def step_get_path_to_root_missing(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_path_to_root(
"01HGZ6FE0AQDYTR4BXNOSUCHID"
)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_superseded is called for that plan")
def step_get_superseded(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_superseded(context.r2_plan_id)
except Exception as exc:
context.r2_error = exc
@when("r2cov update_superseded_by is called on the original with the replacement ID")
def step_update_superseded(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.update_superseded_by(
context.r2_original_id,
context.r2_replacement_id,
)
session = context.r2_session_factory()
session.commit()
except Exception as exc:
context.r2_error = exc
@when("r2cov update_superseded_by is called with a nonexistent decision ID")
def step_update_superseded_missing(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.update_superseded_by(
"01HGZ6FE0AQDYTR4BXNOSUCHID",
"01HGZ6FE0AQDYTR4BXNOSUCHTG",
)
except Exception as exc:
context.r2_error = exc
@when('r2cov list_by_type is called with "strategy_choice"')
def step_list_by_type(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.list_by_type(
context.r2_plan_id, "strategy_choice"
)
except Exception as exc:
context.r2_error = exc
@when("r2cov delete is called on the decision")
def step_delete_decision(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.delete(
context.r2_delete_decision_id
)
session = context.r2_session_factory()
session.commit()
except Exception as exc:
context.r2_error = exc
@when("r2cov delete is called with a nonexistent decision ID")
def step_delete_decision_missing(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.delete(
"01HGZ6FE0AQDYTR4BXNOSUCHID"
)
except Exception as exc:
context.r2_error = exc
@when("r2cov get_max_sequence_number is called for that plan")
def step_get_max_seq(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.get_max_sequence_number(
context.r2_plan_id
)
except Exception as exc:
context.r2_error = exc
@when("r2cov count is called for that plan")
def step_count_decisions(context: Context) -> None:
try:
context.r2_result = context.r2_decision_repo.count(context.r2_plan_id)
except Exception as exc:
context.r2_error = exc
# ===========================================================================
# DecisionRepository - assertion steps
# ===========================================================================
@then("r2cov the decision should be retrievable by ID")
def step_decision_retrievable(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
result = context.r2_decision_repo.get(context.r2_created_decision_id)
assert result is not None, "Decision not found after create"
assert result.decision_id == context.r2_created_decision_id
@then("r2cov a DuplicateDecisionError should be raised")
def step_duplicate_decision_error(context: Context) -> None:
assert context.r2_error is not None, "Expected DuplicateDecisionError but no error"
assert isinstance(context.r2_error, DuplicateDecisionError), (
f"Expected DuplicateDecisionError, got {type(context.r2_error).__name__}"
)
@then("r2cov the result should be None")
def step_result_none(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is None, f"Expected None, got {context.r2_result}"
@then("r2cov {count:d} decisions should be returned in sequence order")
def step_decisions_in_order(context: Context, count: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) == count, (
f"Expected {count} decisions, got {len(context.r2_result)}"
)
for i in range(len(context.r2_result) - 1):
assert (
context.r2_result[i].sequence_number
<= context.r2_result[i + 1].sequence_number
), "Decisions not in sequence order"
@then("r2cov {count:d} decisions should be returned with root first")
def step_decisions_bfs(context: Context, count: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) == count
assert context.r2_result[0].decision_id == context.r2_root_decision_id
@then("r2cov a DecisionNotFoundError should be raised")
def step_decision_not_found_error(context: Context) -> None:
assert context.r2_error is not None, "Expected DecisionNotFoundError but no error"
assert isinstance(context.r2_error, DecisionNotFoundError), (
f"Expected DecisionNotFoundError, got {type(context.r2_error).__name__}: "
f"{context.r2_error}"
)
@then("r2cov the path should contain 3 decisions starting with leaf ending with root")
def step_path_leaf_to_root(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
path = context.r2_result
assert len(path) == 3, f"Expected 3 decisions in path, got {len(path)}"
assert path[0].decision_id == context.r2_chain_leaf_id
assert path[-1].decision_id == context.r2_chain_root_id
@then("r2cov exactly 1 superseded decision should be returned")
def step_one_superseded(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) == 1, (
f"Expected 1 superseded decision, got {len(context.r2_result)}"
)
assert context.r2_result[0].superseded_by is not None
@then("r2cov the original decision should have superseded_by set")
def step_superseded_by_set(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
result = context.r2_decision_repo.get(context.r2_original_id)
assert result is not None
assert result.superseded_by == context.r2_replacement_id
@then("r2cov only strategy_choice decisions should be returned")
def step_only_strategy(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) >= 1
for d in context.r2_result:
assert (
d.decision_type == DecisionType.STRATEGY_CHOICE
or str(d.decision_type) == "strategy_choice"
)
@then("r2cov delete should return True")
def step_delete_true(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is True
@then("r2cov the decision should no longer be retrievable")
def step_decision_gone(context: Context) -> None:
result = context.r2_decision_repo.get(context.r2_delete_decision_id)
assert result is None, "Decision still exists after delete"
@then("r2cov delete should return False")
def step_delete_false(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is False
@then("r2cov the result should be {expected:d}")
def step_result_int(context: Context, expected: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result == expected, (
f"Expected {expected}, got {context.r2_result}"
)
@then("r2cov the count result should be {expected:d}")
def step_count_result(context: Context, expected: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result == expected, (
f"Expected {expected}, got {context.r2_result}"
)
# ===========================================================================
# CheckpointRepository - setup steps
# ===========================================================================
@given("r2cov a lifecycle plan exists for checkpoints")
def step_plan_for_checkpoints(context: Context) -> None:
context.r2_ckpt_plan_id = _next_ulid()
session = context.r2_session_factory()
_insert_v3_plan(session, context.r2_ckpt_plan_id)
session.commit()
@given("r2cov {count:d} checkpoints exist for that plan")
def step_n_checkpoints(context: Context, count: int) -> None:
context.r2_checkpoint_ids = []
for i in range(count):
cid = _next_ulid()
# Stagger creation times slightly
created = datetime(2024, 1, 1, 0, 0, i, tzinfo=UTC)
checkpoint = _make_checkpoint(
plan_id=context.r2_ckpt_plan_id,
checkpoint_id=cid,
created_at=created,
)
context.r2_checkpoint_repo.create(checkpoint)
context.r2_checkpoint_ids.append(cid)
session = context.r2_session_factory()
session.commit()
@given("r2cov a single checkpoint exists for deletion")
def step_checkpoint_for_deletion(context: Context) -> None:
cid = _next_ulid()
checkpoint = _make_checkpoint(
plan_id=context.r2_ckpt_plan_id,
checkpoint_id=cid,
)
context.r2_checkpoint_repo.create(checkpoint)
session = context.r2_session_factory()
session.commit()
context.r2_delete_checkpoint_id = cid
# ===========================================================================
# CheckpointRepository - action steps
# ===========================================================================
@when("r2cov a checkpoint is created for that plan")
def step_create_checkpoint(context: Context) -> None:
context.r2_created_ckpt_id = _next_ulid()
checkpoint = _make_checkpoint(
plan_id=context.r2_ckpt_plan_id,
checkpoint_id=context.r2_created_ckpt_id,
)
try:
context.r2_result = context.r2_checkpoint_repo.create(checkpoint)
session = context.r2_session_factory()
session.commit()
except Exception as exc:
context.r2_error = exc
@when('r2cov getting checkpoint "{checkpoint_id}" is attempted')
def step_get_checkpoint(context: Context, checkpoint_id: str) -> None:
try:
context.r2_result = context.r2_checkpoint_repo.get_by_id(checkpoint_id)
except Exception as exc:
context.r2_error = exc
@when("r2cov list_by_plan is called for that plan")
def step_list_checkpoints(context: Context) -> None:
try:
context.r2_result = context.r2_checkpoint_repo.list_by_plan(
context.r2_ckpt_plan_id
)
except Exception as exc:
context.r2_error = exc
@when("r2cov delete_checkpoint is called on the checkpoint")
def step_delete_checkpoint(context: Context) -> None:
try:
context.r2_result = context.r2_checkpoint_repo.delete(
context.r2_delete_checkpoint_id
)
session = context.r2_session_factory()
session.commit()
except Exception as exc:
context.r2_error = exc
@when("r2cov delete_checkpoint is called with a nonexistent ID")
def step_delete_checkpoint_missing(context: Context) -> None:
try:
context.r2_result = context.r2_checkpoint_repo.delete(
"01HGZ6FE0AQDYTR4BXCHKNOSUC"
)
except Exception as exc:
context.r2_error = exc
@when("r2cov prune is called with max_checkpoints {max_ckpt:d}")
def step_prune(context: Context, max_ckpt: int) -> None:
try:
context.r2_result = context.r2_checkpoint_repo.prune(
context.r2_ckpt_plan_id, max_ckpt
)
except Exception as exc:
context.r2_error = exc
# ===========================================================================
# CheckpointRepository - assertion steps
# ===========================================================================
@then("r2cov the checkpoint should be retrievable by ID")
def step_checkpoint_retrievable(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
result = context.r2_checkpoint_repo.get_by_id(context.r2_created_ckpt_id)
assert result is not None, "Checkpoint not found after create"
assert result.checkpoint_id == context.r2_created_ckpt_id
@then("r2cov a CheckpointNotFoundError should be raised")
def step_checkpoint_not_found_error(context: Context) -> None:
assert context.r2_error is not None, "Expected CheckpointNotFoundError"
assert isinstance(context.r2_error, CheckpointNotFoundError), (
f"Expected CheckpointNotFoundError, got {type(context.r2_error).__name__}"
)
@then("r2cov {count:d} checkpoints should be returned in creation order")
def step_checkpoints_ordered(context: Context, count: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) == count
for i in range(len(context.r2_result) - 1):
assert context.r2_result[i].created_at <= context.r2_result[i + 1].created_at
@then("r2cov checkpoint delete should return True")
def step_ckpt_delete_true(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is True
@then("r2cov checkpoint delete should return False")
def step_ckpt_delete_false(context: Context) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert context.r2_result is False
@then("r2cov {count:d} checkpoint IDs should be returned as pruned")
def step_pruned_count(context: Context, count: int) -> None:
assert context.r2_error is None, f"Unexpected error: {context.r2_error}"
assert len(context.r2_result) == count, (
f"Expected {count} pruned IDs, got {len(context.r2_result)}"
)
@then("r2cov only {count:d} checkpoints should remain for the plan")
def step_remaining_checkpoints(context: Context, count: int) -> None:
remaining = context.r2_checkpoint_repo.list_by_plan(context.r2_ckpt_plan_id)
assert len(remaining) == count, (
f"Expected {count} remaining checkpoints, got {len(remaining)}"
)
# ===========================================================================
# ToolRepository - error path steps
# ===========================================================================
@given("r2cov a tool repository with a broken session")
def step_tool_repo_broken(context: Context) -> None:
"""Create a ToolRepository whose session raises OperationalError on query."""
def broken_factory() -> Session:
mock_session = MagicMock(spec=Session)
mock_query = MagicMock()
mock_query.filter_by.return_value.first.side_effect = OperationalError(
"connection lost", {}, None
)
mock_session.query.return_value = mock_query
return mock_session
context.r2_tool_repo = ToolRepository(session_factory=broken_factory)
@given("r2cov a tool repository backed by an empty database")
def step_tool_repo_empty(context: Context) -> None:
context.r2_tool_repo = ToolRepository(
session_factory=context.r2_session_factory,
)
@when('r2cov ToolRepository.get_by_name is called with "{name}"')
def step_tool_get_by_name(context: Context, name: str) -> None:
try:
context.r2_result = context.r2_tool_repo.get_by_name(name)
except Exception as exc:
context.r2_error = exc
@when('r2cov ToolRepository.remove is called with "{name}"')
def step_tool_remove(context: Context, name: str) -> None:
try:
context.r2_result = context.r2_tool_repo.remove(name)
except Exception as exc:
context.r2_error = exc
@then('r2cov a DatabaseError should be raised containing "{fragment}"')
def step_database_error_fragment(context: Context, fragment: str) -> None:
assert context.r2_error is not None, "Expected DatabaseError but no error raised"
assert isinstance(context.r2_error, DatabaseError), (
f"Expected DatabaseError, got {type(context.r2_error).__name__}: "
f"{context.r2_error}"
)
assert fragment in str(context.r2_error), (
f"Expected '{fragment}' in error message, got: {context.r2_error}"
)
@then("r2cov a ToolNotFoundError should be raised")
def step_tool_not_found_error(context: Context) -> None:
assert context.r2_error is not None, "Expected ToolNotFoundError but no error"
assert isinstance(context.r2_error, ToolNotFoundError), (
f"Expected ToolNotFoundError, got {type(context.r2_error).__name__}: "
f"{context.r2_error}"
)