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
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
278 lines
9.1 KiB
Python
278 lines
9.1 KiB
Python
"""Helper script for checkpoint_real_rollback.robot integration tests.
|
|
|
|
Each subcommand exercises CheckpointService.rollback_to_checkpoint() against
|
|
a real git repository to verify that the method executes ``git reset --hard``
|
|
and actually restores file system state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import NoReturn
|
|
|
|
# Ensure local source tree AND robot/ directory are importable.
|
|
_ROOT = Path(__file__).resolve().parents[1]
|
|
_SRC = str(_ROOT / "src")
|
|
_ROBOT = str(_ROOT / "robot")
|
|
for _p in (_SRC, _ROBOT):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
from cleveragents.application.services.checkpoint_service import ( # noqa: E402
|
|
CheckpointService,
|
|
)
|
|
from cleveragents.infrastructure.events.models import DomainEvent # noqa: E402
|
|
from cleveragents.infrastructure.events.types import EventType # noqa: E402
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_INITIAL_CONTENT = "initial content\n"
|
|
_MODIFIED_CONTENT = "modified after checkpoint\n"
|
|
_TRACKED_FILENAME = "tracked.txt"
|
|
_NEW_FILENAME = "extra.txt"
|
|
_PLAN_ID = "01JBG822CHKPT000PXAN000000"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fail(msg: str) -> NoReturn:
|
|
"""Print failure message to stderr and exit with code 1."""
|
|
print(msg, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _run_git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
|
|
"""Run a git command and return the completed process."""
|
|
return subprocess.run(
|
|
["git", *args],
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def _get_head_sha(cwd: str) -> str:
|
|
"""Return the HEAD commit SHA of a git repository."""
|
|
result = _run_git(["rev-parse", "HEAD"], cwd=cwd)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _create_workspace() -> str:
|
|
"""Create a temporary git workspace with an initial committed file.
|
|
|
|
Returns the path to the temporary directory.
|
|
"""
|
|
tmpdir = tempfile.mkdtemp(prefix="checkpoint_822_robot_")
|
|
try:
|
|
_run_git(["init", "--initial-branch=main"], cwd=tmpdir)
|
|
_run_git(["config", "user.email", "test@example.com"], cwd=tmpdir)
|
|
_run_git(["config", "user.name", "Test"], cwd=tmpdir)
|
|
|
|
tracked_path = os.path.join(tmpdir, _TRACKED_FILENAME)
|
|
Path(tracked_path).write_text(_INITIAL_CONTENT, encoding="utf-8")
|
|
_run_git(["add", _TRACKED_FILENAME], cwd=tmpdir)
|
|
_run_git(["commit", "-m", "Initial commit"], cwd=tmpdir)
|
|
except Exception:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
raise
|
|
return tmpdir
|
|
|
|
|
|
class _RecordingEventBus:
|
|
"""In-memory event bus that records emitted events."""
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def rollback_restores_content() -> None:
|
|
"""Verify that rollback reverts a modified tracked file."""
|
|
tmpdir = _create_workspace()
|
|
try:
|
|
tracked_path = os.path.join(tmpdir, _TRACKED_FILENAME)
|
|
initial_sha = _get_head_sha(tmpdir)
|
|
|
|
service = CheckpointService()
|
|
service.register_sandbox(_PLAN_ID, tmpdir)
|
|
checkpoint = service.create_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
sandbox_ref=initial_sha,
|
|
reason="Integration test checkpoint",
|
|
checkpoint_type="manual",
|
|
)
|
|
|
|
# Modify the tracked file and commit
|
|
Path(tracked_path).write_text(_MODIFIED_CONTENT)
|
|
_run_git(["add", _TRACKED_FILENAME], cwd=tmpdir)
|
|
_run_git(["commit", "-m", "Modify tracked file"], cwd=tmpdir)
|
|
|
|
# Invoke rollback
|
|
service.rollback_to_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
checkpoint_id=checkpoint.checkpoint_id,
|
|
)
|
|
|
|
# Assert file content matches checkpoint state
|
|
actual = Path(tracked_path).read_text()
|
|
if actual != _INITIAL_CONTENT:
|
|
_fail(
|
|
f"Tracked file not reverted after rollback. "
|
|
f"Expected: {_INITIAL_CONTENT!r}, Got: {actual!r}."
|
|
)
|
|
|
|
print("checkpoint-rollback-restores-content-ok")
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def rollback_removes_added_files() -> None:
|
|
"""Verify that rollback removes files added after the checkpoint."""
|
|
tmpdir = _create_workspace()
|
|
try:
|
|
initial_sha = _get_head_sha(tmpdir)
|
|
|
|
service = CheckpointService()
|
|
service.register_sandbox(_PLAN_ID, tmpdir)
|
|
checkpoint = service.create_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
sandbox_ref=initial_sha,
|
|
reason="Integration test checkpoint",
|
|
checkpoint_type="manual",
|
|
)
|
|
|
|
# Add a new file and commit
|
|
new_path = os.path.join(tmpdir, _NEW_FILENAME)
|
|
Path(new_path).write_text("new file content\n")
|
|
_run_git(["add", _NEW_FILENAME], cwd=tmpdir)
|
|
_run_git(["commit", "-m", "Add new file"], cwd=tmpdir)
|
|
|
|
# Invoke rollback
|
|
service.rollback_to_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
checkpoint_id=checkpoint.checkpoint_id,
|
|
)
|
|
|
|
# Assert the new file no longer exists
|
|
if os.path.exists(new_path):
|
|
_fail(f"New file still exists after rollback: {new_path}.")
|
|
|
|
print("checkpoint-rollback-removes-added-files-ok")
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def rollback_emits_event() -> None:
|
|
"""Verify that rollback emits a CHECKPOINT_RESTORED domain event."""
|
|
tmpdir = _create_workspace()
|
|
try:
|
|
initial_sha = _get_head_sha(tmpdir)
|
|
event_bus = _RecordingEventBus()
|
|
|
|
service = CheckpointService(event_bus=event_bus) # type: ignore[arg-type]
|
|
service.register_sandbox(_PLAN_ID, tmpdir)
|
|
checkpoint = service.create_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
sandbox_ref=initial_sha,
|
|
reason="Integration test checkpoint",
|
|
checkpoint_type="manual",
|
|
)
|
|
|
|
# Modify and commit so rollback has something to do
|
|
tracked_path = os.path.join(tmpdir, _TRACKED_FILENAME)
|
|
Path(tracked_path).write_text(_MODIFIED_CONTENT)
|
|
_run_git(["add", _TRACKED_FILENAME], cwd=tmpdir)
|
|
_run_git(["commit", "-m", "Modify tracked file"], cwd=tmpdir)
|
|
|
|
service.rollback_to_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
checkpoint_id=checkpoint.checkpoint_id,
|
|
)
|
|
|
|
restored_events = [
|
|
e for e in event_bus.events if e.event_type == EventType.CHECKPOINT_RESTORED
|
|
]
|
|
if len(restored_events) != 1:
|
|
_fail(f"Expected 1 CHECKPOINT_RESTORED event, got {len(restored_events)}.")
|
|
|
|
print("checkpoint-rollback-emits-event-ok")
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def rollback_rejects_nonexistent_sandbox() -> None:
|
|
"""Verify that rollback rejects a nonexistent sandbox path."""
|
|
from cleveragents.core.exceptions import BusinessRuleViolation
|
|
|
|
tmpdir = _create_workspace()
|
|
try:
|
|
initial_sha = _get_head_sha(tmpdir)
|
|
|
|
service = CheckpointService()
|
|
service.register_sandbox(_PLAN_ID, "/tmp/nonexistent_sandbox_path_822")
|
|
checkpoint = service.create_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
sandbox_ref=initial_sha,
|
|
reason="Integration test checkpoint",
|
|
checkpoint_type="manual",
|
|
)
|
|
|
|
try:
|
|
service.rollback_to_checkpoint(
|
|
plan_id=_PLAN_ID,
|
|
checkpoint_id=checkpoint.checkpoint_id,
|
|
)
|
|
_fail("Expected BusinessRuleViolation for nonexistent sandbox path")
|
|
except BusinessRuleViolation as exc:
|
|
if "sandbox path does not exist" not in str(exc):
|
|
_fail(f"Unexpected error message: {exc}")
|
|
|
|
print("checkpoint-rollback-rejects-nonexistent-sandbox-ok")
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COMMANDS: dict[str, Callable[[], None]] = {
|
|
"rollback-restores-content": rollback_restores_content,
|
|
"rollback-removes-added-files": rollback_removes_added_files,
|
|
"rollback-emits-event": rollback_emits_event,
|
|
"rollback-rejects-nonexistent-sandbox": rollback_rejects_nonexistent_sandbox,
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
|
print(
|
|
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
cmd = _COMMANDS[sys.argv[1]]
|
|
cmd()
|