fix(cli): add checkpoint label, creation time, and side effects to rollback confirmation prompt #3470

Merged
freemo merged 1 commits from fix/uat-rollback-confirmation-prompt into master 2026-04-05 21:06:56 +00:00
3 changed files with 393 additions and 31 deletions
+48
View File
@@ -222,3 +222,51 @@ Feature: Plan CLI coverage round 2 remaining uncovered lines
When I invoke r2cov plan rollback without --yes and decline
Then the r2cov command should abort
And the r2cov output should contain "Rollback cancelled"
# ===================================================================
# rollback_plan — enriched confirmation prompt (issue #3443)
# ===================================================================
Scenario: Rollback confirmation prompt shows checkpoint label and creation time
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback with label "before-db-migration"
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "before-db-migration"
And the r2cov output should contain "created"
Scenario: Rollback confirmation prompt shows side effects when decisions exist
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback with 3 decisions and 1 child plan
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "invalidate 3 decisions"
And the r2cov output should contain "cancel 1 child plan"
Scenario: Rollback confirmation prompt falls back when checkpoint not found
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback where get_checkpoint raises not found
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "Rollback"
Scenario: Rollback confirmation prompt shows only decisions when no child plans
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback with 4 decisions and no child plans
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "invalidate 4 decisions"
Scenario: Rollback confirmation prompt counts parallel spawn decisions as child plans
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback with 2 parallel spawn child plans
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "cancel 2 child plans"
Scenario: Rollback confirmation prompt handles future checkpoint timestamp gracefully
Given a r2cov CLI runner
And a mocked checkpoint service for r2cov rollback with a future checkpoint timestamp
When I invoke r2cov plan rollback without --yes and accept
Then the r2cov command should exit normally
And the r2cov output should contain "just now"
+241 -28
View File
@@ -22,7 +22,7 @@ Targets remaining uncovered lines in cleveragents/cli/commands/plan.py:
from __future__ import annotations
import io
from datetime import datetime
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
@@ -41,7 +41,11 @@ from cleveragents.core.exceptions import (
ResourceNotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.checkpoint import RollbackResult
from cleveragents.domain.models.core.checkpoint import (
Checkpoint,
CheckpointMetadata,
RollbackResult,
)
from cleveragents.domain.models.core.multi_project import (
ChangeSetSummary,
MultiProjectMetadata,
@@ -489,13 +493,46 @@ def _make_rollback_result() -> RollbackResult:
)
@given("a mocked checkpoint service for r2cov rollback that succeeds")
def step_mock_rollback_success(context: Context) -> None:
def _make_checkpoint(
*,
label: str = "before-auth-refactor",
created_at: datetime | None = None,
) -> Checkpoint:
"""Create a Checkpoint domain object for use in rollback test mocks."""
return Checkpoint(
checkpoint_id=_ULID_CP,
plan_id=_ULID_A,
sandbox_ref="abc123",
created_at=created_at or datetime(2026, 4, 5, 10, 0, 0, tzinfo=UTC),
metadata=CheckpointMetadata(reason=label),
)
def _make_rollback_container(
*,
rollback_result: RollbackResult | None = None,
rollback_side_effect: Exception | None = None,
) -> MagicMock:
"""Build a mock container with checkpoint_service and decision_service wired."""
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.return_value = _make_rollback_result()
mock_cp_svc.get_checkpoint.return_value = _make_checkpoint()
if rollback_side_effect is not None:
mock_cp_svc.selective_rollback.side_effect = rollback_side_effect
else:
mock_cp_svc.selective_rollback.return_value = (
rollback_result or _make_rollback_result()
)
mock_container.checkpoint_service.return_value = mock_cp_svc
mock_decision_svc = MagicMock()
mock_decision_svc.list_decisions.return_value = []
mock_container.decision_service.return_value = mock_decision_svc
return mock_container
@given("a mocked checkpoint service for r2cov rollback that succeeds")
def step_mock_rollback_success(context: Context) -> None:
mock_container = _make_rollback_container()
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@@ -505,13 +542,9 @@ def step_mock_rollback_success(context: Context) -> None:
"a mocked checkpoint service for r2cov rollback that raises BusinessRuleViolation"
)
def step_mock_rollback_business_rule(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = BusinessRuleViolation(
"Plan is already applied"
mock_container = _make_rollback_container(
rollback_side_effect=BusinessRuleViolation("Plan is already applied")
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@@ -521,13 +554,9 @@ def step_mock_rollback_business_rule(context: Context) -> None:
"a mocked checkpoint service for r2cov rollback that raises ResourceNotFoundError"
)
def step_mock_rollback_not_found(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = ResourceNotFoundError(
"Checkpoint not found"
mock_container = _make_rollback_container(
rollback_side_effect=ResourceNotFoundError("Checkpoint not found")
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@@ -535,11 +564,9 @@ def step_mock_rollback_not_found(context: Context) -> None:
@given("a mocked checkpoint service for r2cov rollback that raises ValidationError")
def step_mock_rollback_validation(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = ValidationError("Invalid checkpoint")
mock_container.checkpoint_service.return_value = mock_cp_svc
mock_container = _make_rollback_container(
rollback_side_effect=ValidationError("Invalid checkpoint")
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@@ -547,13 +574,9 @@ def step_mock_rollback_validation(context: Context) -> None:
@given("a mocked checkpoint service for r2cov rollback that raises CleverAgentsError")
def step_mock_rollback_clever_error(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = CleverAgentsError(
"unknown rollback failure"
mock_container = _make_rollback_container(
rollback_side_effect=CleverAgentsError("unknown rollback failure")
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@@ -699,6 +722,196 @@ def step_invoke_rollback_decline(context: Context) -> None:
)
@when("I invoke r2cov plan rollback without --yes and accept")
def step_invoke_rollback_accept(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["rollback", _ULID_A, _ULID_CP],
input="y\n",
)
# ======================================================================
# Given — enriched confirmation prompt mocks (issue #3443)
# ======================================================================
@given('a mocked checkpoint service for r2cov rollback with label "{label}"')
def step_mock_rollback_with_label(context: Context, label: str) -> None:
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.return_value = (
_make_checkpoint(label=label)
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback with {decisions:d} decisions "
"and {child_plans:d} child plan"
)
def step_mock_rollback_with_decisions(
context: Context, decisions: int, child_plans: int
) -> None:
from datetime import timedelta
from ulid import ULID
from cleveragents.domain.models.core.decision import Decision, DecisionType
cp_time = datetime(2026, 4, 5, 10, 0, 0, tzinfo=UTC)
after_time = cp_time + timedelta(minutes=5)
# Build decision list: (decisions - child_plans) regular + child_plans spawns
decision_list = []
regular_count = decisions - child_plans
for i in range(regular_count):
decision_list.append(
Decision(
decision_id=str(ULID()),
plan_id=_ULID_A,
sequence_number=i,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="What to do?",
chosen_option="Do it",
created_at=after_time,
)
)
for i in range(child_plans):
decision_list.append(
Decision(
decision_id=str(ULID()),
plan_id=_ULID_A,
sequence_number=regular_count + i,
decision_type=DecisionType.SUBPLAN_SPAWN,
question="Spawn child?",
chosen_option="Yes",
created_at=after_time,
)
)
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.return_value = (
_make_checkpoint(created_at=cp_time)
)
mock_container.decision_service.return_value.list_decisions.return_value = (
decision_list
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback where get_checkpoint raises not found"
)
def step_mock_rollback_get_checkpoint_not_found(context: Context) -> None:
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.side_effect = (
ResourceNotFoundError("Checkpoint not found")
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback with {decisions:d} decisions "
"and no child plans"
)
def step_mock_rollback_with_decisions_no_child_plans(
context: Context, decisions: int
) -> None:
from datetime import timedelta
from ulid import ULID
from cleveragents.domain.models.core.decision import Decision, DecisionType
cp_time = datetime(2026, 4, 5, 10, 0, 0, tzinfo=UTC)
after_time = cp_time + timedelta(minutes=5)
decision_list = [
Decision(
decision_id=str(ULID()),
plan_id=_ULID_A,
sequence_number=i,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="What to do?",
chosen_option="Do it",
created_at=after_time,
)
for i in range(decisions)
]
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.return_value = (
_make_checkpoint(created_at=cp_time)
)
mock_container.decision_service.return_value.list_decisions.return_value = (
decision_list
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback with {child_plans:d} "
"parallel spawn child plans"
)
def step_mock_rollback_with_parallel_spawn(context: Context, child_plans: int) -> None:
from datetime import timedelta
from ulid import ULID
from cleveragents.domain.models.core.decision import Decision, DecisionType
cp_time = datetime(2026, 4, 5, 10, 0, 0, tzinfo=UTC)
after_time = cp_time + timedelta(minutes=5)
decision_list = [
Decision(
decision_id=str(ULID()),
plan_id=_ULID_A,
sequence_number=i,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
question="Spawn parallel child?",
chosen_option="Yes",
created_at=after_time,
)
for i in range(child_plans)
]
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.return_value = (
_make_checkpoint(created_at=cp_time)
)
mock_container.decision_service.return_value.list_decisions.return_value = (
decision_list
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback with a future checkpoint timestamp"
)
def step_mock_rollback_with_future_timestamp(context: Context) -> None:
from datetime import timedelta
future_time = datetime.now(UTC) + timedelta(hours=1)
mock_container = _make_rollback_container()
mock_container.checkpoint_service.return_value.get_checkpoint.return_value = (
_make_checkpoint(created_at=future_time)
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
# ======================================================================
# Then — common assertions
# ======================================================================
+104 -3
View File
@@ -122,6 +122,42 @@ def validate_namespaced_actor(value: str, flag_name: str) -> str:
return value
def _format_relative_time(dt: datetime) -> str:
"""Format a datetime as a human-readable relative time string.
Returns a string like "42 minutes ago", "2 hours ago", "3 days ago",
or "just now" for very recent timestamps.
Args:
dt: The datetime to format (timezone-aware or naive UTC).
Returns:
A human-readable relative time string.
"""
from datetime import UTC
now = datetime.now(UTC)
# Normalise naive datetimes to UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
delta = now - dt
total_seconds = int(delta.total_seconds())
# Guard against future timestamps (e.g. clock skew between CLI host and server)
if total_seconds < 0:
return "just now"
if total_seconds < 60:
return "just now"
if total_seconds < 3600:
minutes = total_seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
if total_seconds < 86400:
hours = total_seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''} ago"
days = total_seconds // 86400
return f"{days} day{'s' if days != 1 else ''} ago"
def _validate_plan_ulid(plan_id: str) -> str:
"""Validate that ``plan_id`` is a valid ULID before querying v3 storage.
@@ -3383,17 +3419,82 @@ def rollback_plan(
)
raise typer.Abort()
container = get_container()
svc = container.checkpoint_service()
if not yes:
confirm = typer.confirm(
# Default prompt (used as fallback if metadata fetch fails)
prompt_text = (
f"\nRollback plan {plan_id} to checkpoint {resolved_checkpoint_id}?"
)
# Fetch checkpoint metadata to enrich the confirmation prompt
try:
checkpoint = svc.get_checkpoint(resolved_checkpoint_id)
label = checkpoint.metadata.reason
relative_time = _format_relative_time(checkpoint.created_at)
label_display = f"'{label}'" if label else resolved_checkpoint_id
prompt_text = (
f"\nRoll back plan {plan_id} to checkpoint "
f"{label_display} (created {relative_time})?"
)
# Count decisions and child plans that would be invalidated
decisions_count = 0
child_plans_count = 0
try:
from datetime import UTC
from cleveragents.domain.models.core.decision import DecisionType
decision_svc = container.decision_service()
all_decisions = decision_svc.list_decisions(plan_id)
cp_time = checkpoint.created_at
if cp_time.tzinfo is None:
cp_time = cp_time.replace(tzinfo=UTC)
for d in all_decisions:
d_time = d.created_at
if d_time.tzinfo is None:
d_time = d_time.replace(tzinfo=UTC)
if d_time > cp_time:
decisions_count += 1
if d.decision_type in (
DecisionType.SUBPLAN_SPAWN,
DecisionType.SUBPLAN_PARALLEL_SPAWN,
):
child_plans_count += 1
except CleverAgentsError:
# Best-effort: if the decision service is unavailable, skip
# the side-effects line rather than blocking the confirmation.
pass
# Build side-effects message including only non-zero counts
side_effects_parts: list[str] = []
if decisions_count > 0:
side_effects_parts.append(
f"invalidate {decisions_count} "
f"decision{'s' if decisions_count != 1 else ''}"
)
if child_plans_count > 0:
side_effects_parts.append(
f"cancel {child_plans_count} child "
f"plan{'s' if child_plans_count != 1 else ''}"
)
if side_effects_parts:
console.print("This will " + " and ".join(side_effects_parts) + ".")
except CleverAgentsError:
# If checkpoint metadata fetch fails (e.g. ResourceNotFoundError),
# fall back to the default prompt.
pass
confirm = typer.confirm(prompt_text)
if not confirm:
console.print("[yellow]Rollback cancelled.[/yellow]")
raise typer.Abort()
try:
container = get_container()
svc = container.checkpoint_service()
t0 = time.monotonic()
result = svc.selective_rollback(plan_id, resolved_checkpoint_id)
elapsed = time.monotonic() - t0