7fb3fc76c8
- 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
135 lines
4.1 KiB
Python
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()
|