forked from HAL9000/cleveragents-core
38e05ac45a
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
435 lines
15 KiB
Python
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}"
|
|
)
|