Files
cleveragents-core/features/steps/changeset_persistence_steps.py
T
freemo 0ca1303927
CI / lint (pull_request) Successful in 23s
CI / quality (pull_request) Successful in 29s
CI / security (pull_request) Successful in 54s
CI / typecheck (pull_request) Successful in 59s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 25s
CI / integration_tests (pull_request) Successful in 4m33s
CI / benchmark-regression (pull_request) Successful in 22m32s
CI / unit_tests (pull_request) Successful in 30m28s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 1h39m42s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 20s
CI / build (push) Successful in 24s
CI / security (push) Successful in 28s
CI / typecheck (push) Successful in 1m0s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 4m35s
CI / benchmark-publish (push) Successful in 13m36s
CI / coverage (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / docker (push) Has been cancelled
feat(sandbox): add checkpoint and rollback hooks
Introduce a lightweight checkpoint/rollback system for sandbox state
during plan execute and apply flows.  CheckpointManager snapshots
the sandbox working directory before each phase and can restore it
on failure, giving the execution engine a reliable undo mechanism.

Key changes:
- SandboxCheckpoint model, Checkpointable protocol, and
  CheckpointManager in infrastructure/sandbox/checkpoint.py
- PlanExecutor gains optional checkpoint_manager with pre/post
  execute hooks and automatic rollback on failure
- PlanApplyService gains optional checkpoint_manager with pre-apply
  checkpoint and rollback helper
- 12 BDD scenarios (features/sandbox_checkpoints.feature)
- 5 Robot Framework smoke tests (robot/sandbox_checkpoint_smoke.robot)
- ASV benchmarks for creation, rollback, and listing operations
- Reference documentation in docs/reference/sandbox.md

ISSUES CLOSED: #183
2026-02-27 23:08:55 +00:00

364 lines
13 KiB
Python

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