"""Step definitions for changeset persistence feature tests.""" from __future__ import annotations import re from collections.abc import Callable from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from cleveragents.application.services.plan_apply_service import ( PlanApplyService, ) from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.config.settings import Settings from cleveragents.core.exceptions import ValidationError from cleveragents.domain.models.core.change import ( ChangeEntry, ChangeOperation, ToolInvocation, ) from cleveragents.infrastructure.database.changeset_repository import ( ChangeSetEntryRepository, SqliteChangeSetStore, ToolInvocationRepository, ) from cleveragents.infrastructure.database.models import Base def _make_in_memory_session_factory() -> tuple[Callable[[], Session], Session, Engine]: """Create an in-memory SQLite engine + shared session factory with schema. Uses a single shared session so that flush() data is visible across all repository calls within the same scenario. Without this, short-lived sessions returned by sessionmaker() may be garbage- collected, causing the StaticPool's reset_on_return to discard uncommitted INSERTs. """ engine = create_engine( "sqlite:///:memory:", echo=False, connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) session: Session = sessionmaker(bind=engine)() return lambda: session, session, engine def _make_entry( plan_id: str, operation: ChangeOperation = ChangeOperation.CREATE, path: str = "src/test.py", resource_id: str = "res-001", ) -> ChangeEntry: """Create a test ChangeEntry.""" before_hash = "aaa111" if operation != ChangeOperation.CREATE else None after_hash = "bbb222" if operation != ChangeOperation.DELETE else None return ChangeEntry( plan_id=plan_id, resource_id=resource_id, tool_name="file-write", operation=operation, path=path, before_hash=before_hash, after_hash=after_hash, ) # ---- SqliteChangeSetStore steps ---- @given("I have a sqlite-backed changeset store") def step_sqlite_store(context: Context) -> None: factory, session, engine = _make_in_memory_session_factory() context.session_factory = factory context.engine = engine context.sqlite_store = SqliteChangeSetStore(factory) context._cleanup_handlers.append(lambda: session.close()) context._cleanup_handlers.append(lambda: engine.dispose()) @when('I persist-start a changeset for plan "{plan_id}"') def step_start_changeset(context: Context, plan_id: str) -> None: context.changeset_id = context.sqlite_store.start(plan_id) context.plan_id = plan_id @then("the persisted changeset ID should be a valid ULID") def step_valid_ulid(context: Context) -> None: assert context.changeset_id is not None assert len(context.changeset_id) == 26 assert re.match(r"^[0-9A-Z]{26}$", context.changeset_id) @when("I persist-record a create entry in the changeset") def step_record_create(context: Context) -> None: entry = _make_entry(context.plan_id, ChangeOperation.CREATE, "src/new.py") context.sqlite_store.record(context.changeset_id, entry) @when("I persist-record a modify entry in the changeset") def step_record_modify(context: Context) -> None: entry = _make_entry(context.plan_id, ChangeOperation.MODIFY, "src/existing.py") context.sqlite_store.record(context.changeset_id, entry) @when("I persist-record a delete entry in the changeset") def step_record_delete(context: Context) -> None: entry = _make_entry(context.plan_id, ChangeOperation.DELETE, "src/old.py") context.sqlite_store.record(context.changeset_id, entry) @then("persisted changeset get should return {count:d} entries") def step_get_entries(context: Context, count: int) -> None: cs = context.sqlite_store.get(context.changeset_id) assert cs is not None assert len(cs.entries) == count @then('the persisted first entry operation should be "{op}"') def step_first_op(context: Context, op: str) -> None: cs = context.sqlite_store.get(context.changeset_id) assert cs is not None assert cs.entries[0].operation == op @then('the persisted second entry operation should be "{op}"') def step_second_op(context: Context, op: str) -> None: cs = context.sqlite_store.get(context.changeset_id) assert cs is not None assert cs.entries[1].operation == op @then('persisted changeset "{cid}" returns None on get') def step_get_none(context: Context, cid: str) -> None: result = context.sqlite_store.get(cid) assert result is None @when('I persist-start and populate a changeset for plan "{plan_id}"') def step_start_and_populate(context: Context, plan_id: str) -> None: context.plan_id = plan_id context.changeset_id = context.sqlite_store.start(plan_id) entry = _make_entry(plan_id, ChangeOperation.CREATE, "src/file.py") context.sqlite_store.record(context.changeset_id, entry) @then('persisted get_for_plan "{plan_id}" returns at least {count:d} changeset') def step_get_for_plan(context: Context, plan_id: str, count: int) -> None: result = context.sqlite_store.get_for_plan(plan_id) assert len(result) >= count @then("persisted summarize should show {count:d} total") def step_summarize(context: Context, count: int) -> None: summary = context.sqlite_store.summarize(context.changeset_id) assert summary.get("total") == count @then('persisted summarize of changeset "{cid}" returns empty dict') def step_summarize_empty(context: Context, cid: str) -> None: result = context.sqlite_store.summarize(cid) assert result == {} # ---- ChangeSetEntryRepository steps ---- @given("I have a persisted ChangeSetEntryRepository") def step_entry_repo(context: Context) -> None: factory, session, engine = _make_in_memory_session_factory() context.session_factory = factory context.engine = engine context.entry_repo = ChangeSetEntryRepository(factory) context._cleanup_handlers.append(lambda: session.close()) context._cleanup_handlers.append(lambda: engine.dispose()) @when('I persist-save an entry for changeset "{cs_id}" plan "{plan_id}"') def step_save_entry(context: Context, cs_id: str, plan_id: str) -> None: entry = _make_entry(plan_id, ChangeOperation.CREATE, "src/test.py") context.entry_repo.save_entry(cs_id, entry) @then('persisted entries for changeset "{cs_id}" count is {count:d}') def step_retrieve_for_changeset(context: Context, cs_id: str, count: int) -> None: entries = context.entry_repo.get_entries_for_changeset(cs_id) assert len(entries) == count @when('I persist-save entries for plan "{plan_id}"') def step_save_entries_for_plan(context: Context, plan_id: str) -> None: context.plan_id = plan_id for i in range(3): entry = _make_entry(plan_id, ChangeOperation.CREATE, f"src/f{i}.py") context.entry_repo.save_entry(f"cs-{i}", entry) @when('I persist-delete entries for plan "{plan_id}"') def step_delete_for_plan(context: Context, plan_id: str) -> None: context.entry_repo.delete_for_plan(plan_id) @then('persisted entries for plan "{plan_id}" count is {count:d}') def step_retrieve_for_plan(context: Context, plan_id: str, count: int) -> None: entries = context.entry_repo.get_entries_for_plan(plan_id) assert len(entries) == count @when('I persist-delete entries for changeset "{cs_id}"') def step_delete_for_changeset(context: Context, cs_id: str) -> None: context.entry_repo.delete_for_changeset(cs_id) # ---- ToolInvocationRepository steps ---- @given("I have a persisted ToolInvocationRepository") def step_inv_repo(context: Context) -> None: factory, session, engine = _make_in_memory_session_factory() context.session_factory = factory context.engine = engine context.inv_repo = ToolInvocationRepository(factory) context._cleanup_handlers.append(lambda: session.close()) context._cleanup_handlers.append(lambda: engine.dispose()) @when('I persist-save an invocation for plan "{plan_id}"') def step_save_invocation(context: Context, plan_id: str) -> None: context.plan_id = plan_id inv = ToolInvocation( plan_id=plan_id, tool_name="file-write", arguments={"path": "test.py"}, success=True, ) context.inv_repo.save_invocation(inv, changeset_id="cs-inv") @then('persisted invocations for plan "{plan_id}" count is {count:d}') def step_retrieve_invocations(context: Context, plan_id: str, count: int) -> None: invocations = context.inv_repo.get_invocations_for_plan(plan_id) assert len(invocations) == count @when('I persist-delete invocations for plan "{plan_id}"') def step_delete_invocations(context: Context, plan_id: str) -> None: context.inv_repo.delete_for_plan(plan_id) # ---- PlanApplyService cleanup steps ---- @given("I have a PlanApplyService backed by sqlite changeset store") def step_apply_service_with_store(context: Context) -> None: factory, session, engine = _make_in_memory_session_factory() context.session_factory = factory context.engine = engine context.sqlite_store = SqliteChangeSetStore(factory) context._cleanup_handlers.append(lambda: session.close()) context._cleanup_handlers.append(lambda: engine.dispose()) settings = Settings() lifecycle = PlanLifecycleService(settings=settings) context.apply_service = PlanApplyService( lifecycle_service=lifecycle, changeset_store=context.sqlite_store, ) lifecycle.create_action( name="local/cleanup-test", description="Cleanup test action", definition_of_done="Tests pass", strategy_actor="local/planner", execution_actor="local/executor", ) plan = lifecycle.use_action(action_name="local/cleanup-test") context.test_plan_id = plan.identity.plan_id @when("I persist-start and populate a changeset for the test plan") def step_populate_test_changeset(context: Context) -> None: cs_id = context.sqlite_store.start(context.test_plan_id) entry = _make_entry(context.test_plan_id, ChangeOperation.CREATE, "src/test.py") context.sqlite_store.record(cs_id, entry) context.test_changeset_id = cs_id @when("I call cleanup_changeset on the persisted test plan") def step_cleanup(context: Context) -> None: context.cleanup_count = context.apply_service.cleanup_changeset( context.test_plan_id, ) @then("the persisted changeset entries for the test plan are empty") def step_entries_empty(context: Context) -> None: entries = context.sqlite_store.get_for_plan(context.test_plan_id) total_entries = sum(len(cs.entries) for cs in entries) assert total_entries == 0 # ---- Validation steps ---- @when("I try creating a persisted ChangeSetEntryRepository with None") def step_entry_repo_none(context: Context) -> None: try: ChangeSetEntryRepository(None) # type: ignore[arg-type] context.persist_raised = None except ValueError as exc: context.persist_raised = exc @when("I try creating a persisted ToolInvocationRepository with None") def step_inv_repo_none(context: Context) -> None: try: ToolInvocationRepository(None) # type: ignore[arg-type] context.persist_raised = None except ValueError as exc: context.persist_raised = exc @when("I try creating a persisted SqliteChangeSetStore with None") def step_store_none(context: Context) -> None: try: SqliteChangeSetStore(None) # type: ignore[arg-type] context.persist_raised = None except ValueError as exc: context.persist_raised = exc @then("a persist ValueError should be raised") def step_value_error(context: Context) -> None: assert isinstance(context.persist_raised, ValueError) @when("I try to persist-start a changeset with empty plan_id") def step_start_empty(context: Context) -> None: try: context.sqlite_store.start("") context.persist_raised = None except ValueError as exc: context.persist_raised = exc @when("I try to persist-cleanup changeset with empty plan_id") def step_cleanup_empty(context: Context) -> None: try: context.apply_service.cleanup_changeset("") context.persist_raised = None except (ValidationError, ValueError) as exc: context.persist_raised = exc @then("a persist validation error should be raised") def step_validation_error(context: Context) -> None: assert isinstance(context.persist_raised, (ValidationError, ValueError))