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
146 lines
4.5 KiB
Python
146 lines
4.5 KiB
Python
"""Helper script for actor CLI show output fields Robot test."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands.actor import app as actor_app
|
|
from cleveragents.domain.models.core.actor import Actor
|
|
|
|
|
|
def _make_actor(
|
|
*,
|
|
name: str = "local/robot-actor",
|
|
provider: str = "openai",
|
|
model: str = "gpt-4",
|
|
config: dict[str, Any] | None = None,
|
|
unsafe: bool = False,
|
|
is_default: bool = False,
|
|
is_built_in: bool = False,
|
|
) -> Actor:
|
|
blob = config or {"provider": provider, "model": model}
|
|
return Actor(
|
|
id=1,
|
|
name=name,
|
|
provider=provider,
|
|
model=model,
|
|
config_blob=blob,
|
|
config_hash=Actor.compute_hash(blob),
|
|
unsafe=unsafe,
|
|
is_built_in=is_built_in,
|
|
is_default=is_default,
|
|
)
|
|
|
|
|
|
def test_show_json_fields() -> None:
|
|
"""Verify show --format json contains expected fields.
|
|
|
|
The CLI wraps output in a spec-required envelope:
|
|
{"command": ..., "status": ..., "exit_code": ..., "data": {...}, ...}
|
|
Actor data lives under the "data" key.
|
|
"""
|
|
runner = CliRunner()
|
|
actor = _make_actor()
|
|
|
|
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
|
|
registry = MagicMock()
|
|
registry.get_actor.return_value = actor
|
|
mock_svc.return_value = (MagicMock(), registry)
|
|
result = runner.invoke(actor_app, ["show", actor.name, "--format", "json"])
|
|
|
|
assert result.exit_code == 0, (
|
|
f"exit_code={result.exit_code}, output={result.output}"
|
|
)
|
|
envelope = json.loads(result.output.strip())
|
|
# Output is wrapped in the spec-required envelope; actor data is under "data"
|
|
data = envelope.get("data", envelope)
|
|
|
|
required_fields = [
|
|
"name",
|
|
"provider",
|
|
"model",
|
|
"unsafe",
|
|
"is_default",
|
|
"is_built_in",
|
|
"config_hash",
|
|
"schema_version",
|
|
"updated_at",
|
|
]
|
|
missing = [f for f in required_fields if f not in data]
|
|
assert not missing, f"Missing fields: {missing}"
|
|
assert data["name"] == "local/robot-actor"
|
|
assert data["provider"] == "openai"
|
|
assert data["model"] == "gpt-4"
|
|
print("actor-cli-show-json-fields-ok")
|
|
|
|
|
|
def test_show_yaml_output() -> None:
|
|
"""Verify show --format yaml produces valid YAML."""
|
|
runner = CliRunner()
|
|
actor = _make_actor()
|
|
|
|
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
|
|
registry = MagicMock()
|
|
registry.get_actor.return_value = actor
|
|
mock_svc.return_value = (MagicMock(), registry)
|
|
result = runner.invoke(actor_app, ["show", actor.name, "--format", "yaml"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "name:" in result.output
|
|
assert "local/robot-actor" in result.output
|
|
print("actor-cli-show-yaml-output-ok")
|
|
|
|
|
|
def test_list_json_format() -> None:
|
|
"""Verify list --format json returns array of actors.
|
|
|
|
The CLI wraps output in a spec-required envelope:
|
|
{"command": ..., "status": ..., "exit_code": ..., "data": [...], ...}
|
|
The actors array lives under the "data" key.
|
|
"""
|
|
runner = CliRunner()
|
|
actors = [
|
|
_make_actor(name="local/a1", provider="p1", model="m1"),
|
|
_make_actor(name="local/a2", provider="p2", model="m2"),
|
|
]
|
|
|
|
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
|
|
registry = MagicMock()
|
|
registry.list_actors.return_value = actors
|
|
mock_svc.return_value = (MagicMock(), registry)
|
|
result = runner.invoke(actor_app, ["list", "--format", "json"])
|
|
|
|
assert result.exit_code == 0
|
|
envelope = json.loads(result.output.strip())
|
|
# Output is wrapped in the spec-required envelope; actors array is under "data"
|
|
data = envelope.get("data", envelope)
|
|
assert isinstance(data, list), (
|
|
f"Expected list for actor data, got {type(data).__name__}. "
|
|
f"Output: {result.output[:300]}"
|
|
)
|
|
assert len(data) == 2
|
|
print("actor-cli-list-json-format-ok")
|
|
|
|
|
|
def main() -> None:
|
|
command = sys.argv[1] if len(sys.argv) > 1 else "show-json-fields"
|
|
dispatch = {
|
|
"show-json-fields": test_show_json_fields,
|
|
"show-yaml-output": test_show_yaml_output,
|
|
"list-json-format": test_list_json_format,
|
|
}
|
|
fn = dispatch.get(command)
|
|
if fn is None:
|
|
print(f"Unknown command: {command}", file=sys.stderr)
|
|
sys.exit(1)
|
|
fn()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|