Files
cleveragents-core/features/steps/checkpoint_rollback_steps.py
T
freemo ec4c39aecf
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 17s
CI / build (pull_request) Successful in 28s
CI / quality (pull_request) Successful in 34s
CI / security (pull_request) Successful in 42s
CI / typecheck (pull_request) Successful in 45s
CI / unit_tests (pull_request) Successful in 3m11s
CI / integration_tests (pull_request) Successful in 3m37s
CI / e2e_tests (pull_request) Successful in 4m1s
CI / docker (pull_request) Successful in 56s
CI / coverage (pull_request) Successful in 6m37s
CI / lint (push) Successful in 11s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 32s
CI / typecheck (push) Successful in 44s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 56s
CI / unit_tests (push) Successful in 2m55s
CI / integration_tests (push) Successful in 3m34s
CI / docker (push) Successful in 59s
CI / e2e_tests (push) Successful in 3m59s
CI / coverage (push) Successful in 6m55s
CI / benchmark-publish (push) Has been cancelled
CI / benchmark-regression (pull_request) Successful in 37m40s
fix(plan): implement real checkpoint rollback via git reset
Replace the simulated rollback in CheckpointService.rollback_to_checkpoint()
with real git operations. The method now executes git reset --hard <sandbox_ref>
followed by git clean -fd inside the sandbox working directory, reverting all
tracked file changes and removing untracked files added after the checkpoint.

Key changes:
- CheckpointService.rollback_to_checkpoint() now calls _git_reset_hard() and
  _git_clean() via subprocess against the sandbox path, enforcing sandbox
  boundary by confining all git operations to cwd=sandbox_path.
- Added _resolve_sandbox_path() to extract sandbox validation into a dedicated
  method, supporting both lifecycle-service and in-memory fallback paths.
- Added _validate_sandbox() to verify the sandbox path is a directory containing
  a .git subdirectory before executing git operations.
- Added _git_changed_paths() to compute the diff between HEAD and the target ref
  before reset, providing accurate restored file counts in the result.
- Domain event emission (CHECKPOINT_RESTORED) added via the optional event_bus,
  following the same EventBus protocol pattern used by other services.
- Updated existing Robot Framework helper (helper_checkpoint_rollback.py) to use
  a real temporary git workspace instead of a fake sandbox path, since
  _validate_sandbox() now enforces that the path is a real git repository.
- Removed @tdd_expected_fail tags from TDD test files since the bug is now fixed.
- Added new Behave scenarios and Robot integration tests for real file reversion,
  file removal after rollback, domain event emission, and sandbox boundary
  enforcement.

ISSUES CLOSED: #822
2026-03-18 16:51:14 +00:00

992 lines
33 KiB
Python

