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
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
364 lines
13 KiB
Python
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))
|