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
335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""Helper script for config_resolution.robot end-to-end tests.
|
|
|
|
Each subcommand is a self-contained check that prints a sentinel on success.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
# 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.application.services.config_service import ( # noqa: E402
|
|
ConfigLevel,
|
|
ConfigService,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_service() -> tuple[ConfigService, Path]:
|
|
"""Create a ConfigService backed by an isolated temp directory."""
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
svc = ConfigService(
|
|
config_dir=tmpdir,
|
|
config_path=tmpdir / "config.toml",
|
|
project_root=None,
|
|
)
|
|
return svc, tmpdir
|
|
|
|
|
|
def _cleanup(tmpdir: Path) -> None:
|
|
"""Remove the temp directory ignoring errors."""
|
|
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def registry_key_count() -> None:
|
|
"""Verify that the registry contains the expected number of keys.
|
|
|
|
The count is updated as new spec-defined keys are added.
|
|
Current: 106 keys (was 105; 1 new key added in recent spec update).
|
|
"""
|
|
registry = ConfigService.registry()
|
|
count = len(registry)
|
|
if count >= 105:
|
|
print("config-registry-key-count-ok")
|
|
else:
|
|
print(f"FAIL: expected >= 105 keys, got {count}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def resolve_default() -> None:
|
|
"""Verify default resolution with no overrides."""
|
|
svc, tmpdir = _make_service()
|
|
try:
|
|
result = svc.resolve("core.log.level")
|
|
if result.value == "FATAL" and result.source == ConfigLevel.DEFAULT:
|
|
print("config-resolution-default-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected FATAL/default, got {result.value}/{result.source}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def resolve_global() -> None:
|
|
"""Verify that a TOML file value overrides the default."""
|
|
svc, tmpdir = _make_service()
|
|
try:
|
|
svc.set_value("core.log.level", "DEBUG")
|
|
result = svc.resolve("core.log.level")
|
|
if result.value == "DEBUG" and result.source == ConfigLevel.GLOBAL:
|
|
print("config-resolution-global-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected DEBUG/global, got {result.value}/{result.source}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def resolve_env() -> None:
|
|
"""Verify that env var overrides the global config value."""
|
|
svc, tmpdir = _make_service()
|
|
env_key = "CLEVERAGENTS_LOG_LEVEL"
|
|
old_val = os.environ.get(env_key)
|
|
try:
|
|
# Set a global value first so we can prove env wins
|
|
svc.set_value("core.log.level", "DEBUG")
|
|
os.environ[env_key] = "WARNING"
|
|
result = svc.resolve("core.log.level")
|
|
if result.value == "WARNING" and result.source == ConfigLevel.ENV_VAR:
|
|
print("config-resolution-env-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected WARNING/env_var, got {result.value}/{result.source}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
if old_val is None:
|
|
os.environ.pop(env_key, None)
|
|
else:
|
|
os.environ[env_key] = old_val
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def resolve_cli() -> None:
|
|
"""Verify that cli_value has the highest priority."""
|
|
svc, tmpdir = _make_service()
|
|
env_key = "CLEVERAGENTS_LOG_LEVEL"
|
|
old_val = os.environ.get(env_key)
|
|
try:
|
|
# Set all lower levels so we can prove CLI wins
|
|
svc.set_value("core.log.level", "DEBUG")
|
|
os.environ[env_key] = "WARNING"
|
|
result = svc.resolve("core.log.level", cli_value="ERROR")
|
|
if result.value == "ERROR" and result.source == ConfigLevel.CLI_FLAG:
|
|
print("config-resolution-cli-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected ERROR/cli_flag, got {result.value}/{result.source}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
if old_val is None:
|
|
os.environ.pop(env_key, None)
|
|
else:
|
|
os.environ[env_key] = old_val
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def resolve_project() -> None:
|
|
"""Verify project-scoped config resolution via project_name."""
|
|
svc, tmpdir = _make_service()
|
|
try:
|
|
# Write project-scoped override into the TOML file
|
|
import tomlkit
|
|
|
|
doc = tomlkit.document()
|
|
project_table = tomlkit.table()
|
|
myproj_table = tomlkit.table()
|
|
myproj_table["core.automation-profile"] = "full-auto"
|
|
project_table["myproject"] = myproj_table
|
|
doc["project"] = project_table
|
|
svc._config_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(svc._config_path, "w") as fh:
|
|
tomlkit.dump(doc, fh)
|
|
|
|
result = svc.resolve("core.automation-profile", project_name="myproject")
|
|
if result.value == "full-auto" and result.source == ConfigLevel.PROJECT:
|
|
print("config-resolution-project-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected full-auto/project, got {result.value}/{result.source}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def resolve_verbose_chain() -> None:
|
|
"""Verify verbose=True populates all 6 chain entries."""
|
|
svc, tmpdir = _make_service()
|
|
try:
|
|
result = svc.resolve("core.log.level", verbose=True)
|
|
chain_len = len(result.chain)
|
|
if chain_len == 6:
|
|
# Verify the sources in order
|
|
expected_sources = [
|
|
ConfigLevel.CLI_FLAG.value,
|
|
ConfigLevel.ENV_VAR.value,
|
|
ConfigLevel.LOCAL.value,
|
|
ConfigLevel.PROJECT.value,
|
|
ConfigLevel.GLOBAL.value,
|
|
ConfigLevel.DEFAULT.value,
|
|
]
|
|
actual_sources = [entry["source"] for entry in result.chain]
|
|
if actual_sources == expected_sources:
|
|
print("config-resolution-verbose-chain-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: chain sources mismatch: {actual_sources}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
print(
|
|
f"FAIL: expected 6 chain entries, got {chain_len}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
finally:
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
def env_var_provider() -> None:
|
|
"""Verify that provider.openai.api-key maps to OPENAI_API_KEY."""
|
|
env_name = ConfigService.env_var_for_key("provider.openai.api-key")
|
|
if env_name == "OPENAI_API_KEY":
|
|
print("config-env-var-provider-ok")
|
|
else:
|
|
print(
|
|
f"FAIL: expected OPENAI_API_KEY, got {env_name}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def type_coercion() -> None:
|
|
"""Verify str→int, str→float, and str→bool coercion."""
|
|
errors: list[str] = []
|
|
|
|
# str → int
|
|
result_int = ConfigService.validate_type("core.log.retention-days", "45")
|
|
if result_int != 45 or not isinstance(result_int, int):
|
|
errors.append(
|
|
f"int coercion: expected 45 (int), got {result_int!r} "
|
|
f"({type(result_int).__name__})"
|
|
)
|
|
|
|
# str → float
|
|
result_float = ConfigService.validate_type("plan.budget.per-plan", "3.14")
|
|
if result_float != 3.14 or not isinstance(result_float, float):
|
|
errors.append(
|
|
f"float coercion: expected 3.14 (float), got {result_float!r} "
|
|
f"({type(result_float).__name__})"
|
|
)
|
|
|
|
# str → bool (true)
|
|
result_bool_t = ConfigService.validate_type("core.log.file-enabled", "true")
|
|
if result_bool_t is not True:
|
|
errors.append(f"bool coercion (true): got {result_bool_t!r}")
|
|
|
|
# str → bool (false)
|
|
result_bool_f = ConfigService.validate_type("core.log.file-enabled", "false")
|
|
if result_bool_f is not False:
|
|
errors.append(f"bool coercion (false): got {result_bool_f!r}")
|
|
|
|
if errors:
|
|
for err in errors:
|
|
print(f"FAIL: {err}", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
print("config-type-coercion-ok")
|
|
|
|
|
|
def cli_list_integration() -> None:
|
|
"""Verify ``agents config list`` outputs all registered keys."""
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands import config as config_mod
|
|
from cleveragents.cli.commands.config import app as config_app
|
|
|
|
runner = CliRunner()
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
tmppath = tmpdir / "config.toml"
|
|
|
|
try:
|
|
with (
|
|
patch.object(config_mod, "_CONFIG_DIR", tmpdir),
|
|
patch.object(config_mod, "_CONFIG_PATH", tmppath),
|
|
):
|
|
result = runner.invoke(config_app, ["list"])
|
|
|
|
if result.exit_code != 0:
|
|
print(f"FAIL: config list exited {result.exit_code}", file=sys.stderr)
|
|
print(result.output, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
output = result.output
|
|
|
|
# Spot-check several representative keys from different sections
|
|
missing: list[str] = []
|
|
for key_fragment in ["log.level", "automation-profile", "openai.api-key"]:
|
|
if key_fragment not in output:
|
|
missing.append(key_fragment)
|
|
|
|
if missing:
|
|
print(
|
|
f"FAIL: missing key fragments in output: {missing}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("config-cli-list-integration-ok")
|
|
finally:
|
|
_cleanup(tmpdir)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COMMANDS: dict[str, Callable[[], None]] = {
|
|
"registry-key-count": registry_key_count,
|
|
"resolve-default": resolve_default,
|
|
"resolve-global": resolve_global,
|
|
"resolve-env": resolve_env,
|
|
"resolve-cli": resolve_cli,
|
|
"resolve-project": resolve_project,
|
|
"resolve-verbose-chain": resolve_verbose_chain,
|
|
"env-var-provider": env_var_provider,
|
|
"type-coercion": type_coercion,
|
|
"cli-list-integration": cli_list_integration,
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
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]]()
|