"""Step definitions for checkpoint_rollback.feature.
Exercises checkpoint domain models, CheckpointService operations
(create, list, rollback, prune, delete), guard validations, and
correction-service integration.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from behave import given, then, when
from pydantic import ValidationError as PydanticValidationError
from cleveragents.application.services.checkpoint_service import CheckpointService
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import (
BusinessRuleViolation,
ResourceNotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.checkpoint import (
Checkpoint,
CheckpointMetadata,
CheckpointRetentionPolicy,
RollbackResult,
)
from cleveragents.infrastructure.events.models import DomainEvent
_VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
def _create_git_workspace(context: object) -> str:
"""Create a temporary git workspace and register cleanup on context."""
tmpdir = tempfile.mkdtemp(prefix="checkpoint_test_")
subprocess.run(
["git", "init", "--initial-branch=main"],
cwd=tmpdir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=tmpdir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=tmpdir,
capture_output=True,
text=True,
check=True,
)
# Create an initial commit so the repo is not empty
placeholder = os.path.join(tmpdir, ".gitkeep")
Path(placeholder).write_text("")
subprocess.run(
["git", "add", ".gitkeep"],
cwd=tmpdir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=tmpdir,
capture_output=True,
text=True,
check=True,
)
def _cleanup() -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
if hasattr(context, "add_cleanup"):
context.add_cleanup(_cleanup)
return tmpdir
def _get_head_sha(cwd: str) -> str:
"""Return the HEAD commit SHA of a git repository."""
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
# -------------------------------------------------------------------
# Domain model steps
# -------------------------------------------------------------------
@given('a valid checkpoint with plan_id "{plan_id}" and sandbox_ref "{sandbox_ref}"')
def step_create_valid_checkpoint(context, plan_id, sandbox_ref):
from ulid import ULID
context.checkpoint = Checkpoint(
checkpoint_id=str(ULID()),
plan_id=plan_id,
sandbox_ref=sandbox_ref,
)
@then('the checkpoint plan_id should be "{expected}"')
def step_check_plan_id(context, expected):
assert context.checkpoint.plan_id == expected
@then('the checkpoint sandbox_ref should be "{expected}"')
def step_check_sandbox_ref(context, expected):
assert context.checkpoint.sandbox_ref == expected
@given(
'a checkpoint with reason "{reason}" and source_tool "{tool}" and phase "{phase}"'
)
def step_create_checkpoint_with_metadata(context, reason, tool, phase):
from ulid import ULID
context.checkpoint = Checkpoint(
checkpoint_id=str(ULID()),
plan_id=_VALID_ULID,
sandbox_ref="ref123",
metadata=CheckpointMetadata(reason=reason, source_tool=tool, phase=phase),
)
@then('the checkpoint metadata reason should be "{expected}"')
def step_check_metadata_reason(context, expected):
assert context.checkpoint.metadata.reason == expected
@then('the checkpoint metadata source_tool should be "{expected}"')
def step_check_metadata_source_tool(context, expected):
assert context.checkpoint.metadata.source_tool == expected
@then('the checkpoint metadata phase should be "{expected}"')
def step_check_metadata_phase(context, expected):
assert context.checkpoint.metadata.phase == expected
@given("a default retention policy")
def step_default_retention(context):
context.policy = CheckpointRetentionPolicy()
@then("the max_checkpoints should be {count:d}")
def step_check_max_checkpoints(context, count):
assert context.policy.max_checkpoints == count
@then("auto_prune should be true")
def step_check_auto_prune_true(context):
assert context.policy.auto_prune is True
@then("auto_prune should be false")
def step_check_auto_prune_false(context):
assert context.policy.auto_prune is False
@given("a retention policy with max_checkpoints {max_cp:d} and auto_prune false")
def step_custom_retention(context, max_cp):
context.policy = CheckpointRetentionPolicy(max_checkpoints=max_cp, auto_prune=False)
@given('a rollback result with {count:d} restored files and checkpoint "{cp_id}"')
def step_create_rollback_result(context, count, cp_id):
context.rollback_result = RollbackResult(
restored_files_count=count,
changed_paths=[f"file_{i}.py" for i in range(count)],
from_checkpoint_id=cp_id,
)
@then("the restored_files_count should be {count:d}")
def step_check_restored_count(context, count):
assert context.rollback_result.restored_files_count == count
@then('the from_checkpoint_id should be "{expected}"')
def step_check_from_checkpoint(context, expected):
assert context.rollback_result.from_checkpoint_id == expected
# -------------------------------------------------------------------
# CheckpointService steps
# -------------------------------------------------------------------
@given("a checkpoint service")
def step_create_service(context):
context.svc = CheckpointService()
context.checkpoint = None
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
@given('a checkpoint service with sandbox for plan "{plan_id}"')
def step_create_service_with_sandbox(context, plan_id):
workspace = _create_git_workspace(context)
context.svc = CheckpointService()
context.svc.register_sandbox(plan_id, workspace)
context.workspace_dir = workspace
context.checkpoint = None
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
@given('plan "{plan_id}" is marked as applied')
def step_mark_applied(context, plan_id):
context.svc.mark_plan_applied(plan_id)
@given('I create a checkpoint for plan "{plan_id}" with sandbox_ref "{ref}"')
@when('I create a checkpoint for plan "{plan_id}" with sandbox_ref "{ref}"')
def step_create_checkpoint(context, plan_id, ref):
context.checkpoint = context.svc.create_checkpoint(plan_id=plan_id, sandbox_ref=ref)
@then("the checkpoint should be created successfully")
def step_checkpoint_created(context):
assert context.checkpoint is not None
assert context.checkpoint.checkpoint_id
@then('the checkpoint plan_id should match "{expected}"')
def step_checkpoint_plan_match(context, expected):
assert context.checkpoint.plan_id == expected
@when('I list checkpoints for plan "{plan_id}"')
def step_list_checkpoints(context, plan_id):
context.checkpoints = context.svc.list_checkpoints(plan_id)
@then("I should see {count:d} checkpoints")
def step_check_checkpoint_count(context, count):
assert len(context.checkpoints) == count
@when('I rollback plan "{plan_id}" to the created checkpoint')
def step_rollback_to_checkpoint(context, plan_id):
context.rollback_result = context.svc.rollback_to_checkpoint(
plan_id, context.checkpoint.checkpoint_id
)
@then("the rollback should succeed")
def step_rollback_success(context):
assert context.rollback_result is not None
@then("the rollback result should show restored files")
def step_rollback_shows_files(context):
assert context.rollback_result.restored_files_count > 0
@when('I attempt rollback plan "{plan_id}" to the created checkpoint')
def step_attempt_rollback(context, plan_id):
try:
context.rollback_result = context.svc.rollback_to_checkpoint(
plan_id, context.checkpoint.checkpoint_id
)
context.error = None
except (BusinessRuleViolation, ResourceNotFoundError, ValidationError) as e:
context.error = e
@when('I attempt rollback plan "{plan_id}" to checkpoint "{cp_id}"')
def step_attempt_rollback_by_id(context, plan_id, cp_id):
try:
context.rollback_result = context.svc.rollback_to_checkpoint(plan_id, cp_id)
context.error = None
except (BusinessRuleViolation, ResourceNotFoundError, ValidationError) as e:
context.error = e
@then('the rollback should be rejected with "{msg}"')
def step_rollback_rejected(context, msg):
assert context.error is not None
assert msg in str(context.error)
@then("the rollback should fail with not found error")
def step_rollback_not_found(context):
assert context.error is not None
assert isinstance(context.error, ResourceNotFoundError)
@when("I delete the created checkpoint")
def step_delete_checkpoint(context):
context.svc.delete_checkpoint(context.checkpoint.checkpoint_id)
@then("the checkpoint should be deleted")
def step_checkpoint_deleted(context):
try:
context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
msg = "Checkpoint should have been deleted"
raise AssertionError(msg)
except ResourceNotFoundError:
pass
@given('I create {count:d} checkpoints for plan "{plan_id}"')
def step_create_multiple_checkpoints(context, count, plan_id):
for i in range(count):
context.checkpoint = context.svc.create_checkpoint(
plan_id=plan_id, sandbox_ref=f"commit-{i}"
)
@when("I prune checkpoints with max {max_cp:d}")
def step_prune_checkpoints(context, max_cp):
policy = CheckpointRetentionPolicy(max_checkpoints=max_cp, auto_prune=True)
context.pruned_ids = context.svc.prune_checkpoints(_VALID_ULID, policy)
@then("{count:d} checkpoints should be pruned")
def step_check_pruned_count(context, count):
assert len(context.pruned_ids) == count
@then("{count:d} checkpoint snapshots should remain")
def step_check_remaining_count(context, count):
remaining = context.svc.list_checkpoints(_VALID_ULID)
assert len(remaining) == count
@when("I create a checkpoint with retention policy max {max_cp:d}")
def step_create_with_retention(context, max_cp):
policy = CheckpointRetentionPolicy(max_checkpoints=max_cp, auto_prune=True)
before_count = len(context.svc.list_checkpoints(_VALID_ULID))
context.checkpoint = context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="commit-retention",
retention_policy=policy,
)
after_count = len(context.svc.list_checkpoints(_VALID_ULID))
context.pruned_on_creation = (before_count + 1) - after_count
@then("{count:d} checkpoints should have been pruned on creation")
def step_check_pruned_on_creation(context, count):
assert context.pruned_on_creation == count
@when("I attempt to create a checkpoint with empty plan_id")
def step_create_empty_plan(context):
try:
context.svc.create_checkpoint(plan_id="", sandbox_ref="ref")
context.error = None
except (ValidationError, PydanticValidationError) as e:
context.error = e
@when("I attempt to create a checkpoint with empty sandbox_ref")
def step_create_empty_ref(context):
try:
context.svc.create_checkpoint(plan_id=_VALID_ULID, sandbox_ref="")
context.error = None
except (ValidationError, PydanticValidationError) as e:
context.error = e
@then("a checkpoint validation error should be raised")
def step_checkpoint_validation_error(context):
assert context.error is not None
assert isinstance(context.error, (ValidationError, PydanticValidationError))
@when("I get the checkpoint by its ID")
def step_get_checkpoint_by_id(context):
context.result_checkpoint = context.svc.get_checkpoint(
context.checkpoint.checkpoint_id
)
@then("the checkpoint should be returned successfully")
def step_checkpoint_returned(context):
assert context.result_checkpoint is not None
assert context.result_checkpoint.checkpoint_id == context.checkpoint.checkpoint_id
@when('I attempt to get checkpoint "{cp_id}"')
def step_get_nonexistent(context, cp_id):
try:
context.svc.get_checkpoint(cp_id)
context.error = None
except ResourceNotFoundError as e:
context.error = e
@then("a checkpoint not found error should be raised")
def step_checkpoint_not_found_error(context):
assert context.error is not None
assert isinstance(context.error, ResourceNotFoundError)
# -------------------------------------------------------------------
# Correction service integration steps
# -------------------------------------------------------------------
@given("a correction service with checkpoint support")
def step_correction_with_checkpoint(context):
cp_svc = CheckpointService()
context.correction_svc = CorrectionService(checkpoint_service=cp_svc)
@then("the correction service should have checkpoint support")
def step_check_checkpoint_support(context):
assert context.correction_svc._checkpoint_service is not None
@when(
'I create a checkpoint with reason "{reason}" and source_tool "{tool}" and phase "{phase}"'
)
def step_create_with_metadata(context, reason, tool, phase):
context.checkpoint = context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="ref-meta",
reason=reason,
source_tool=tool,
phase=phase,
)
@then('the stored checkpoint metadata should have reason "{expected}"')
def step_stored_reason(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.metadata.reason == expected
@then('the stored checkpoint metadata should have source_tool "{expected}"')
def step_stored_source_tool(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.metadata.source_tool == expected
@then('the stored checkpoint metadata should have phase "{expected}"')
def step_stored_phase(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.metadata.phase == expected
# -------------------------------------------------------------------
# Spec-aligned field steps (decision_id, checkpoint_type, etc.)
# -------------------------------------------------------------------
@when('I create a checkpoint aligned to decision "{decision_id}"')
def step_create_with_decision(context, decision_id):
context.checkpoint = context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="ref-decision",
decision_id=decision_id,
)
@then('the stored checkpoint decision_id should be "{expected}"')
def step_check_decision_id(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.decision_id == expected
@when('I create a pre_write checkpoint for plan "{plan_id}"')
def step_create_prewrite(context, plan_id):
context.checkpoint = context.svc.create_checkpoint(
plan_id=plan_id,
sandbox_ref="ref-prewrite",
checkpoint_type="pre_write",
)
@then('the stored checkpoint_type should be "{expected}"')
def step_check_checkpoint_type(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.checkpoint_type == expected
@when('I attempt to create a checkpoint with type "{cp_type}"')
def step_create_invalid_type(context, cp_type):
try:
context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="ref-invalid",
checkpoint_type=cp_type,
)
context.error = None
except (ValidationError, PydanticValidationError) as e:
context.error = e
@when('I create a checkpoint with resource "{resource_id}"')
def step_create_with_resource(context, resource_id):
context.checkpoint = context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="ref-resource",
resource_id=resource_id,
)
@then('the stored checkpoint resource_id should be "{expected}"')
def step_check_resource_id(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.resource_id == expected
@when('I create a checkpoint with path "{path}" and size {size:d}')
def step_create_with_path_and_size(context, path, size):
context.checkpoint = context.svc.create_checkpoint(
plan_id=_VALID_ULID,
sandbox_ref="ref-path",
filesystem_path=path,
size_bytes=size,
)
@then('the stored checkpoint filesystem_path should be "{expected}"')
def step_check_filesystem_path(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.filesystem_path == expected
@then("the stored checkpoint size_bytes should be {expected:d}")
def step_check_size_bytes(context, expected):
cp = context.svc.get_checkpoint(context.checkpoint.checkpoint_id)
assert cp.size_bytes == expected
@then("the first checkpoint should survive pruning")
def step_first_survives_pruning(context):
"""Verify the first (oldest) checkpoint survived pruning."""
remaining = context.svc.list_checkpoints(_VALID_ULID)
# The first checkpoint created has sandbox_ref "commit-0"
assert any(cp.sandbox_ref == "commit-0" for cp in remaining), (
"First checkpoint (commit-0) should be preserved after pruning"
)
@then("the most recent checkpoint should survive pruning")
def step_most_recent_survives_pruning(context):
"""Verify the most recent checkpoint survived pruning."""
remaining = context.svc.list_checkpoints(_VALID_ULID)
# The last checkpoint created has the highest commit-N
last_ref = context.checkpoint.sandbox_ref
assert any(cp.sandbox_ref == last_ref for cp in remaining), (
f"Most recent checkpoint ({last_ref}) should be preserved after pruning"
)
# -------------------------------------------------------------------
# Persistent guard steps (lifecycle-service backed)
# -------------------------------------------------------------------
class _FakePlan:
"""Minimal plan stub for lifecycle-service guard tests."""
def __init__(self, plan_id, processing_state, sandbox_refs=None):
self.plan_id = plan_id
self.processing_state = processing_state
self.sandbox_refs = sandbox_refs or []
class _FakeLifecycleService:
"""Stub PlanLifecycleService that returns a canned plan."""
def __init__(self, plan):
self._plan = plan
def get_plan(self, plan_id):
return self._plan
@given(
'a checkpoint service backed by a lifecycle service with an applied plan "{plan_id}"'
)
def step_service_lifecycle_applied(context, plan_id):
from cleveragents.domain.models.core.plan import ProcessingState
fake_plan = _FakePlan(plan_id, ProcessingState.APPLIED, sandbox_refs=["sb"])
fake_ls = _FakeLifecycleService(fake_plan)
context.svc = CheckpointService(plan_lifecycle_service=fake_ls) # type: ignore[arg-type]
context.checkpoint = None
context.error = None
context.rollback_result = None
@given(
'a checkpoint service backed by a lifecycle service with no sandbox for plan "{plan_id}"'
)
def step_service_lifecycle_no_sandbox(context, plan_id):
from cleveragents.domain.models.core.plan import ProcessingState
fake_plan = _FakePlan(plan_id, ProcessingState.PROCESSING, sandbox_refs=[])
fake_ls = _FakeLifecycleService(fake_plan)
context.svc = CheckpointService(plan_lifecycle_service=fake_ls) # type: ignore[arg-type]
context.checkpoint = None
context.error = None
context.rollback_result = None
@given(
'a checkpoint service backed by a lifecycle service with sandbox for plan "{plan_id}"'
)
def step_service_lifecycle_with_sandbox(context, plan_id):
from cleveragents.domain.models.core.plan import ProcessingState
fake_plan = _FakePlan(
plan_id, ProcessingState.PROCESSING, sandbox_refs=["sandbox-root"]
)
fake_ls = _FakeLifecycleService(fake_plan)
context.svc = CheckpointService(plan_lifecycle_service=fake_ls) # type: ignore[arg-type]
context.checkpoint = None
context.error = None
context.rollback_result = None
# -------------------------------------------------------------------
# Default retention policy step
# -------------------------------------------------------------------
@then('at most {max_cp:d} checkpoint snapshots should remain for plan "{plan_id}"')
def step_at_most_checkpoints_remain(context, max_cp, plan_id):
remaining = context.svc.list_checkpoints(plan_id)
assert len(remaining) <= max_cp, (
f"Expected at most {max_cp} checkpoints, but found {len(remaining)}"
)
@given('the sandbox for plan "{plan_id}" is unregistered')
def step_unregister_sandbox(context, plan_id):
context.svc.unregister_sandbox(plan_id)
@when("I prune checkpoints with auto_prune disabled")
def step_prune_no_auto(context):
policy = CheckpointRetentionPolicy(max_checkpoints=3, auto_prune=False)
context.pruned_ids = context.svc.prune_checkpoints(_VALID_ULID, policy)
# -------------------------------------------------------------------
# Stub repository for repository-backed tests
# -------------------------------------------------------------------
class _StubCheckpointRepository:
"""In-memory repository stub that implements the CheckpointRepository interface.
Used to exercise the repository-backed code paths in CheckpointService
without requiring a real database connection.
"""
def __init__(self) -> None:
self._store: dict[str, Checkpoint] = {}
self._plan_index: dict[str, list[str]] = {}
def create(self, checkpoint: Checkpoint) -> Checkpoint:
self._store[checkpoint.checkpoint_id] = checkpoint
self._plan_index.setdefault(checkpoint.plan_id, []).append(
checkpoint.checkpoint_id
)
return checkpoint
def get_by_id(self, checkpoint_id: str) -> Checkpoint:
from cleveragents.infrastructure.database.repositories import (
CheckpointNotFoundError,
)
cp = self._store.get(checkpoint_id)
if cp is None:
raise CheckpointNotFoundError(checkpoint_id)
return cp
def list_by_plan(self, plan_id: str) -> list[Checkpoint]:
ids = self._plan_index.get(plan_id, [])
return [self._store[cid] for cid in ids if cid in self._store]
def delete(self, checkpoint_id: str) -> bool:
cp = self._store.pop(checkpoint_id, None)
if cp is None:
return False
plan_list = self._plan_index.get(cp.plan_id, [])
if checkpoint_id in plan_list:
plan_list.remove(checkpoint_id)
return True
def prune(self, plan_id: str, max_checkpoints: int) -> list[str]:
ids = self._plan_index.get(plan_id, [])
if len(ids) <= max_checkpoints or len(ids) < 3:
return []
interior = ids[1:-1]
excess = len(ids) - max_checkpoints
to_remove = interior[:excess]
for cid in to_remove:
self._store.pop(cid, None)
self._plan_index[plan_id] = [cid for cid in ids if cid not in set(to_remove)]
return to_remove
@given("a checkpoint service backed by a stub repository")
def step_create_repo_backed_service(context):
repo = _StubCheckpointRepository()
context.stub_repo = repo
context.svc = CheckpointService(repository=repo) # type: ignore[arg-type]
context.checkpoint = None
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
@then("the stub repository should have {count:d} checkpoint stored")
def step_check_stub_repo_count(context, count):
assert len(context.stub_repo._store) == count
@when('I attempt to delete checkpoint "{cp_id}"')
def step_attempt_delete_checkpoint(context, cp_id):
try:
context.svc.delete_checkpoint(cp_id)
context.error = None
except ResourceNotFoundError as e:
context.error = e
# -------------------------------------------------------------------
# In-memory edge case steps
# -------------------------------------------------------------------
@given("a checkpoint service with an orphaned plan index entry")
def step_service_with_orphaned_index(context):
"""Create a service where _plan_index has an ID not in _checkpoints."""
context.svc = CheckpointService()
# Create a real checkpoint first
real_cp = context.svc.create_checkpoint(
plan_id=_VALID_ULID, sandbox_ref="commit-real"
)
# Add a ghost entry to plan_index that has no corresponding checkpoint
context.svc._plan_index[_VALID_ULID].append("01GHOST0000000000000000000")
context.checkpoint = real_cp
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
@given("a checkpoint service with a checkpoint missing from plan index")
def step_service_with_missing_plan_index(context):
"""Create a service where a checkpoint exists in _checkpoints but not in _plan_index."""
context.svc = CheckpointService()
cp = context.svc.create_checkpoint(plan_id=_VALID_ULID, sandbox_ref="commit-orphan")
# Remove the checkpoint from plan_index but leave it in _checkpoints
context.svc._plan_index[_VALID_ULID].clear()
context.checkpoint = cp
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
# -------------------------------------------------------------------
# Domain model validator edge case steps
# -------------------------------------------------------------------
@when("I invoke the checkpoint ULID validator with an empty string")
def step_invoke_ulid_validator_empty(context):
try:
Checkpoint.validate_ulid("")
context.error = None
except ValueError as e:
context.error = e
@then('the checkpoint validator should raise ValueError "{msg}"')
def step_check_checkpoint_value_error(context, msg):
assert context.error is not None, "Expected a ValueError"
assert isinstance(context.error, ValueError)
assert msg in str(context.error)
# -------------------------------------------------------------------
# Real git rollback steps (bug #822 fix)
# -------------------------------------------------------------------
_SANDBOX_INITIAL_CONTENT = "initial sandbox content\n"
_SANDBOX_MODIFIED_CONTENT = "modified after checkpoint\n"
_SANDBOX_TRACKED_FILE = "sandbox_tracked.txt"
_SANDBOX_NEW_FILE = "sandbox_extra.txt"
@given('a checkpoint is created from the sandbox HEAD for plan "{plan_id}"')
def step_create_checkpoint_from_sandbox_head(context, plan_id):
"""Create a checkpoint using the real HEAD SHA of the sandbox workspace."""
head_sha = _get_head_sha(context.workspace_dir)
context.checkpoint = context.svc.create_checkpoint(
plan_id=plan_id,
sandbox_ref=head_sha,
reason="checkpoint from sandbox HEAD",
checkpoint_type="manual",
)
context.checkpoint_head_sha = head_sha
@given("a file is modified in the sandbox after the checkpoint")
def step_modify_sandbox_file(context):
"""Modify a file in the sandbox and commit to advance HEAD."""
tracked_path = os.path.join(context.workspace_dir, _SANDBOX_TRACKED_FILE)
Path(tracked_path).write_text(_SANDBOX_MODIFIED_CONTENT)
subprocess.run(
["git", "add", _SANDBOX_TRACKED_FILE],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "Modify sandbox file"],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
@given("a tracked sandbox file is modified and committed after the checkpoint")
def step_modify_tracked_sandbox_file(context):
"""Create, commit, then modify a tracked sandbox file after checkpoint."""
tracked_path = os.path.join(context.workspace_dir, _SANDBOX_TRACKED_FILE)
# Write initial content and commit (this is the pre-checkpoint state
# since .gitkeep was the only file at checkpoint time)
Path(tracked_path).write_text(_SANDBOX_INITIAL_CONTENT)
subprocess.run(
["git", "add", _SANDBOX_TRACKED_FILE],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "Add tracked file"],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
context.sandbox_tracked_path = tracked_path
@given("a new file is added and committed in the sandbox after the checkpoint")
def step_add_new_sandbox_file(context):
"""Add a new file and commit it in the sandbox after the checkpoint."""
new_path = os.path.join(context.workspace_dir, _SANDBOX_NEW_FILE)
Path(new_path).write_text("new file content\n")
subprocess.run(
["git", "add", _SANDBOX_NEW_FILE],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "Add new sandbox file"],
cwd=context.workspace_dir,
capture_output=True,
text=True,
check=True,
)
context.sandbox_new_file_path = new_path
@then("the sandbox file content should match the pre-checkpoint state")
def step_assert_sandbox_file_reverted(context):
"""Assert the tracked sandbox file no longer exists (it was added after checkpoint)."""
assert not os.path.exists(context.sandbox_tracked_path), (
f"Tracked file should not exist after rollback to pre-checkpoint state. "
f"File still exists: {context.sandbox_tracked_path}"
)
@then("the file added after the checkpoint should not exist")
def step_assert_sandbox_new_file_removed(context):
"""Assert the file added after checkpoint is removed by rollback."""
assert not os.path.exists(context.sandbox_new_file_path), (
f"New file should not exist after rollback. "
f"File still exists: {context.sandbox_new_file_path}"
)
class _RecordingEventBus:
"""In-memory event bus that records emitted events for assertions."""
def __init__(self) -> None:
self.events: list[DomainEvent] = []
def emit(self, event: DomainEvent) -> None:
self.events.append(event)
def subscribe(self, event_type: object, handler: object) -> None:
pass
@given('a checkpoint service with sandbox and event bus for plan "{plan_id}"')
def step_create_service_with_sandbox_and_event_bus(context, plan_id):
workspace = _create_git_workspace(context)
event_bus = _RecordingEventBus()
context.svc = CheckpointService(event_bus=event_bus) # type: ignore[arg-type]
context.svc.register_sandbox(plan_id, workspace)
context.workspace_dir = workspace
context.event_bus = event_bus
context.checkpoint = None
context.checkpoints = None
context.rollback_result = None
context.error = None
context.pruned_ids = None
@then("a CHECKPOINT_RESTORED domain event should have been emitted")
def step_assert_checkpoint_restored_event(context):
from cleveragents.infrastructure.events.types import EventType
events = context.event_bus.events
restored_events = [
e for e in events if e.event_type == EventType.CHECKPOINT_RESTORED
]
assert len(restored_events) == 1, (
f"Expected exactly 1 CHECKPOINT_RESTORED event, got {len(restored_events)}"
)
@given('a checkpoint service with nonexistent sandbox path for plan "{plan_id}"')
def step_create_service_with_nonexistent_sandbox(context, plan_id):
context.svc = CheckpointService()
context.svc.register_sandbox(plan_id, "/tmp/nonexistent_sandbox_path_822")
context.checkpoint = None
context.error = None
context.rollback_result = None
@given('a checkpoint service with non-git sandbox path for plan "{plan_id}"')
def step_create_service_with_non_git_sandbox(context, plan_id):
tmpdir = tempfile.mkdtemp(prefix="checkpoint_non_git_")
context.svc = CheckpointService()
context.svc.register_sandbox(plan_id, tmpdir)
context.checkpoint = None
context.error = None
context.rollback_result = None
def _cleanup() -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
context.add_cleanup(_cleanup)
@given(
'a checkpoint service backed by a lifecycle service with real sandbox for plan "{plan_id}"'
)
def step_service_lifecycle_with_real_sandbox(context, plan_id):
from cleveragents.domain.models.core.plan import ProcessingState
workspace = _create_git_workspace(context)
fake_plan = _FakePlan(plan_id, ProcessingState.PROCESSING, sandbox_refs=[workspace])
fake_ls = _FakeLifecycleService(fake_plan)
context.svc = CheckpointService(plan_lifecycle_service=fake_ls) # type: ignore[arg-type]
context.workspace_dir = workspace
context.checkpoint = None
context.error = None
context.rollback_result = None