fix(plan): implement real checkpoint rollback via git reset #1036
@@ -53,10 +53,10 @@ Feature: Checkpoint and rollback
|
||||
|
||||
Scenario: Rollback to checkpoint succeeds
|
||||
Given a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And I create a checkpoint for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sandbox_ref "commit-abc"
|
||||
And a checkpoint is created from the sandbox HEAD for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a file is modified in the sandbox after the checkpoint
|
||||
When I rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the rollback should succeed
|
||||
And the rollback result should show restored files
|
||||
|
||||
Scenario: Rollback rejected when plan is applied
|
||||
Given a checkpoint service
|
||||
@@ -188,11 +188,11 @@ Feature: Checkpoint and rollback
|
||||
Then the rollback should be rejected with "sandbox is missing"
|
||||
|
||||
Scenario: Rollback succeeds via lifecycle service when plan has sandbox
|
||||
Given a checkpoint service backed by a lifecycle service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And I create a checkpoint for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sandbox_ref "commit-abc"
|
||||
Given a checkpoint service backed by a lifecycle service with real sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a checkpoint is created from the sandbox HEAD for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a file is modified in the sandbox after the checkpoint
|
||||
When I rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the rollback should succeed
|
||||
And the rollback result should show restored files
|
||||
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# Default retention policy scenario
|
||||
@@ -294,3 +294,40 @@ Feature: Checkpoint and rollback
|
||||
Scenario: Checkpoint ULID validator rejects empty string
|
||||
When I invoke the checkpoint ULID validator with an empty string
|
||||
Then the checkpoint validator should raise ValueError "ULID field must not be empty"
|
||||
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# Real git rollback verification scenarios (bug #822)
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
Scenario: Rollback reverts modified file content to checkpoint state
|
||||
Given a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a checkpoint is created from the sandbox HEAD for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a tracked sandbox file is modified and committed after the checkpoint
|
||||
When I rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the sandbox file content should match the pre-checkpoint state
|
||||
|
||||
Scenario: Rollback removes files added after the checkpoint
|
||||
Given a checkpoint service with sandbox for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a checkpoint is created from the sandbox HEAD for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a new file is added and committed in the sandbox after the checkpoint
|
||||
When I rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the file added after the checkpoint should not exist
|
||||
|
||||
Scenario: Rollback emits CheckpointRolledBack domain event
|
||||
Given a checkpoint service with sandbox and event bus for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a checkpoint is created from the sandbox HEAD for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And a file is modified in the sandbox after the checkpoint
|
||||
When I rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then a CHECKPOINT_RESTORED domain event should have been emitted
|
||||
|
||||
Scenario: Rollback rejects sandbox path that does not exist
|
||||
Given a checkpoint service with nonexistent sandbox path for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And I create a checkpoint for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sandbox_ref "abc123"
|
||||
When I attempt rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the rollback should be rejected with "sandbox path does not exist"
|
||||
|
||||
Scenario: Rollback rejects sandbox path that is not a git repo
|
||||
Given a checkpoint service with non-git sandbox path for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
And I create a checkpoint for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sandbox_ref "abc123"
|
||||
When I attempt rollback plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" to the created checkpoint
|
||||
Then the rollback should be rejected with "not a git repository"
|
||||
|
||||
@@ -7,6 +7,12 @@ 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
|
||||
|
||||
@@ -23,9 +29,74 @@ from cleveragents.domain.models.core.checkpoint import (
|
||||
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
|
||||
# -------------------------------------------------------------------
|
||||
@@ -142,8 +213,10 @@ def step_create_service(context):
|
||||
|
||||
@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, "sandbox-path-123")
|
||||
context.svc.register_sandbox(plan_id, workspace)
|
||||
context.workspace_dir = workspace
|
||||
context.checkpoint = None
|
||||
context.checkpoints = None
|
||||
context.rollback_result = None
|
||||
@@ -727,3 +800,192 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@tdd_expected_fail @tdd_bug @tdd_bug_822
|
||||
@tdd_bug @tdd_bug_822
|
||||
Feature: TDD Bug #822 — checkpoint rollback is simulated, does not execute real git reset
|
||||
As a developer
|
||||
I want to verify that CheckpointService.rollback_to_checkpoint()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for real checkpoint rollback via git reset --hard.
|
||||
... Verifies that CheckpointService.rollback_to_checkpoint() actually
|
||||
... restores file system state by executing git reset --hard in the
|
||||
... sandbox working directory. Covers file reversion, file removal,
|
||||
... domain event emission, and sandbox boundary enforcement.
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_checkpoint_real_rollback.py
|
||||
|
||||
*** Test Cases ***
|
||||
Checkpoint Rollback Restores Modified File Content
|
||||
[Documentation] Create files, checkpoint, modify, rollback, verify original content restored.
|
||||
[Tags] checkpoint rollback git
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-restores-content cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} checkpoint-rollback-restores-content-ok
|
||||
|
||||
Checkpoint Rollback Removes Files Added After Checkpoint
|
||||
[Documentation] Create files, checkpoint, add new file, rollback, verify new file removed.
|
||||
[Tags] checkpoint rollback git
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-removes-added-files cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} checkpoint-rollback-removes-added-files-ok
|
||||
|
||||
Checkpoint Rollback Emits Domain Event
|
||||
[Documentation] Verify that rollback emits a CHECKPOINT_RESTORED domain event.
|
||||
[Tags] checkpoint rollback event
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-emits-event cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} checkpoint-rollback-emits-event-ok
|
||||
|
||||
Checkpoint Rollback Rejects Nonexistent Sandbox
|
||||
[Documentation] Verify that rollback rejects a sandbox path that does not exist.
|
||||
[Tags] checkpoint rollback boundary
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-rejects-nonexistent-sandbox cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} checkpoint-rollback-rejects-nonexistent-sandbox-ok
|
||||
@@ -0,0 +1,277 @@
|
||||
"""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()
|
||||
@@ -6,13 +6,68 @@ prune, delete, guards, metadata, and correction-service integration.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cleveragents.application.services.checkpoint_service import CheckpointService
|
||||
from cleveragents.application.services.correction_service import CorrectionService
|
||||
from cleveragents.core.exceptions import BusinessRuleViolation, ResourceNotFoundError
|
||||
from cleveragents.domain.models.core.checkpoint import CheckpointRetentionPolicy
|
||||
|
||||
|
||||
def _create_temp_git_workspace() -> str:
|
||||
"""Create a temporary git workspace with an initial commit."""
|
||||
tmpdir = tempfile.mkdtemp(prefix="checkpoint_rollback_test_")
|
||||
subprocess.run(
|
||||
["git", "init", "--initial-branch=main", tmpdir],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
tracked = os.path.join(tmpdir, "tracked.txt")
|
||||
Path(tracked).write_text("initial content\n")
|
||||
subprocess.run(
|
||||
["git", "add", "tracked.txt"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return tmpdir
|
||||
|
||||
|
||||
def _get_head_sha(cwd: str) -> str:
|
||||
"""Return HEAD commit SHA."""
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
_PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
|
||||
@@ -34,13 +89,40 @@ def _list_checkpoints() -> None:
|
||||
|
||||
|
||||
def _rollback_checkpoint() -> None:
|
||||
svc = CheckpointService()
|
||||
svc.register_sandbox(_PLAN_ID, "sandbox-123")
|
||||
cp = svc.create_checkpoint(_PLAN_ID, "commit-abc")
|
||||
result = svc.rollback_to_checkpoint(_PLAN_ID, cp.checkpoint_id)
|
||||
assert result.restored_files_count > 0
|
||||
assert result.from_checkpoint_id == cp.checkpoint_id
|
||||
print("rollback-checkpoint-ok")
|
||||
tmpdir = _create_temp_git_workspace()
|
||||
try:
|
||||
initial_sha = _get_head_sha(tmpdir)
|
||||
svc = CheckpointService()
|
||||
svc.register_sandbox(_PLAN_ID, tmpdir)
|
||||
cp = svc.create_checkpoint(_PLAN_ID, initial_sha)
|
||||
|
||||
# Modify the tracked file and commit
|
||||
Path(os.path.join(tmpdir, "tracked.txt")).write_text("modified\n")
|
||||
subprocess.run(
|
||||
["git", "add", "tracked.txt"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Modify tracked file"],
|
||||
cwd=tmpdir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
result = svc.rollback_to_checkpoint(_PLAN_ID, cp.checkpoint_id)
|
||||
assert result.restored_files_count > 0
|
||||
assert result.from_checkpoint_id == cp.checkpoint_id
|
||||
|
||||
# Verify actual file reversion
|
||||
content = Path(os.path.join(tmpdir, "tracked.txt")).read_text()
|
||||
assert content == "initial content\n", (
|
||||
f"Expected file reverted, got: {content!r}"
|
||||
)
|
||||
print("rollback-checkpoint-ok")
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _rollback_applied_guard() -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ ${HELPER} ${CURDIR}/helper_tdd_checkpoint_real_rollback.py
|
||||
*** Test Cases ***
|
||||
TDD Checkpoint Rollback Restores File Content
|
||||
[Documentation] Verify that rollback reverts a modified file to its checkpoint state
|
||||
[Tags] tdd_expected_fail tdd_bug tdd_bug_822
|
||||
[Tags] tdd_bug tdd_bug_822
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-restores-content cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
@@ -26,7 +26,7 @@ TDD Checkpoint Rollback Restores File Content
|
||||
|
||||
TDD Checkpoint Rollback Removes Added Files
|
||||
[Documentation] Verify that rollback removes files added after the checkpoint
|
||||
[Tags] tdd_expected_fail tdd_bug tdd_bug_822
|
||||
[Tags] tdd_bug tdd_bug_822
|
||||
${result}= Run Process ${PYTHON} ${HELPER} rollback-removes-added-files cwd=${WORKSPACE} timeout=30s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -20,7 +20,9 @@ tests that do not require the lifecycle service.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ulid import ULID
|
||||
@@ -37,6 +39,8 @@ from cleveragents.domain.models.core.checkpoint import (
|
||||
CheckpointRetentionPolicy,
|
||||
RollbackResult,
|
||||
)
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
@@ -45,6 +49,7 @@ if TYPE_CHECKING:
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
CheckpointRepository,
|
||||
)
|
||||
from cleveragents.infrastructure.events.protocol import EventBus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,15 +68,20 @@ class CheckpointService:
|
||||
lifecycle service is *not* available (e.g. in unit tests), the
|
||||
service falls back to the in-memory ``_plan_applied`` /
|
||||
``_plan_sandbox_refs`` helpers.
|
||||
|
||||
When *event_bus* is supplied, domain events are emitted on rollback
|
||||
operations (``CHECKPOINT_RESTORED``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: CheckpointRepository | None = None,
|
||||
plan_lifecycle_service: PlanLifecycleService | None = None,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._plan_lifecycle_service = plan_lifecycle_service
|
||||
self._event_bus = event_bus
|
||||
# In-memory fallback stores (used only when repository is None)
|
||||
self._checkpoints: dict[str, Checkpoint] = {}
|
||||
self._plan_index: dict[str, list[str]] = {}
|
||||
@@ -195,7 +205,12 @@ class CheckpointService:
|
||||
plan_id: str,
|
||||
checkpoint_id: str,
|
||||
) -> RollbackResult:
|
||||
"""Restore sandbox state to a named checkpoint.
|
||||
"""Restore sandbox state to a named checkpoint via ``git reset --hard``.
|
||||
|
||||
Executes a real ``git reset --hard <sandbox_ref>`` followed by
|
||||
``git clean -fd`` inside the sandbox working directory to revert
|
||||
all tracked file changes and remove untracked files added after
|
||||
the checkpoint.
|
||||
|
||||
Args:
|
||||
plan_id: Plan owning the checkpoint.
|
||||
@@ -205,33 +220,16 @@ class CheckpointService:
|
||||
A ``RollbackResult`` describing restored files.
|
||||
|
||||
Raises:
|
||||
BusinessRuleViolation: If plan is applied or sandbox missing.
|
||||
BusinessRuleViolation: If plan is applied, sandbox missing,
|
||||
sandbox path is not a git repository, or the git reset
|
||||
operation fails.
|
||||
ResourceNotFoundError: If checkpoint does not exist.
|
||||
ValidationError: If checkpoint does not belong to the plan.
|
||||
"""
|
||||
# Guard: plan must not be applied and sandbox must exist.
|
||||
# Prefer querying persistent plan state via the lifecycle service;
|
||||
# fall back to in-memory flags when the service is not wired.
|
||||
if self._plan_lifecycle_service is not None:
|
||||
plan = self._plan_lifecycle_service.get_plan(plan_id)
|
||||
if plan is None: # pragma: no cover - defensive
|
||||
raise ResourceNotFoundError(resource_type="plan", resource_id=plan_id)
|
||||
from cleveragents.domain.models.core.plan import ProcessingState
|
||||
|
||||
if plan.processing_state == ProcessingState.APPLIED:
|
||||
raise BusinessRuleViolation("Cannot rollback: plan is already applied")
|
||||
if not plan.sandbox_refs:
|
||||
raise BusinessRuleViolation(
|
||||
"Cannot rollback: sandbox is missing for this plan"
|
||||
)
|
||||
else:
|
||||
# In-memory fallback (used in unit tests without lifecycle service)
|
||||
if plan_id in self._plan_applied:
|
||||
raise BusinessRuleViolation("Cannot rollback: plan is already applied")
|
||||
if plan_id not in self._plan_sandbox_refs:
|
||||
raise BusinessRuleViolation(
|
||||
"Cannot rollback: sandbox is missing for this plan"
|
||||
)
|
||||
sandbox_path = self._resolve_sandbox_path(plan_id)
|
||||
|
||||
# Look up checkpoint
|
||||
checkpoint = self._get_checkpoint(checkpoint_id)
|
||||
@@ -242,12 +240,21 @@ class CheckpointService:
|
||||
f"Checkpoint {checkpoint_id} does not belong to plan {plan_id}"
|
||||
)
|
||||
|
||||
# Simulate rollback: in a real implementation this would call
|
||||
# git reset --hard <sandbox_ref> in the worktree. Here we
|
||||
# produce a result that describes the restore.
|
||||
sandbox_ref = checkpoint.sandbox_ref
|
||||
changed_paths = [f"restored:{sandbox_ref}"]
|
||||
restored_count = 1
|
||||
|
||||
# Validate sandbox boundary: path must exist and be a git repo
|
||||
self._validate_sandbox(sandbox_path)
|
||||
|
||||
# Compute the list of changed paths before reset for reporting
|
||||
changed_paths = self._git_changed_paths(sandbox_path, sandbox_ref)
|
||||
|
||||
# Execute real git reset --hard to restore sandbox to checkpoint
|
||||
self._git_reset_hard(sandbox_path, sandbox_ref)
|
||||
|
||||
# Remove untracked files that were added after the checkpoint
|
||||
self._git_clean(sandbox_path)
|
||||
|
||||
restored_count = len(changed_paths)
|
||||
|
||||
logger.info(
|
||||
"checkpoint.rollback",
|
||||
@@ -255,9 +262,26 @@ class CheckpointService:
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"plan_id": plan_id,
|
||||
"sandbox_ref": sandbox_ref,
|
||||
"restored_files_count": restored_count,
|
||||
"changed_paths": changed_paths,
|
||||
},
|
||||
)
|
||||
|
||||
# Emit domain event for rollback
|
||||
if self._event_bus is not None:
|
||||
self._event_bus.emit(
|
||||
DomainEvent(
|
||||
event_type=EventType.CHECKPOINT_RESTORED,
|
||||
plan_id=plan_id,
|
||||
details={
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"sandbox_ref": sandbox_ref,
|
||||
"restored_files_count": restored_count,
|
||||
"changed_paths": changed_paths,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return RollbackResult(
|
||||
restored_files_count=restored_count,
|
||||
changed_paths=changed_paths,
|
||||
@@ -371,6 +395,147 @@ class CheckpointService:
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_sandbox_path(self, plan_id: str) -> str:
|
||||
"""Resolve and validate the sandbox working directory for a plan.
|
||||
|
||||
Applies guard checks (plan must not be applied, sandbox must
|
||||
exist) and returns the filesystem path of the sandbox.
|
||||
|
||||
Args:
|
||||
plan_id: The plan whose sandbox to resolve.
|
||||
|
||||
Returns:
|
||||
The sandbox filesystem path.
|
||||
|
||||
Raises:
|
||||
BusinessRuleViolation: If plan is applied or sandbox missing.
|
||||
ResourceNotFoundError: If plan not found (lifecycle path).
|
||||
"""
|
||||
if self._plan_lifecycle_service is not None:
|
||||
plan = self._plan_lifecycle_service.get_plan(plan_id)
|
||||
if plan is None: # pragma: no cover - defensive
|
||||
raise ResourceNotFoundError(resource_type="plan", resource_id=plan_id)
|
||||
from cleveragents.domain.models.core.plan import ProcessingState
|
||||
|
||||
if plan.processing_state == ProcessingState.APPLIED:
|
||||
raise BusinessRuleViolation("Cannot rollback: plan is already applied")
|
||||
if not plan.sandbox_refs:
|
||||
raise BusinessRuleViolation(
|
||||
"Cannot rollback: sandbox is missing for this plan"
|
||||
)
|
||||
return str(plan.sandbox_refs[0])
|
||||
|
||||
# In-memory fallback (used in unit tests without lifecycle service)
|
||||
if plan_id in self._plan_applied:
|
||||
raise BusinessRuleViolation("Cannot rollback: plan is already applied")
|
||||
if plan_id not in self._plan_sandbox_refs:
|
||||
raise BusinessRuleViolation(
|
||||
"Cannot rollback: sandbox is missing for this plan"
|
||||
)
|
||||
return self._plan_sandbox_refs[plan_id]
|
||||
|
||||
def _validate_sandbox(self, sandbox_path: str) -> None:
|
||||
"""Validate that the sandbox path exists and is a git repository.
|
||||
|
||||
Args:
|
||||
sandbox_path: Filesystem path to the sandbox.
|
||||
|
||||
Raises:
|
||||
BusinessRuleViolation: If the path does not exist or is not
|
||||
a git repository.
|
||||
"""
|
||||
path = Path(sandbox_path)
|
||||
if not path.is_dir():
|
||||
raise BusinessRuleViolation(
|
||||
f"Cannot rollback: sandbox path does not exist: {sandbox_path}"
|
||||
)
|
||||
git_dir = path / ".git"
|
||||
if not git_dir.exists():
|
||||
raise BusinessRuleViolation(
|
||||
f"Cannot rollback: sandbox is not a git repository: {sandbox_path}"
|
||||
)
|
||||
|
||||
def _run_git(
|
||||
self,
|
||||
args: list[str],
|
||||
cwd: str,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a git command inside the sandbox directory.
|
||||
|
||||
Args:
|
||||
args: Git subcommand and arguments.
|
||||
cwd: Working directory for the git process.
|
||||
|
||||
Returns:
|
||||
The completed process result.
|
||||
|
||||
Raises:
|
||||
BusinessRuleViolation: If the git command fails.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise BusinessRuleViolation(
|
||||
f"Git operation failed: git {' '.join(args)}: {exc.stderr.strip()}"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise BusinessRuleViolation(
|
||||
f"Git operation timed out: git {' '.join(args)}"
|
||||
) from exc
|
||||
|
||||
def _git_changed_paths(self, sandbox_path: str, target_ref: str) -> list[str]:
|
||||
"""Compute the list of files that differ between HEAD and target ref.
|
||||
|
||||
Args:
|
||||
sandbox_path: Filesystem path to the sandbox.
|
||||
target_ref: Git commit reference to compare against.
|
||||
|
||||
Returns:
|
||||
List of relative file paths that changed.
|
||||
"""
|
||||
result = self._run_git(
|
||||
["diff", "--name-only", target_ref, "HEAD"],
|
||||
cwd=sandbox_path,
|
||||
)
|
||||
paths = [line for line in result.stdout.strip().splitlines() if line]
|
||||
# Also include untracked files that would be removed by git clean
|
||||
untracked_result = self._run_git(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
cwd=sandbox_path,
|
||||
)
|
||||
untracked = [
|
||||
line for line in untracked_result.stdout.strip().splitlines() if line
|
||||
]
|
||||
all_paths = list(dict.fromkeys(paths + untracked))
|
||||
return all_paths
|
||||
|
||||
def _git_reset_hard(self, sandbox_path: str, target_ref: str) -> None:
|
||||
"""Execute ``git reset --hard <target_ref>`` in the sandbox.
|
||||
|
||||
Enforces sandbox boundary by running the command only within the
|
||||
sandbox directory.
|
||||
|
||||
Args:
|
||||
sandbox_path: Filesystem path to the sandbox.
|
||||
target_ref: Git commit reference to reset to.
|
||||
"""
|
||||
self._run_git(["reset", "--hard", target_ref], cwd=sandbox_path)
|
||||
|
||||
def _git_clean(self, sandbox_path: str) -> None:
|
||||
"""Execute ``git clean -fd`` to remove untracked files and dirs.
|
||||
|
||||
Args:
|
||||
sandbox_path: Filesystem path to the sandbox.
|
||||
"""
|
||||
self._run_git(["clean", "-fd"], cwd=sandbox_path)
|
||||
|
||||
def _get_checkpoint(self, checkpoint_id: str) -> Checkpoint:
|
||||
"""Retrieve a checkpoint via repo or in-memory store."""
|
||||
if self._repository is not None:
|
||||
|
||||
Reference in New Issue
Block a user