Files
temp/features/steps/correction_checkpoint_rollback_steps.py
freemo 38e05ac45a feat(correction): wire checkpoint rollback into correction service revert flow
Implement the full correction-checkpoint rollback pipeline:

- Workspace snapshots: CheckpointService.create_workspace_snapshot()
  creates diff-based checkpoints before decision execution, storing
  only changed file paths in metadata.extra["diff_paths"]

- CorrectionService.revert_decisions(): new high-level entry point
  that creates a correction, computes impact, invokes checkpoint
  rollback, and archives artifacts in a single call

- Physical artifact archival: CheckpointService.archive_artifacts()
  moves files to .cleveragents/archived_artifacts/ instead of just
  flagging metadata. CorrectionService._archive_decision_artifacts()
  delegates to this during revert execution

- Selective rollback: CheckpointService.selective_rollback() wraps
  rollback_to_checkpoint with atomic semantics — captures HEAD before
  rollback and recovers on failure

- Diff-based storage: _compute_diff_snapshot() computes changed paths
  between checkpoints via git diff; snapshots store diff manifest and
  SHA-256 hash in metadata

- CLI: plan rollback now accepts --to-checkpoint <id> in addition to
  the positional checkpoint_id argument; uses selective_rollback for
  atomic execution

- DI wiring: Container now injects checkpoint_service into
  CorrectionService; CLI correct command uses container-provided
  service instead of ad-hoc instance (fixes bug #986)

- Checkpoint model: pre_decision added to allowed checkpoint_type
  values

- TDD: Removed @tdd_expected_fail from wiring test feature since the
  DI bug is now fixed

ISSUES CLOSED: #943
2026-03-30 17:06:49 -04:00

435 lines
15 KiB
Python

"""Step definitions for correction_checkpoint_rollback.feature.
Exercises the wiring of checkpoint rollback into the correction service
revert flow, including workspace snapshots, physical artifact archival,
selective rollback, diff-based storage, and atomic rollback semantics.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from pathlib import Path
from behave import given, then, when
from cleveragents.application.services.checkpoint_service import CheckpointService
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import BusinessRuleViolation, ResourceNotFoundError
# ─── Helpers ───────────────────────────────────────────────────────────
def _init_sandbox(tmpdir: str) -> str:
"""Create a bare git repo sandbox in tmpdir and return its path."""
sandbox = os.path.join(tmpdir, "sandbox")
os.makedirs(sandbox)
subprocess.run(["git", "init"], cwd=sandbox, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=sandbox,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=sandbox,
capture_output=True,
check=True,
)
# Initial commit
init_file = os.path.join(sandbox, "README.md")
with open(init_file, "w") as f:
f.write("initial\n")
subprocess.run(["git", "add", "."], cwd=sandbox, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=sandbox,
capture_output=True,
check=True,
)
return sandbox
def _get_head(sandbox: str) -> str:
"""Get current HEAD commit hash."""
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=sandbox,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
# ─── Workspace snapshot creation ──────────────────────────────────────
@given("ccr a checkpoint service")
def step_ccr_checkpoint_service(context):
context.ccr_svc = CheckpointService()
@when(
'ccr I create a workspace snapshot for plan "{plan_id}" '
'decision "{decision_id}" with sandbox_ref "{ref}"'
)
def step_ccr_create_workspace_snapshot(context, plan_id, decision_id, ref):
context.ccr_snapshot = context.ccr_svc.create_workspace_snapshot(
plan_id=plan_id,
sandbox_ref=ref,
decision_id=decision_id,
)
@then('ccr the snapshot should be created with type "{cp_type}"')
def step_ccr_snapshot_type(context, cp_type):
assert context.ccr_snapshot.checkpoint_type == cp_type
@then('ccr the snapshot should be aligned to decision "{decision_id}"')
def step_ccr_snapshot_decision(context, decision_id):
assert context.ccr_snapshot.decision_id == decision_id
@then("ccr the snapshot metadata should indicate diff-based storage")
def step_ccr_snapshot_diff_based(context):
assert context.ccr_snapshot.metadata.extra.get("diff_based") is True
@then('ccr the snapshot metadata extra should contain "diff_based" as true')
def step_ccr_snapshot_diff_based_true(context):
assert context.ccr_snapshot.metadata.extra.get("diff_based") is True
@then('ccr the snapshot metadata extra should contain a "diff_hash"')
def step_ccr_snapshot_diff_hash(context):
assert "diff_hash" in context.ccr_snapshot.metadata.extra
assert isinstance(context.ccr_snapshot.metadata.extra["diff_hash"], str)
assert len(context.ccr_snapshot.metadata.extra["diff_hash"]) > 0
# ─── CorrectionService.revert_decisions() wiring ─────────────────────
@given("ccr a correction service with checkpoint support")
def step_ccr_correction_with_checkpoint(context):
context.ccr_cp_svc = CheckpointService()
context.ccr_corr_svc = CorrectionService(
checkpoint_service=context.ccr_cp_svc,
)
@given("ccr a correction service without checkpoint support")
def step_ccr_correction_without_checkpoint(context):
context.ccr_corr_svc = CorrectionService()
@when(
'ccr I call revert_decisions for plan "{plan_id}" '
'targeting decision "{decision_id}"'
)
def step_ccr_revert_decisions(context, plan_id, decision_id):
context.ccr_result = context.ccr_corr_svc.revert_decisions(
plan_id=plan_id,
target_decision_id=decision_id,
)
@then('ccr the correction result should have status "{status}"')
def step_ccr_result_status(context, status):
assert context.ccr_result.status.value == status
@then("ccr the correction result should have reverted decisions")
def step_ccr_result_has_reverted(context):
assert len(context.ccr_result.reverted_decisions) > 0
@then("ccr the correction result checkpoint_restored should be false")
def step_ccr_result_no_checkpoint(context):
assert context.ccr_result.checkpoint_restored is False
# ─── Physical artifact archival ───────────────────────────────────────
@given("ccr a temporary sandbox directory with artifact files")
def step_ccr_sandbox_with_artifacts(context):
context.ccr_tmpdir = tempfile.mkdtemp()
sandbox = os.path.join(context.ccr_tmpdir, "sandbox")
os.makedirs(sandbox)
# Create artifact files
context.ccr_artifact_paths = ["output/result.txt", "output/log.txt"]
for rel in context.ccr_artifact_paths:
full = os.path.join(sandbox, rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(f"artifact: {rel}\n")
context.ccr_sandbox_path = sandbox
@given("ccr a temporary sandbox directory without artifact files")
def step_ccr_sandbox_without_artifacts(context):
context.ccr_tmpdir = tempfile.mkdtemp()
sandbox = os.path.join(context.ccr_tmpdir, "sandbox")
os.makedirs(sandbox)
context.ccr_artifact_paths = ["nonexistent/file.txt"]
context.ccr_sandbox_path = sandbox
@when("ccr I archive artifacts from the sandbox")
def step_ccr_archive_artifacts(context):
context.ccr_error = None
try:
context.ccr_archived = context.ccr_svc.archive_artifacts(
context.ccr_sandbox_path,
context.ccr_artifact_paths,
)
except Exception as exc:
context.ccr_error = exc
context.ccr_archived = []
@then("ccr the artifacts should exist in the archive directory")
def step_ccr_artifacts_in_archive(context):
archive_root = (
Path(context.ccr_sandbox_path) / ".cleveragents" / "archived_artifacts"
)
for rel in context.ccr_archived:
assert (archive_root / rel).exists(), f"Archived file missing: {rel}"
@then("ccr the artifacts should not exist in their original locations")
def step_ccr_artifacts_not_in_original(context):
for rel in context.ccr_archived:
original = Path(context.ccr_sandbox_path) / rel
assert not original.exists(), f"Original file still exists: {rel}"
@then("ccr no errors should be raised")
def step_ccr_no_errors(context):
assert context.ccr_error is None
@then("ccr the archived count should be {count:d}")
def step_ccr_archived_count(context, count):
assert len(context.ccr_archived) == count
# ─── Selective rollback ───────────────────────────────────────────────
@given('ccr a checkpoint service with sandbox for plan "{plan_id}"')
def step_ccr_svc_with_sandbox(context, plan_id):
context.ccr_tmpdir = tempfile.mkdtemp()
context.ccr_sandbox = _init_sandbox(context.ccr_tmpdir)
context.ccr_svc = CheckpointService()
context.ccr_svc.register_sandbox(plan_id, context.ccr_sandbox)
context.ccr_plan_id = plan_id
@given('ccr checkpoints "cp1" and "cp2" exist for plan "{plan_id}"')
def step_ccr_two_checkpoints(context, plan_id):
head1 = _get_head(context.ccr_sandbox)
context.ccr_cp1 = context.ccr_svc.create_checkpoint(
plan_id=plan_id,
sandbox_ref=head1,
reason="cp1",
)
# Make a change and commit for cp2
fpath = os.path.join(context.ccr_sandbox, "file2.txt")
with open(fpath, "w") as f:
f.write("cp2 content\n")
subprocess.run(
["git", "add", "."], cwd=context.ccr_sandbox, capture_output=True, check=True
)
subprocess.run(
["git", "commit", "-m", "cp2"],
cwd=context.ccr_sandbox,
capture_output=True,
check=True,
)
head2 = _get_head(context.ccr_sandbox)
context.ccr_cp2 = context.ccr_svc.create_checkpoint(
plan_id=plan_id,
sandbox_ref=head2,
reason="cp2",
)
@when('ccr I selectively rollback to checkpoint "cp1"')
def step_ccr_selective_rollback_cp1(context):
context.ccr_rollback_result = context.ccr_svc.selective_rollback(
context.ccr_plan_id,
context.ccr_cp1.checkpoint_id,
)
@then("ccr the rollback should succeed")
def step_ccr_rollback_success(context):
assert context.ccr_rollback_result is not None
assert context.ccr_rollback_result.restored_files_count >= 0
@when("ccr I attempt selective rollback to a nonexistent checkpoint")
def step_ccr_selective_rollback_nonexistent(context):
context.ccr_original_head = _get_head(context.ccr_sandbox)
context.ccr_rollback_error = None
try:
context.ccr_svc.selective_rollback(
context.ccr_plan_id,
"01NONEXISTENT0000000000000",
)
except (ResourceNotFoundError, BusinessRuleViolation) as exc:
context.ccr_rollback_error = exc
@then("ccr the sandbox should remain at the original HEAD")
def step_ccr_sandbox_at_original_head(context):
assert context.ccr_rollback_error is not None
current_head = _get_head(context.ccr_sandbox)
assert current_head == context.ccr_original_head
# ─── Diff-based storage ──────────────────────────────────────────────
@given('ccr an initial checkpoint exists for plan "{plan_id}"')
def step_ccr_initial_checkpoint(context, plan_id):
head = _get_head(context.ccr_sandbox)
context.ccr_initial_cp = context.ccr_svc.create_checkpoint(
plan_id=plan_id,
sandbox_ref=head,
reason="initial",
)
@given("ccr a file is modified and committed in the sandbox")
def step_ccr_modify_file(context):
fpath = os.path.join(context.ccr_sandbox, "modified.txt")
with open(fpath, "w") as f:
f.write("modified content\n")
subprocess.run(
["git", "add", "."], cwd=context.ccr_sandbox, capture_output=True, check=True
)
subprocess.run(
["git", "commit", "-m", "modify"],
cwd=context.ccr_sandbox,
capture_output=True,
check=True,
)
context.ccr_modified_file = "modified.txt"
@when("ccr I create a workspace snapshot capturing the diff")
def step_ccr_workspace_snapshot_diff(context):
head = _get_head(context.ccr_sandbox)
context.ccr_diff_snapshot = context.ccr_svc.create_workspace_snapshot(
plan_id=context.ccr_plan_id,
sandbox_ref=head,
decision_id="01DEC0000000000000000000",
)
@then("ccr the snapshot diff_paths should include the modified file")
def step_ccr_diff_paths_contain_modified(context):
diff_paths = context.ccr_diff_snapshot.metadata.extra.get("diff_paths", [])
assert context.ccr_modified_file in diff_paths, (
f"Expected {context.ccr_modified_file!r} in {diff_paths}"
)
# ─── Atomic rollback ─────────────────────────────────────────────────
@given("ccr a checkpoint is created from sandbox HEAD")
def step_ccr_checkpoint_from_head(context):
head = _get_head(context.ccr_sandbox)
context.ccr_atomic_cp = context.ccr_svc.create_checkpoint(
plan_id=context.ccr_plan_id,
sandbox_ref=head,
reason="atomic-test",
)
# Record original content
readme = os.path.join(context.ccr_sandbox, "README.md")
with open(readme) as f:
context.ccr_original_readme = f.read()
@when("ccr I perform selective rollback to the checkpoint")
def step_ccr_selective_rollback_atomic(context):
context.ccr_rollback_result = context.ccr_svc.selective_rollback(
context.ccr_plan_id,
context.ccr_atomic_cp.checkpoint_id,
)
@then("ccr the modified file should be reverted to its original state")
def step_ccr_file_reverted(context):
readme = os.path.join(context.ccr_sandbox, "README.md")
with open(readme) as f:
content = f.read()
assert content == context.ccr_original_readme
# ─── DI container wiring ─────────────────────────────────────────────
@given("ccr a fresh DI container")
def step_ccr_fresh_container(context):
# Container will be resolved in the when step
pass
@when("ccr I resolve the correction_service from the container")
def step_ccr_resolve_correction_service(context):
from cleveragents.application.container import get_container
container = get_container()
context.ccr_container_correction_svc = container.correction_service()
@then("ccr the correction_service should have a non-None checkpoint_service")
def step_ccr_correction_has_checkpoint(context):
svc = context.ccr_container_correction_svc
assert svc._checkpoint_service is not None, (
"CorrectionService._checkpoint_service should be wired by the DI container"
)
# ─── CLI rollback --to-checkpoint ─────────────────────────────────────
@given("ccr a CLI runner")
def step_ccr_cli_runner(context):
from typer.testing import CliRunner
context.ccr_cli_runner = CliRunner()
@when("ccr I invoke plan rollback with --to-checkpoint option")
def step_ccr_cli_rollback_to_checkpoint(context):
from cleveragents.cli.commands.plan import app as plan_app
result = context.ccr_cli_runner.invoke(
plan_app,
["rollback", "PLAN-DUMMY", "--to-checkpoint", "CP-DUMMY", "--yes"],
)
context.ccr_cli_result = result
@then("ccr the command should recognize the --to-checkpoint flag")
def step_ccr_cli_to_checkpoint_flag(context):
result = context.ccr_cli_result
# Typer/Click returns exit code 2 with "No such option" for unrecognized flags.
# Any other exit code means the flag was accepted by the CLI parser.
assert "No such option" not in result.output, (
f"--to-checkpoint was not recognized: {result.output}"
)