Files
cleveragents-core/robot/helper_a2a_facade.py
T
freemo 7fb3fc76c8 fix(plan-lifecycle): add rollback_plan method to PlanLifecycleService
- What was implemented
  - Added PLAN_ROLLED_BACK event type to the EventType enum at src/cleveragents/infrastructure/events/types.py to properly represent successful rollbacks in the domain model.
  - Implemented rollback_plan(plan_id: str, checkpoint_id: str) -> RollbackResult in PlanLifecycleService (src/cleveragents/application/services/plan_lifecycle_service.py) with:
    - Plan state validation: rejects rollback when the plan is in terminal APPLIED or CANCELLED states.
    - Delegation to CheckpointService.selective_rollback() to perform the actual rollback logic and obtain a RollbackResult.
    - Emission of PLAN_ROLLED_BACK as a domain event to reflect the completed rollback.
    - checkpoint_service is accepted as an optional constructor parameter; if not provided, a PlanError is raised to preserve backward compatibility.
  - Updated CLI behavior in src/cleveragents/cli/commands/plan.py so agents plan rollback routes through PlanLifecycleService.rollback_plan() rather than calling CheckpointService.selective_rollback() directly.
  - Updated PlanLifecycleService module docstring to include rollback_plan in the documented API.
  - Added Behave feature file features/plan_lifecycle_rollback.feature with 11 scenarios covering state validation, domain events, and delegation.
  - Added step implementations in features/steps/plan_lifecycle_rollback_steps.py to support the new scenarios.

- Key design decisions
  - rollback_plan returns RollbackResult (the same result type produced by CheckpointService.selective_rollback) so the CLI can display rollback details consistently.
  - Terminal states APPLIED and CANCELLED are disallowed for rollback to prevent inconsistent or invalid state transitions.
  - checkpoint_service is optional in the PlanLifecycleService constructor; when omitted (None), a PlanError is raised to retain backward compatibility while signaling explicit dependency requirements.
  - CLI UI remains powered by CheckpointService for metadata enrichment (e.g., confirmation prompts), but the actual rollback action is performed via PlanLifecycleService to ensure proper domain workflow and event emission.

- Technical implications
  - All rollback logic now flows through the domain service layer (PlanLifecycleService) to preserve invariants and emit domain events, rather than allowing ad-hoc UI routes to bypass service validation.
  - The UI can still retrieve checkpoint metadata for user confirmation, but the operation that modifies state uses the new rollback_plan pathway.
  - Tests and behavior coverage were expanded via the new Behave feature and step implementations to validate state handling, events, and delegation.

- Affected modules/components
  - src/cleveragents/infrastructure/events/types.py
  - src/cleveragents/application/services/plan_lifecycle_service.py
  - src/cleveragents/cli/commands/plan.py
  - PlanLifecycleService module docstring
  - features/plan_lifecycle_rollback.feature
  - features/steps/plan_lifecycle_rollback_steps.py

ISSUES CLOSED: #3677
2026-06-03 03:22:59 -04:00

135 lines
4.1 KiB
Python

"""Helper script for a2a_facade.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.a2a.errors import ( # noqa: E402
A2aNotAvailableError,
A2aVersionMismatchError,
)
from cleveragents.a2a.events import A2aEventQueue # noqa: E402
from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402
from cleveragents.a2a.models import A2aEvent, A2aRequest # noqa: E402
from cleveragents.a2a.transport import A2aHttpTransport # noqa: E402
from cleveragents.a2a.versioning import A2aVersionNegotiator # noqa: E402
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def facade_dispatch() -> None:
"""Dispatch session.create via local facade."""
facade = A2aLocalFacade()
request = A2aRequest(method="session.create")
response = facade.dispatch(request)
if (
response.error is None
and response.result is not None
and "session_id" in response.result
):
print("a2a-facade-dispatch-ok")
else:
print(f"FAIL: unexpected response {response}", file=sys.stderr)
sys.exit(1)
def transport_stub() -> None:
"""Verify transport stub raises A2aNotAvailableError."""
transport = A2aHttpTransport()
try:
transport.connect("http://localhost:8080")
print("FAIL: should have raised", file=sys.stderr)
sys.exit(1)
except A2aNotAvailableError:
pass
if transport.is_connected() is False:
print("a2a-transport-stub-ok")
else:
print("FAIL: is_connected should be False", file=sys.stderr)
sys.exit(1)
def event_queue() -> None:
"""Verify local event queue publish/subscribe."""
queue = A2aEventQueue()
received: list[A2aEvent] = []
queue.subscribe_local(lambda e: received.append(e))
queue.publish(A2aEvent(event_type="test.event"))
if len(received) == 1 and received[0].event_type == "test.event":
print("a2a-event-queue-ok")
else:
print(f"FAIL: received={received}", file=sys.stderr)
sys.exit(1)
def version_negotiate() -> None:
"""Verify version negotiation.
The current supported A2A version is 2.0. Negotiating with the
current version must succeed; negotiating with an unsupported version
(e.g. "99.0") must raise A2aVersionMismatchError.
"""
negotiator = A2aVersionNegotiator()
current = negotiator.get_current()
result = negotiator.negotiate(current)
if result != current:
print(f"FAIL: expected {current!r}, got {result!r}", file=sys.stderr)
sys.exit(1)
try:
negotiator.negotiate("99.0")
print("FAIL: should have raised for unsupported version", file=sys.stderr)
sys.exit(1)
except A2aVersionMismatchError:
pass
print("a2a-version-negotiate-ok")
def list_operations() -> None:
"""Verify list_operations returns expected operations."""
facade = A2aLocalFacade()
ops = facade.list_operations()
expected = {"session.create", "plan.create", "plan.execute", "context.get"}
if expected.issubset(set(ops)) and len(ops) == 44:
print("a2a-list-operations-ok")
else:
print(f"FAIL: ops={ops}", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
_COMMANDS = {
"facade-dispatch": facade_dispatch,
"transport-stub": transport_stub,
"event-queue": event_queue,
"version-negotiate": version_negotiate,
"list-operations": list_operations,
}
def main() -> None:
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(2)
_COMMANDS[sys.argv[1]]()
if __name__ == "__main__":
main()