"""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}" )