Files
temp/robot/helper_devcontainer_handler.py
T
freemo a321fb3b37 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-04-06 13:15:57 +00:00

305 lines
11 KiB
Python

"""Helper utilities for devcontainer handler Robot smoke tests.
Each command prints a single ``<command>-ok`` token on success so the
calling Robot test can assert on ``stdout``.
"""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
from typing import Any
from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.discovery import (
DevcontainerDiscoveryResult,
discover_devcontainers,
)
from cleveragents.resource.handlers.protocol import ResourceHandler
from cleveragents.resource.handlers.resolver import (
clear_handler_cache,
resolve_handler,
)
_VALID_DC_JSON: str = json.dumps(
{"name": "test-devcontainer", "image": "ubuntu:latest"}
)
# -- Built-in type definitions for DAG checks --------------------------------
_BUILTIN_TYPES: list[dict[str, Any]] = [
{
"name": "git-checkout",
"description": "Git checkout",
"resource_kind": "physical",
"sandbox_strategy": "git_worktree",
"built_in": True,
"child_types": [
"fs-directory",
"fs-file",
"devcontainer-instance",
"devcontainer-file",
],
},
{
"name": "devcontainer-instance",
"description": "Devcontainer instance",
"resource_kind": "physical",
"sandbox_strategy": "snapshot",
"built_in": True,
"parent_types": ["git-checkout", "fs-directory"],
"child_types": ["devcontainer-file"],
},
{
"name": "devcontainer-file",
"description": "Devcontainer config file",
"resource_kind": "physical",
"sandbox_strategy": "copy_on_write",
"built_in": True,
"parent_types": ["devcontainer-instance"],
"child_types": [],
},
]
def cmd_protocol_check() -> None:
"""Verify DevcontainerHandler satisfies ResourceHandler."""
handler = DevcontainerHandler()
assert isinstance(handler, ResourceHandler), "Protocol check failed"
print("protocol-check-ok")
def cmd_strategy_check() -> None:
"""Verify DevcontainerHandler default strategy.
F22/F25 fix: handler uses ``none`` because SandboxFactory has not yet
implemented ``snapshot``. The container itself provides isolation.
Known limitation — will switch to ``snapshot`` once implemented.
"""
handler = DevcontainerHandler()
assert handler._default_strategy.value == "none"
print("strategy-check-ok")
def cmd_discovery_valid() -> None:
"""Discover devcontainer from valid config."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 1, f"Expected 1 result, got {len(results)}"
assert str(results[0].config_path).endswith("devcontainer.json")
print("discovery-valid-ok")
def cmd_discovery_invalid() -> None:
"""Skip invalid devcontainer.json."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text("not valid json {{", encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 0, f"Expected 0 results, got {len(results)}"
print("discovery-invalid-ok")
def cmd_discovery_root() -> None:
"""Discover root-level .devcontainer.json."""
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "fs-directory")
assert len(results) == 1, f"Expected 1 result, got {len(results)}"
print("discovery-root-ok")
def cmd_discovery_nontrigger() -> None:
"""No discovery for non-trigger types."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "fs-file")
assert len(results) == 0, f"Expected 0 results, got {len(results)}"
print("discovery-nontrigger-ok")
def cmd_builtin_types() -> None:
"""Verify devcontainer types in BUILTIN_NAMES."""
names = ResourceTypeSpec.BUILTIN_NAMES
assert "devcontainer-instance" in names
assert "devcontainer-file" in names
assert "container-instance" in names
print("builtin-types-ok")
def cmd_dag_hierarchy() -> None:
"""Validate devcontainer DAG parent/child constraints."""
for type_def in _BUILTIN_TYPES:
name = type_def["name"]
if name == "devcontainer-instance":
assert "git-checkout" in type_def["parent_types"]
assert "fs-directory" in type_def["parent_types"]
assert "devcontainer-file" in type_def["child_types"]
elif name == "devcontainer-file":
assert "devcontainer-instance" in type_def["parent_types"]
assert len(type_def["child_types"]) == 0
elif name == "git-checkout":
assert "devcontainer-instance" in type_def["child_types"]
print("dag-hierarchy-ok")
def cmd_resolver_import() -> None:
"""Verify resolve_handler loads DevcontainerHandler."""
clear_handler_cache()
handler = resolve_handler(
"cleveragents.resource.handlers.devcontainer:DevcontainerHandler"
)
assert isinstance(handler, ResourceHandler)
assert isinstance(handler, DevcontainerHandler)
print("resolver-import-ok")
def cmd_result_validation() -> None:
"""Verify DevcontainerDiscoveryResult validates inputs."""
with tempfile.TemporaryDirectory() as tmp:
dc_file = Path(tmp) / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
# Valid creation
result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=tmp,
)
assert result.config_path == dc_file
# Empty parent_location should raise
try:
DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location="",
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
print("result-validation-ok")
def cmd_discovery_named_single() -> None:
"""Discover a single named devcontainer configuration."""
with tempfile.TemporaryDirectory() as tmp:
named_dir = Path(tmp) / ".devcontainer" / "api"
named_dir.mkdir(parents=True)
(named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 1, f"Expected 1 result, got {len(results)}"
assert results[0].config_name == "api", (
f"Expected config_name='api', got {results[0].config_name!r}"
)
print("discovery-named-single-ok")
def cmd_discovery_named_multiple() -> None:
"""Discover multiple named devcontainer configurations."""
with tempfile.TemporaryDirectory() as tmp:
for name in ["api", "frontend"]:
d = Path(tmp) / ".devcontainer" / name
d.mkdir(parents=True)
(d / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 2, f"Expected 2 results, got {len(results)}"
names = {r.config_name for r in results}
assert names == {"api", "frontend"}, (
f"Expected {{'api', 'frontend'}}, got {names!r}"
)
print("discovery-named-multiple-ok")
def cmd_discovery_named_mixed() -> None:
"""Discover mixed root and named devcontainer configurations."""
with tempfile.TemporaryDirectory() as tmp:
dc_dir = Path(tmp) / ".devcontainer"
dc_dir.mkdir()
(dc_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
named_dir = dc_dir / "api"
named_dir.mkdir()
(named_dir / "devcontainer.json").write_text(_VALID_DC_JSON, encoding="utf-8")
results = discover_devcontainers(tmp, "git-checkout")
assert len(results) == 2, f"Expected 2 results, got {len(results)}"
root_results = [r for r in results if r.config_name is None]
named_results = [r for r in results if r.config_name == "api"]
assert len(root_results) == 1, "Expected 1 root result"
assert len(named_results) == 1, "Expected 1 named result"
print("discovery-named-mixed-ok")
def cmd_discovery_named_config_name_attr() -> None:
"""Verify config_name attribute on DevcontainerDiscoveryResult."""
with tempfile.TemporaryDirectory() as tmp:
# Named config has config_name set
named_dir = Path(tmp) / ".devcontainer" / "backend"
named_dir.mkdir(parents=True)
dc_file = named_dir / "devcontainer.json"
dc_file.write_text(_VALID_DC_JSON, encoding="utf-8")
result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=tmp,
config_name="backend",
)
assert result.config_name == "backend", (
f"Expected 'backend', got {result.config_name!r}"
)
# Root config has config_name=None
root_result = DevcontainerDiscoveryResult(
config_path=dc_file,
config_data=json.loads(_VALID_DC_JSON),
parent_location=tmp,
config_name=None,
)
assert root_result.config_name is None, (
f"Expected None, got {root_result.config_name!r}"
)
print("discovery-named-config-name-attr-ok")
_COMMANDS = {
"protocol-check": cmd_protocol_check,
"strategy-check": cmd_strategy_check,
"discovery-valid": cmd_discovery_valid,
"discovery-invalid": cmd_discovery_invalid,
"discovery-root": cmd_discovery_root,
"discovery-nontrigger": cmd_discovery_nontrigger,
"builtin-types": cmd_builtin_types,
"dag-hierarchy": cmd_dag_hierarchy,
"resolver-import": cmd_resolver_import,
"result-validation": cmd_result_validation,
"discovery-named-single": cmd_discovery_named_single,
"discovery-named-multiple": cmd_discovery_named_multiple,
"discovery-named-mixed": cmd_discovery_named_mixed,
"discovery-named-config-name-attr": cmd_discovery_named_config_name_attr,
}
def main() -> None:
"""Dispatch subcommand from argv."""
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
func = _COMMANDS.get(cmd)
if func is None:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
func()
if __name__ == "__main__":
main()