feat: Jeff Day 23 combined — config service, phase reversion, CLI extensions #381
@@ -0,0 +1,255 @@
|
||||
"""ASV benchmarks for CLI extensions parsing overhead.
|
||||
|
||||
Measures the performance of:
|
||||
- plan use with automation profile and invariant flags
|
||||
- plan use with actor override flags and validation
|
||||
- plan status / lifecycle-list rendering with extended fields
|
||||
- action show rendering with optional actors/invariants/inputs_schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.cli.commands.action import app as action_app # noqa: E402
|
||||
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
|
||||
from cleveragents.domain.models.core.action import ( # noqa: E402
|
||||
Action,
|
||||
ActionState,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import ( # noqa: E402
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
InvariantSource,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
_runner = CliRunner()
|
||||
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
|
||||
|
||||
|
||||
def _mock_action(name: str = "local/bench-action") -> Action:
|
||||
return Action(
|
||||
namespaced_name=NamespacedName.parse(name),
|
||||
description="Benchmark action",
|
||||
definition_of_done="Benchmarks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
estimation_actor="openai/gpt-4",
|
||||
invariant_actor="anthropic/claude-3",
|
||||
invariants=["No regressions", "Keep backward compat"],
|
||||
inputs_schema={
|
||||
"type": "object",
|
||||
"properties": {"coverage": {"type": "integer"}},
|
||||
},
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def _mock_plan(
|
||||
name: str = "local/bench-plan",
|
||||
*,
|
||||
with_profile: bool = False,
|
||||
with_invariants: bool = False,
|
||||
) -> Plan:
|
||||
now = datetime.now()
|
||||
profile = None
|
||||
if with_profile:
|
||||
profile = AutomationProfileRef(
|
||||
profile_name="trusted",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
invariants: list[PlanInvariant] = []
|
||||
if with_invariants:
|
||||
invariants = [
|
||||
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
|
||||
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
|
||||
]
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ULID),
|
||||
namespaced_name=NamespacedName.parse(name),
|
||||
description="Benchmark plan",
|
||||
definition_of_done="Benchmarks pass",
|
||||
action_name="local/bench-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
project_links=[ProjectLink(project_name="my-project")],
|
||||
arguments={},
|
||||
arguments_order=[],
|
||||
automation_profile=profile,
|
||||
invariants=invariants,
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
)
|
||||
|
||||
|
||||
class PlanUseWithProfileSuite:
|
||||
"""Benchmark plan use with --automation-profile."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.get_action_by_name.return_value = _mock_action()
|
||||
self._mock_service.use_action.return_value = _mock_plan(with_profile=True)
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_plan_use_with_profile(self) -> None:
|
||||
"""Benchmark plan use with automation profile."""
|
||||
_runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/bench-action",
|
||||
"--automation-profile",
|
||||
"trusted",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class PlanUseWithInvariantsSuite:
|
||||
"""Benchmark plan use with --invariant flags."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.get_action_by_name.return_value = _mock_action()
|
||||
self._mock_service.use_action.return_value = _mock_plan(with_invariants=True)
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_plan_use_with_invariants(self) -> None:
|
||||
"""Benchmark plan use with invariant flags."""
|
||||
_runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/bench-action",
|
||||
"--invariant",
|
||||
"No warnings",
|
||||
"--invariant",
|
||||
"Keep compat",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class PlanUseActorValidationSuite:
|
||||
"""Benchmark plan use with actor override flags + validation."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.get_action_by_name.return_value = _mock_action()
|
||||
self._mock_service.use_action.return_value = _mock_plan()
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_plan_use_with_actor_overrides(self) -> None:
|
||||
"""Benchmark plan use with all actor overrides."""
|
||||
_runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/bench-action",
|
||||
"--strategy-actor",
|
||||
"openai/gpt-4",
|
||||
"--execution-actor",
|
||||
"anthropic/claude-3",
|
||||
"--estimation-actor",
|
||||
"openai/gpt-4",
|
||||
"--invariant-actor",
|
||||
"anthropic/claude-3",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class PlanStatusWithExtendedFieldsSuite:
|
||||
"""Benchmark plan status with automation profile + invariants."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.list_plans.return_value = [
|
||||
_mock_plan(f"local/plan-{i}", with_profile=True, with_invariants=True)
|
||||
for i in range(20)
|
||||
]
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_plan_status_table(self) -> None:
|
||||
"""Benchmark plan status table rendering with extended fields."""
|
||||
_runner.invoke(plan_app, ["status"])
|
||||
|
||||
def time_plan_lifecycle_list_table(self) -> None:
|
||||
"""Benchmark lifecycle-list table rendering with extended fields."""
|
||||
_runner.invoke(plan_app, ["lifecycle-list"])
|
||||
|
||||
|
||||
class ActionShowExtendedSuite:
|
||||
"""Benchmark action show with optional actors/invariants/schema."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._mock_service = MagicMock()
|
||||
self._mock_service.get_action_by_name.return_value = _mock_action()
|
||||
self._patcher = patch(
|
||||
"cleveragents.cli.commands.action._get_lifecycle_service",
|
||||
return_value=self._mock_service,
|
||||
)
|
||||
self._patcher.start()
|
||||
|
||||
def teardown(self) -> None:
|
||||
self._patcher.stop()
|
||||
|
||||
def time_action_show_rich(self) -> None:
|
||||
"""Benchmark action show in rich format."""
|
||||
_runner.invoke(action_app, ["show", "local/bench-action"])
|
||||
|
||||
def time_action_show_json(self) -> None:
|
||||
"""Benchmark action show in JSON format."""
|
||||
_runner.invoke(action_app, ["show", "local/bench-action", "--format", "json"])
|
||||
@@ -0,0 +1,151 @@
|
||||
"""ASV benchmarks for Config Service resolution chain performance.
|
||||
|
||||
Measures the performance of:
|
||||
- Single key resolution (default, global, env var, CLI flag)
|
||||
- Full registry resolution (resolve_all)
|
||||
- Key validation and type coercion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.config_service import ( # noqa: E402
|
||||
ConfigService,
|
||||
)
|
||||
|
||||
|
||||
class ConfigResolutionDefaultSuite:
|
||||
"""Benchmark default resolution (no overrides)."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_default(self) -> None:
|
||||
"""Benchmark resolving a single key from defaults."""
|
||||
self._service.resolve("core.log_level")
|
||||
|
||||
def time_resolve_default_verbose(self) -> None:
|
||||
"""Benchmark resolving a single key with verbose chain."""
|
||||
self._service.resolve("core.log_level", verbose=True)
|
||||
|
||||
|
||||
class ConfigResolutionGlobalSuite:
|
||||
"""Benchmark resolution from global config file."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
self._service.set_value("core.log_level", "DEBUG")
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_global(self) -> None:
|
||||
"""Benchmark resolving from global config file."""
|
||||
self._service.resolve("core.log_level")
|
||||
|
||||
|
||||
class ConfigResolutionEnvSuite:
|
||||
"""Benchmark resolution from env var."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
os.environ["CLEVERAGENTS_CORE_LOG_LEVEL"] = "WARNING"
|
||||
|
||||
def teardown(self) -> None:
|
||||
os.environ.pop("CLEVERAGENTS_CORE_LOG_LEVEL", None)
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_env(self) -> None:
|
||||
"""Benchmark resolving from environment variable."""
|
||||
self._service.resolve("core.log_level")
|
||||
|
||||
|
||||
class ConfigResolutionCLISuite:
|
||||
"""Benchmark resolution with CLI flag."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_cli(self) -> None:
|
||||
"""Benchmark resolving with CLI flag (highest priority)."""
|
||||
self._service.resolve("core.log_level", cli_value="ERROR")
|
||||
|
||||
|
||||
class ConfigResolutionBulkSuite:
|
||||
"""Benchmark bulk operations."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
self._service = ConfigService(
|
||||
config_dir=self._tmpdir,
|
||||
config_path=self._tmpdir / "config.toml",
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
shutil.rmtree(str(self._tmpdir), ignore_errors=True)
|
||||
|
||||
def time_resolve_all(self) -> None:
|
||||
"""Benchmark resolving all registered keys."""
|
||||
self._service.resolve_all()
|
||||
|
||||
def time_registry_keys(self) -> None:
|
||||
"""Benchmark listing all registered keys."""
|
||||
ConfigService.registered_keys()
|
||||
|
||||
|
||||
class ConfigValidationSuite:
|
||||
"""Benchmark key validation and type coercion."""
|
||||
|
||||
def time_validate_known_key(self) -> None:
|
||||
"""Benchmark validating a known key."""
|
||||
ConfigService.validate_key("core.log_level")
|
||||
|
||||
def time_validate_type_str(self) -> None:
|
||||
"""Benchmark type coercion for string."""
|
||||
ConfigService.validate_type("core.log_level", "DEBUG")
|
||||
|
||||
def time_validate_type_int(self) -> None:
|
||||
"""Benchmark type coercion for integer."""
|
||||
ConfigService.validate_type("core.server_port", "8080")
|
||||
|
||||
def time_validate_type_bool(self) -> None:
|
||||
"""Benchmark type coercion for boolean."""
|
||||
ConfigService.validate_type("core.debug_enabled", "true")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""ASV benchmarks for phase reversion state machine.
|
||||
|
||||
Measures the performance of:
|
||||
- Plan.can_revert_to() validation
|
||||
- PlanLifecycleService.revert_plan() overhead
|
||||
- PlanLifecycleService.try_auto_revert_from_apply() overhead
|
||||
- Decision recording during reversion
|
||||
- Reversion loop guard check overhead
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
)
|
||||
|
||||
|
||||
def _make_service() -> PlanLifecycleService:
|
||||
return PlanLifecycleService(settings=Settings())
|
||||
|
||||
|
||||
def _create_plan_in_execute(
|
||||
service: PlanLifecycleService,
|
||||
action_name: str = "local/bench-action",
|
||||
profile: str = "manual",
|
||||
) -> str:
|
||||
service.create_action(
|
||||
name=action_name,
|
||||
description="Benchmark action",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
)
|
||||
plan = service.use_action(action_name)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name=profile,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
plan_id = plan.identity.plan_id
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
return plan_id
|
||||
|
||||
|
||||
class CanRevertToSuite:
|
||||
"""Benchmark Plan.can_revert_to() validation."""
|
||||
|
||||
def setup(self) -> None:
|
||||
service = _make_service()
|
||||
plan_id = _create_plan_in_execute(service, "local/bench-can-revert")
|
||||
self.plan = service.get_plan(plan_id)
|
||||
|
||||
def time_can_revert_to_valid(self) -> None:
|
||||
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
def time_can_revert_to_invalid(self) -> None:
|
||||
self.plan.can_revert_to(PlanPhase.APPLY)
|
||||
|
||||
|
||||
class RevertPlanSuite:
|
||||
"""Benchmark PlanLifecycleService.revert_plan() overhead."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = _make_service()
|
||||
self.counter = 0
|
||||
|
||||
def time_revert_plan(self) -> None:
|
||||
self.counter += 1
|
||||
name = f"local/bench-revert-{self.counter}"
|
||||
plan_id = _create_plan_in_execute(self.service, name)
|
||||
self.service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="benchmark")
|
||||
|
||||
|
||||
class AutoRevertSuite:
|
||||
"""Benchmark auto-reversion from constrained apply."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = _make_service()
|
||||
self.counter = 0
|
||||
|
||||
def time_auto_revert_from_apply(self) -> None:
|
||||
self.counter += 1
|
||||
name = f"local/bench-auto-{self.counter}"
|
||||
self.service.create_action(
|
||||
name=name,
|
||||
description="Benchmark action",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
)
|
||||
plan = self.service.use_action(name)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name="ci",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
plan_id = plan.identity.plan_id
|
||||
self.service.start_strategize(plan_id)
|
||||
self.service.complete_strategize(plan_id)
|
||||
self.service.execute_plan(plan_id)
|
||||
self.service.start_execute(plan_id)
|
||||
self.service.complete_execute(plan_id)
|
||||
self.service.apply_plan(plan_id)
|
||||
self.service.start_apply(plan_id)
|
||||
plan = self.service.get_plan(plan_id)
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
self.service.try_auto_revert_from_apply(plan_id, "benchmark")
|
||||
|
||||
|
||||
class LoopGuardSuite:
|
||||
"""Benchmark loop guard check overhead."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = _make_service()
|
||||
plan_id = _create_plan_in_execute(self.service, "local/bench-guard")
|
||||
self.plan = self.service.get_plan(plan_id)
|
||||
|
||||
def time_loop_guard_under_limit(self) -> None:
|
||||
self.plan.reversion_count = 2
|
||||
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
def time_loop_guard_at_limit(self) -> None:
|
||||
self.plan.reversion_count = 3
|
||||
self.plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
@@ -0,0 +1,141 @@
|
||||
# Action CLI Reference
|
||||
|
||||
The `agents action` command group manages actions (reusable plan templates)
|
||||
in the CleverAgents v3 plan lifecycle.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------------------|------------------------------------------|
|
||||
| `agents action create` | Create action from YAML config file |
|
||||
| `agents action list` | List actions with optional filters |
|
||||
| `agents action show` | Show full action details |
|
||||
| `agents action archive` | Soft-delete (transition to `archived`) |
|
||||
|
||||
## `agents action create`
|
||||
|
||||
Create a new action from a YAML configuration file.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents action create --config <FILE>
|
||||
```
|
||||
|
||||
Actions are created **exclusively** via YAML configuration files. Legacy
|
||||
inline flags (`--name`, `--strategy-actor`, etc.) are not supported.
|
||||
|
||||
### YAML Configuration File
|
||||
|
||||
```yaml
|
||||
name: local/code-coverage
|
||||
description: Increase code coverage
|
||||
strategy_actor: openai/gpt-4
|
||||
execution_actor: openai/gpt-4
|
||||
definition_of_done: |
|
||||
Line coverage reaches the target percentage
|
||||
for all modules under src/.
|
||||
estimation_actor: openai/gpt-4 # optional
|
||||
invariant_actor: anthropic/claude-3 # optional
|
||||
automation_profile: trusted # optional
|
||||
invariants: # optional
|
||||
- "No reduction in existing coverage"
|
||||
- "All new code must have tests"
|
||||
inputs_schema: # optional JSON Schema
|
||||
type: object
|
||||
properties:
|
||||
target_coverage:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
arguments: # optional
|
||||
- name: target_coverage
|
||||
type: integer
|
||||
required: true
|
||||
description: Target coverage percentage
|
||||
```
|
||||
|
||||
## `agents action list`
|
||||
|
||||
List all actions with optional filtering.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents action list [REGEX] [OPTIONS]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description |
|
||||
|---------------------|------------------------------------------|
|
||||
| `--namespace`, `-n` | Filter by namespace |
|
||||
| `--state`, `-s` | Filter by state (`available`, `archived`)|
|
||||
| `--format`, `-f` | Output format |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
agents action list
|
||||
agents action list --namespace local
|
||||
agents action list --state available
|
||||
agents action list "code-.*"
|
||||
agents action list --format json
|
||||
```
|
||||
|
||||
## `agents action show`
|
||||
|
||||
Show details for an action including all extended fields.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents action show <NAMESPACED_NAME> [--format FORMAT]
|
||||
```
|
||||
|
||||
### Output Fields
|
||||
|
||||
The show command displays all action fields:
|
||||
|
||||
- **Namespaced Name**: Full `namespace/name` identifier
|
||||
- **Short Name**: Name portion only
|
||||
- **Description**: Action description
|
||||
- **State**: `available` or `archived`
|
||||
- **Strategy Actor**: Actor for Strategize phase
|
||||
- **Execution Actor**: Actor for Execute phase
|
||||
- **Estimation Actor**: Optional estimation actor (shown when set)
|
||||
- **Invariant Actor**: Optional invariant reconciliation actor (shown when set)
|
||||
- **Reusable**: Whether action stays available after use
|
||||
- **Read Only**: Whether action only reads
|
||||
- **Definition of Done**: Completion criteria
|
||||
- **Arguments**: Typed argument definitions
|
||||
- **Invariants**: Constraint list (shown when present)
|
||||
- **Inputs Schema**: JSON Schema dict (shown when present)
|
||||
- **Automation Profile**: Default profile name (shown when set)
|
||||
- **Created**: Creation timestamp
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
agents action show local/code-coverage
|
||||
agents action show local/code-coverage --format json
|
||||
agents action show local/code-coverage --format yaml
|
||||
```
|
||||
|
||||
## `agents action archive`
|
||||
|
||||
Archive an action (soft delete). Archived actions cannot be used but are
|
||||
preserved for history.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents action archive <NAMESPACED_NAME> [--format FORMAT]
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
agents action archive local/old-action
|
||||
agents action archive local/old-action --format json
|
||||
```
|
||||
@@ -0,0 +1,151 @@
|
||||
# Configuration Resolution Chain
|
||||
|
||||
CleverAgents uses a multi-level configuration resolution chain that determines
|
||||
the effective value for each configuration key. This document describes the
|
||||
resolution order, all registered configuration keys, their types, defaults,
|
||||
and environment variable mappings.
|
||||
|
||||
## Resolution Order
|
||||
|
||||
Values are resolved from **highest to lowest** priority. The first level that
|
||||
provides a non-`None` value wins:
|
||||
|
||||
| Priority | Level | Description |
|
||||
|----------|------------------|------------------------------------------------------|
|
||||
| 1 | **CLI flag** | Value passed explicitly via `--key=value` on the CLI |
|
||||
| 2 | **Env var** | Environment variable (`CLEVERAGENTS_<SECTION>_<KEY>`) |
|
||||
| 3 | **Project scope**| Per-project override in `[project."<name>"]` TOML table |
|
||||
| 4 | **Global config**| Value in `~/.cleveragents/config.toml` |
|
||||
| 5 | **Default** | Built-in default from the key registry |
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
$ export CLEVERAGENTS_CORE_LOG_LEVEL=WARNING
|
||||
$ agents config set core.log_level DEBUG # writes to global config
|
||||
$ agents config get core.log_level
|
||||
# -> WARNING (env var wins over global config)
|
||||
```
|
||||
|
||||
## Environment Variable Convention
|
||||
|
||||
All registered keys map to environment variables following the pattern:
|
||||
|
||||
```
|
||||
CLEVERAGENTS_<SECTION>_<KEY>
|
||||
```
|
||||
|
||||
Where `<SECTION>` and `<KEY>` are uppercased. For example:
|
||||
|
||||
- `core.log_level` -> `CLEVERAGENTS_CORE_LOG_LEVEL`
|
||||
- `plan.max_retries` -> `CLEVERAGENTS_PLAN_MAX_RETRIES`
|
||||
- `provider.temperature` -> `CLEVERAGENTS_PROVIDER_TEMPERATURE`
|
||||
|
||||
## Configuration Keys
|
||||
|
||||
### `core.*` — Core Runtime
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|----------------------|--------|------------------------------|------------------------------------|-------------------|-------------------------------|
|
||||
| `core.log_level` | `str` | `INFO` | `CLEVERAGENTS_CORE_LOG_LEVEL` | Yes | Logging verbosity level |
|
||||
| `core.debug_enabled` | `bool` | `false` | `CLEVERAGENTS_CORE_DEBUG_ENABLED` | Yes | Enable debug mode |
|
||||
| `core.env` | `str` | `development` | `CLEVERAGENTS_CORE_ENV` | Yes | Runtime environment name |
|
||||
| `core.data_dir` | `str` | `data` | `CLEVERAGENTS_CORE_DATA_DIR` | Yes | Base data directory path |
|
||||
| `core.database_url` | `str` | `sqlite:///cleveragents.db` | `CLEVERAGENTS_CORE_DATABASE_URL` | No | Primary database URL |
|
||||
| `core.server_host` | `str` | `0.0.0.0` | `CLEVERAGENTS_CORE_SERVER_HOST` | No | Server bind host |
|
||||
| `core.server_port` | `int` | `8080` | `CLEVERAGENTS_CORE_SERVER_PORT` | No | Server bind port |
|
||||
|
||||
### `plan.*` — Plan Execution
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|------------------------|--------|---------|--------------------------------------|-------------------|---------------------------------------|
|
||||
| `plan.auto_apply` | `bool` | `false` | `CLEVERAGENTS_PLAN_AUTO_APPLY` | Yes | Auto-apply plans on completion |
|
||||
| `plan.max_retries` | `int` | `3` | `CLEVERAGENTS_PLAN_MAX_RETRIES` | Yes | Maximum plan retry count |
|
||||
| `plan.timeout_seconds` | `int` | `300` | `CLEVERAGENTS_PLAN_TIMEOUT_SECONDS` | Yes | Plan execution timeout in seconds |
|
||||
|
||||
### `provider.*` — LLM Provider
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------|---------|---------|----------------------------------------|-------------------|-------------------------------|
|
||||
| `provider.default_provider` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_PROVIDER` | No | Default LLM provider name |
|
||||
| `provider.default_model` | `str` | `""` | `CLEVERAGENTS_PROVIDER_DEFAULT_MODEL` | No | Default LLM model name |
|
||||
| `provider.temperature` | `float` | `0.7` | `CLEVERAGENTS_PROVIDER_TEMPERATURE` | Yes | LLM sampling temperature |
|
||||
| `provider.max_tokens` | `int` | `4096` | `CLEVERAGENTS_PROVIDER_MAX_TOKENS` | Yes | Max tokens per LLM response |
|
||||
|
||||
### `sandbox.*` — Sandbox Isolation
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|------------------------|--------|----------------|--------------------------------------|-------------------|---------------------------------------|
|
||||
| `sandbox.strategy` | `str` | `git_worktree` | `CLEVERAGENTS_SANDBOX_STRATEGY` | Yes | Default sandbox strategy |
|
||||
| `sandbox.auto_cleanup` | `bool` | `true` | `CLEVERAGENTS_SANDBOX_AUTO_CLEANUP` | Yes | Auto-clean sandbox after use |
|
||||
| `sandbox.max_age_hours`| `int` | `48` | `CLEVERAGENTS_SANDBOX_MAX_AGE_HOURS` | Yes | Max sandbox age before cleanup |
|
||||
|
||||
### `context.*` — Context Management
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------------|--------|----------|-------------------------------------------------|-------------------|--------------------------------------------|
|
||||
| `context.max_files` | `int` | `100` | `CLEVERAGENTS_CONTEXT_MAX_FILES` | Yes | Maximum context files per session |
|
||||
| `context.max_tokens` | `int` | `128000` | `CLEVERAGENTS_CONTEXT_MAX_TOKENS` | Yes | Maximum context token window |
|
||||
| `context.auto_include_gitignore` | `bool` | `true` | `CLEVERAGENTS_CONTEXT_AUTO_INCLUDE_GITIGNORE` | Yes | Auto-include gitignore in context filtering |
|
||||
|
||||
### `index.*` — Vector Store Indexing
|
||||
|
||||
| Key | Type | Default | Env Var | Project-Scopable | Description |
|
||||
|--------------------------|--------|---------|------------------------------------------|-------------------|---------------------------------|
|
||||
| `index.enabled` | `bool` | `false` | `CLEVERAGENTS_INDEX_ENABLED` | Yes | Enable vector-store indexing |
|
||||
| `index.backend` | `str` | `faiss` | `CLEVERAGENTS_INDEX_BACKEND` | Yes | Vector store backend name |
|
||||
| `index.embedding_model` | `str` | `fake` | `CLEVERAGENTS_INDEX_EMBEDDING_MODEL` | Yes | Embedding model for indexing |
|
||||
| `index.embedding_dimension` | `int` | `1536` | `CLEVERAGENTS_INDEX_EMBEDDING_DIMENSION` | Yes | Embedding vector dimension |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Unknown Keys
|
||||
|
||||
Attempting to get or set an unregistered key will produce an actionable error:
|
||||
|
||||
```
|
||||
ValueError: Unknown configuration key: 'bogus.key'.
|
||||
Valid keys include: context.auto_include_gitignore, context.max_files, ...
|
||||
```
|
||||
|
||||
### Type Mismatches
|
||||
|
||||
Values are coerced to the registered type. If coercion fails, an actionable
|
||||
error is raised:
|
||||
|
||||
```
|
||||
TypeError: Type mismatch for key 'core.server_port': expected int,
|
||||
got str with value 'not_a_number'.
|
||||
```
|
||||
|
||||
Boolean coercion accepts: `true`/`false`, `1`/`0`, `yes`/`no` (case-insensitive).
|
||||
|
||||
## TOML File Location
|
||||
|
||||
The global configuration file is stored at:
|
||||
|
||||
```
|
||||
~/.cleveragents/config.toml
|
||||
```
|
||||
|
||||
Parent directories are created automatically on first write. Project-scoped
|
||||
overrides live under `[project."<name>"]` tables within the same file.
|
||||
|
||||
## Verbose Mode
|
||||
|
||||
When `--verbose` is passed to `config get`, the full resolution chain is
|
||||
displayed, showing which level provided (or could provide) the value:
|
||||
|
||||
```
|
||||
$ agents config get core.log_level --verbose
|
||||
Key: core.log_level
|
||||
Value: INFO
|
||||
Source: default
|
||||
|
||||
Resolution chain (highest -> lowest priority):
|
||||
cli_flag -> (not set)
|
||||
env_var -> (not set) [CLEVERAGENTS_CORE_LOG_LEVEL]
|
||||
project -> (not set)
|
||||
global -> (not set) [~/.cleveragents/config.toml]
|
||||
default -> INFO <-- active
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Phase Reversion State Machine
|
||||
|
||||
Phase reversion allows plans to return to an earlier lifecycle phase
|
||||
when the current strategy proves unworkable. This enables adaptive
|
||||
re-planning without discarding accumulated context.
|
||||
|
||||
## Reversion Paths
|
||||
|
||||
| Source Phase | Target Phase | Trigger |
|
||||
|-------------|-------------|---------|
|
||||
| Execute | Strategize | Validation failures block apply; constraints too restrictive |
|
||||
| Apply (constrained) | Strategize | Cannot proceed within current strategy constraints |
|
||||
|
||||
## Automation Profile Thresholds
|
||||
|
||||
Automatic reversion is gated by the plan's resolved `AutomationProfile`:
|
||||
|
||||
| Threshold | Controls |
|
||||
|-----------|----------|
|
||||
| `auto_reversion_from_apply` | Auto-revert when Apply is constrained. `< 1.0` = automatic, `1.0` = manual only |
|
||||
| `auto_strategy_revision` | Auto-revert from Execute to Strategize. `< 1.0` = automatic, `1.0` = manual only |
|
||||
|
||||
Built-in profile defaults:
|
||||
|
||||
| Profile | `auto_reversion_from_apply` | `auto_strategy_revision` |
|
||||
|---------|---------------------------|------------------------|
|
||||
| `manual` | 1.0 (never auto) | 1.0 |
|
||||
| `review` | 1.0 | 1.0 |
|
||||
| `trusted` | 1.0 | 1.0 |
|
||||
| `auto` | 1.0 | 0.0 |
|
||||
| `ci` | 0.0 (always auto) | 0.0 |
|
||||
| `full-auto` | 0.0 | 0.0 |
|
||||
|
||||
## Loop Guard
|
||||
|
||||
Every plan tracks a `reversion_count` field. Each reversion increments
|
||||
this counter. The maximum number of reversions per plan execution is
|
||||
**3** (`Plan.MAX_REVERSIONS`). Once this limit is reached, both
|
||||
automatic and manual reversions are blocked.
|
||||
|
||||
This prevents infinite strategize-execute-revert cycles when the
|
||||
underlying problem cannot be resolved through re-planning alone.
|
||||
|
||||
## Decision Recording
|
||||
|
||||
Each reversion is recorded as a `reversion` decision in the plan's
|
||||
`decisions` list with the following fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `str` | Always `"reversion"` |
|
||||
| `source_phase` | `str` | Phase the plan was in before reversion |
|
||||
| `target_phase` | `str` | Phase the plan reverted to |
|
||||
| `reason` | `str` | Human-readable reason for the reversion |
|
||||
| `timestamp` | `str` | ISO 8601 timestamp |
|
||||
| `reversion_number` | `int` | 1-based reversion counter |
|
||||
|
||||
## Manual Reversion
|
||||
|
||||
Plans can be manually reverted via the CLI:
|
||||
|
||||
```bash
|
||||
agents plan revert <plan_id> --to-phase strategize --reason "constraints too strict"
|
||||
```
|
||||
|
||||
Manual reversion is subject to the same loop guard (max 3 reversions)
|
||||
but ignores automation profile thresholds.
|
||||
|
||||
## State Reset on Reversion
|
||||
|
||||
When a plan is reverted:
|
||||
|
||||
1. The `phase` is set to the target phase.
|
||||
2. The `processing_state` is reset to `QUEUED`.
|
||||
3. The `error_message` is cleared.
|
||||
4. The `reversion_count` is incremented.
|
||||
5. The `updated_at` timestamp is refreshed.
|
||||
6. Context and sandbox state are preserved.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Plan CLI Reference
|
||||
|
||||
The `agents plan` command group manages plans in the CleverAgents v3 plan lifecycle.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------------------------|-----------------------------------------|
|
||||
| `agents plan use` | Create plan from action + project(s) |
|
||||
| `agents plan lifecycle-list` | List plans with optional filters |
|
||||
| `agents plan status` | Show plan status / details |
|
||||
| `agents plan execute` | Transition to Execute phase |
|
||||
| `agents plan lifecycle-apply` | Transition to Apply phase |
|
||||
| `agents plan cancel` | Cancel a non-terminal plan |
|
||||
| `agents plan diff` | Show ChangeSet as unified diff |
|
||||
| `agents plan artifacts` | Show ChangeSet ID, sandbox refs, summary|
|
||||
|
||||
## `agents plan use`
|
||||
|
||||
Create a plan from an action template and one or more projects.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents plan use <ACTION_NAME> [PROJECTS...] [OPTIONS]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description |
|
||||
|-------------------------|-------------------------------------------------------|
|
||||
| `--project`, `-p` | Project name (repeatable for multiple projects) |
|
||||
| `--arg`, `-a` | Argument value in `name=value` format (repeatable) |
|
||||
| `--automation-profile` | Automation profile name (e.g., `trusted`, `manual`) |
|
||||
| `--invariant` | Invariant constraint text (repeatable) |
|
||||
| `--strategy-actor` | Override strategy actor (`namespace/name` format) |
|
||||
| `--execution-actor` | Override execution actor (`namespace/name` format) |
|
||||
| `--estimation-actor` | Override estimation actor (`namespace/name` format) |
|
||||
| `--invariant-actor` | Override invariant reconciliation actor |
|
||||
| `--format`, `-f` | Output format: json, yaml, plain, table, rich |
|
||||
|
||||
### Actor Override Validation
|
||||
|
||||
All actor override flags require **namespaced format**: `namespace/name`
|
||||
(e.g., `openai/gpt-4`, `anthropic/claude-3`). Invalid formats are
|
||||
rejected with a descriptive error.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage with one project
|
||||
agents plan use local/code-coverage my-project --arg target_coverage=80
|
||||
|
||||
# Multiple projects
|
||||
agents plan use local/lint proj-1 proj-2
|
||||
|
||||
# With automation profile and invariants
|
||||
agents plan use local/refactor my-project \
|
||||
--automation-profile trusted \
|
||||
--invariant "No new warnings" \
|
||||
--invariant "Maintain backward compatibility"
|
||||
|
||||
# With actor overrides
|
||||
agents plan use local/code-coverage my-project \
|
||||
--strategy-actor openai/gpt-4 \
|
||||
--execution-actor anthropic/claude-3 \
|
||||
--estimation-actor openai/gpt-4
|
||||
|
||||
# JSON output
|
||||
agents plan use local/lint my-project --format json
|
||||
```
|
||||
|
||||
## `agents plan status`
|
||||
|
||||
Show status of one or all v3 lifecycle plans.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents plan status [PLAN_ID] [--format FORMAT]
|
||||
```
|
||||
|
||||
When called without a plan ID, displays a summary table of all active
|
||||
plans including automation profile and invariant count columns.
|
||||
|
||||
### Output Fields
|
||||
|
||||
- **ID**: Truncated plan ULID
|
||||
- **Name**: Namespaced plan name
|
||||
- **Phase**: Current lifecycle phase
|
||||
- **State**: Processing state
|
||||
- **Profile**: Automation profile name (if set)
|
||||
- **Invariants**: Count of attached invariants
|
||||
- **Terminal**: Whether the plan is in a terminal state
|
||||
|
||||
## `agents plan lifecycle-list`
|
||||
|
||||
List v3 lifecycle plans with optional filtering.
|
||||
|
||||
### Synopsis
|
||||
|
||||
```bash
|
||||
agents plan lifecycle-list [REGEX] [OPTIONS]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Description |
|
||||
|-------------------------|--------------------------------------------------|
|
||||
| `--phase` | Filter by phase (strategize, execute, apply) |
|
||||
| `--state` | Filter by processing state |
|
||||
| `--processing-state` | Alias for `--state` |
|
||||
| `--project`, `-p` | Filter by project name |
|
||||
| `--action` | Filter by action name |
|
||||
| `--format`, `-f` | Output format |
|
||||
|
||||
### Output Fields
|
||||
|
||||
Includes **Profile** and **Invariants** columns in all output formats
|
||||
when present on the plan.
|
||||
|
||||
## `agents plan execute`
|
||||
|
||||
Transition a plan from Strategize to Execute phase.
|
||||
|
||||
```bash
|
||||
agents plan execute [PLAN_ID] [--format FORMAT]
|
||||
```
|
||||
|
||||
## `agents plan lifecycle-apply`
|
||||
|
||||
Transition a plan from Execute to Apply phase.
|
||||
|
||||
```bash
|
||||
agents plan lifecycle-apply [PLAN_ID] [--format FORMAT]
|
||||
```
|
||||
|
||||
## `agents plan cancel`
|
||||
|
||||
Cancel a non-terminal plan.
|
||||
|
||||
```bash
|
||||
agents plan cancel PLAN_ID [--reason REASON] [--format FORMAT]
|
||||
```
|
||||
|
||||
## `agents plan diff`
|
||||
|
||||
Show ChangeSet as unified diff for a plan.
|
||||
|
||||
```bash
|
||||
agents plan diff PLAN_ID [--correction ID] [--format FORMAT]
|
||||
```
|
||||
|
||||
## `agents plan artifacts`
|
||||
|
||||
Show plan artifacts including ChangeSet ID and sandbox references.
|
||||
|
||||
```bash
|
||||
agents plan artifacts PLAN_ID [--format FORMAT]
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
Feature: CLI extensions for plan and action commands
|
||||
As a developer using the CleverAgents CLI
|
||||
I want automation profile, invariant, and actor override flags
|
||||
So that I can customize plan creation and view extended details
|
||||
|
||||
Background:
|
||||
Given a cli extensions test runner
|
||||
And a cli extensions mocked lifecycle service
|
||||
|
||||
# --- plan use: automation profile ---
|
||||
|
||||
Scenario: Plan use with --automation-profile persists to plan metadata
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with automation profile flag "trusted"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan should have automation profile "trusted"
|
||||
|
||||
Scenario: Plan use with invalid automation profile is rejected
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with automation profile flag "nonexistent-profile"
|
||||
Then the cli extensions plan use should fail
|
||||
|
||||
# --- plan use: invariant flags ---
|
||||
|
||||
Scenario: Plan use with --invariant flags persists to plan metadata
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with invariant flags "No warnings" and "Keep compat"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions service received invariants "No warnings" and "Keep compat"
|
||||
|
||||
Scenario: Plan use with single invariant flag
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with single invariant "All tests must pass"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions service received single invariant "All tests must pass"
|
||||
|
||||
# --- plan use: actor override validation (valid namespaced) ---
|
||||
|
||||
Scenario: Plan use with valid namespaced strategy-actor
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with strategy actor flag "openai/gpt-4"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan has strategy actor "openai/gpt-4"
|
||||
|
||||
Scenario: Plan use with valid namespaced execution-actor
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with execution actor flag "anthropic/claude-3"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan has execution actor "anthropic/claude-3"
|
||||
|
||||
Scenario: Plan use with valid namespaced estimation-actor
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with estimation actor flag "openai/gpt-4"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan has estimation actor "openai/gpt-4"
|
||||
|
||||
Scenario: Plan use with valid namespaced invariant-actor
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with invariant actor flag "openai/gpt-4"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan has invariant actor "openai/gpt-4"
|
||||
|
||||
# --- plan use: actor override validation (invalid format) ---
|
||||
|
||||
Scenario: Plan use rejects invalid strategy-actor format
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with strategy actor flag "bad-format"
|
||||
Then the cli extensions plan use should fail with actor validation error
|
||||
|
||||
Scenario: Plan use rejects invalid execution-actor format
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with execution actor flag "no_slash"
|
||||
Then the cli extensions plan use should fail with actor validation error
|
||||
|
||||
Scenario: Plan use rejects invalid estimation-actor format
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with estimation actor flag "UPPERCASE/gpt"
|
||||
Then the cli extensions plan use should fail with actor validation error
|
||||
|
||||
Scenario: Plan use rejects invalid invariant-actor format
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with invariant actor flag "spaces are bad"
|
||||
Then the cli extensions plan use should fail with actor validation error
|
||||
|
||||
# --- plan status: showing automation_profile ---
|
||||
|
||||
Scenario: Plan status shows automation profile in table
|
||||
Given cli extensions plans exist with automation profile "trusted"
|
||||
When I run cli extensions plan status
|
||||
Then the cli extensions status output should contain "trusted"
|
||||
|
||||
Scenario: Plan status shows automation profile in JSON
|
||||
Given cli extensions plans exist with automation profile "manual"
|
||||
When I run cli extensions plan status with format "json"
|
||||
Then the cli extensions status json output should contain automation profile "manual"
|
||||
|
||||
# --- plan list: showing invariants when present ---
|
||||
|
||||
Scenario: Plan lifecycle-list shows invariant count in table
|
||||
Given cli extensions plans exist with invariants
|
||||
When I run cli extensions plan lifecycle-list
|
||||
Then the cli extensions list output should contain invariant count
|
||||
|
||||
Scenario: Plan lifecycle-list shows invariants in JSON
|
||||
Given cli extensions plans exist with invariants
|
||||
When I run cli extensions plan lifecycle-list with format "json"
|
||||
Then the cli extensions list json output should contain invariants
|
||||
|
||||
# --- action show: optional actors ---
|
||||
|
||||
Scenario: Action show displays estimation actor when present
|
||||
Given a cli extensions action with estimation actor "openai/gpt-4"
|
||||
When I run cli extensions action show
|
||||
Then the cli extensions action output should contain "Estimation Actor"
|
||||
And the cli extensions action output should contain "openai/gpt-4"
|
||||
|
||||
Scenario: Action show displays invariant actor when present
|
||||
Given a cli extensions action with invariant actor "anthropic/claude-3"
|
||||
When I run cli extensions action show
|
||||
Then the cli extensions action output should contain "Invariant Actor"
|
||||
And the cli extensions action output should contain "anthropic/claude-3"
|
||||
|
||||
Scenario: Action show displays invariants when present
|
||||
Given a cli extensions action with invariants
|
||||
When I run cli extensions action show
|
||||
Then the cli extensions action output should contain "Invariants"
|
||||
|
||||
Scenario: Action show displays inputs schema when present
|
||||
Given a cli extensions action with inputs schema
|
||||
When I run cli extensions action show
|
||||
Then the cli extensions action output should contain "Inputs Schema"
|
||||
|
||||
Scenario: Action show in JSON includes optional actors
|
||||
Given a cli extensions action with estimation actor "openai/gpt-4"
|
||||
When I run cli extensions action show with format "json"
|
||||
Then the cli extensions action json should contain "estimation_actor"
|
||||
|
||||
Scenario: Action show in JSON includes inputs schema
|
||||
Given a cli extensions action with inputs schema
|
||||
When I run cli extensions action show with format "json"
|
||||
Then the cli extensions action json should contain "inputs_schema"
|
||||
|
||||
# --- plan use: automation_profile + invariants combined ---
|
||||
|
||||
Scenario: Plan use with automation profile and invariants together
|
||||
Given a cli extensions action exists
|
||||
When I run plan use with profile "trusted" and invariant "No regressions"
|
||||
Then the cli extensions plan use should succeed
|
||||
And the cli extensions plan should have automation profile "trusted"
|
||||
And the cli extensions service received single invariant "No regressions"
|
||||
|
||||
# --- validate_namespaced_actor function ---
|
||||
|
||||
Scenario: validate_namespaced_actor accepts valid names
|
||||
Then validate_namespaced_actor accepts "openai/gpt-4"
|
||||
And validate_namespaced_actor accepts "anthropic/claude-3"
|
||||
And validate_namespaced_actor accepts "local/my-actor"
|
||||
|
||||
Scenario: validate_namespaced_actor rejects invalid names
|
||||
Then validate_namespaced_actor rejects "no-slash"
|
||||
And validate_namespaced_actor rejects "UPPER/case"
|
||||
And validate_namespaced_actor rejects "/leading-slash"
|
||||
And validate_namespaced_actor rejects "trail/ing/"
|
||||
@@ -0,0 +1,182 @@
|
||||
Feature: Config service with multi-level resolution
|
||||
As a developer
|
||||
I want a config service that resolves values through a precedence chain
|
||||
So that CLI flags, env vars, project scope, global config, and defaults all work correctly
|
||||
|
||||
Background:
|
||||
Given a clean config service test environment
|
||||
|
||||
# --- Resolution levels ---
|
||||
|
||||
Scenario: Default value is returned when nothing else is set
|
||||
When I resolve config key "core.log_level"
|
||||
Then the resolved value should be "INFO"
|
||||
And the resolved source should be "default"
|
||||
|
||||
Scenario: Global config file overrides default
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
When I resolve config key "core.log_level"
|
||||
Then the resolved value should be "DEBUG"
|
||||
And the resolved source should be "global"
|
||||
|
||||
Scenario: Environment variable overrides global config
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log_level"
|
||||
Then the resolved value should be "WARNING"
|
||||
And the resolved source should be "env_var"
|
||||
|
||||
Scenario: CLI flag overrides environment variable
|
||||
Given the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "WARNING"
|
||||
When I resolve config key "core.log_level" with CLI value "ERROR"
|
||||
Then the resolved value should be "ERROR"
|
||||
And the resolved source should be "cli_flag"
|
||||
|
||||
Scenario: Project-scoped value overrides global config
|
||||
Given I have written global config key "core.log_level" with value "DEBUG"
|
||||
And I have written project "myproj" config key "core.log_level" with value "TRACE"
|
||||
When I resolve config key "core.log_level" for project "myproj"
|
||||
Then the resolved value should be "TRACE"
|
||||
And the resolved source should be "project"
|
||||
|
||||
Scenario: Env var still overrides project-scoped value
|
||||
Given I have written project "myproj" config key "core.log_level" with value "TRACE"
|
||||
And the environment variable "CLEVERAGENTS_CORE_LOG_LEVEL" is set to "ERROR"
|
||||
When I resolve config key "core.log_level" for project "myproj"
|
||||
Then the resolved value should be "ERROR"
|
||||
And the resolved source should be "env_var"
|
||||
|
||||
# --- Env var interpolation ---
|
||||
|
||||
Scenario: Env var name matches CLEVERAGENTS convention
|
||||
When I look up the env var for key "core.log_level"
|
||||
Then the env var name should be "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
|
||||
Scenario: Env var interpolation for plan keys
|
||||
When I look up the env var for key "plan.max_retries"
|
||||
Then the env var name should be "CLEVERAGENTS_PLAN_MAX_RETRIES"
|
||||
|
||||
Scenario: Env var interpolation for provider keys
|
||||
When I look up the env var for key "provider.temperature"
|
||||
Then the env var name should be "CLEVERAGENTS_PROVIDER_TEMPERATURE"
|
||||
|
||||
# --- Unknown key rejection ---
|
||||
|
||||
Scenario: Resolving an unknown key raises an error
|
||||
When I attempt to resolve unknown key "bogus.nonexistent"
|
||||
Then the config ValueError message should contain "Unknown configuration key"
|
||||
|
||||
Scenario: Validating an unknown key raises an error
|
||||
When I attempt to validate unknown key "invalid.key"
|
||||
Then the config ValueError message should contain "Unknown configuration key"
|
||||
|
||||
# --- Type validation ---
|
||||
|
||||
Scenario: Integer value is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_SERVER_PORT" is set to "9090"
|
||||
When I resolve config key "core.server_port"
|
||||
Then the resolved value should be integer 9090
|
||||
|
||||
Scenario: Boolean value true is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "true"
|
||||
When I resolve config key "core.debug_enabled"
|
||||
Then the resolved value should be boolean true
|
||||
|
||||
Scenario: Boolean value false is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_CORE_DEBUG_ENABLED" is set to "false"
|
||||
When I resolve config key "core.debug_enabled"
|
||||
Then the resolved value should be boolean false
|
||||
|
||||
Scenario: Float value is coerced from string
|
||||
Given the environment variable "CLEVERAGENTS_PROVIDER_TEMPERATURE" is set to "0.9"
|
||||
When I resolve config key "provider.temperature"
|
||||
Then the resolved value should be float 0.9
|
||||
|
||||
Scenario: Invalid boolean string raises TypeError
|
||||
When I attempt to validate type for key "core.debug_enabled" with value "notabool"
|
||||
Then the config TypeError message should contain "Cannot convert"
|
||||
|
||||
Scenario: Invalid integer string raises TypeError
|
||||
When I attempt to validate type for key "core.server_port" with value "notanumber"
|
||||
Then the config TypeError message should contain "Type mismatch"
|
||||
|
||||
# --- Verbose resolution chain ---
|
||||
|
||||
Scenario: Verbose mode returns full resolution chain
|
||||
When I resolve config key "core.log_level" with verbose mode
|
||||
Then the resolution chain should have 5 entries
|
||||
And the chain should include source "cli_flag"
|
||||
And the chain should include source "env_var"
|
||||
And the chain should include source "project"
|
||||
And the chain should include source "global"
|
||||
And the chain should include source "default"
|
||||
|
||||
# --- Key registry ---
|
||||
|
||||
Scenario: Registry contains core keys
|
||||
Then the registry should contain key "core.log_level"
|
||||
And the registry should contain key "core.debug_enabled"
|
||||
And the registry should contain key "core.env"
|
||||
|
||||
Scenario: Registry contains plan keys
|
||||
Then the registry should contain key "plan.auto_apply"
|
||||
And the registry should contain key "plan.max_retries"
|
||||
And the registry should contain key "plan.timeout_seconds"
|
||||
|
||||
Scenario: Registry contains provider keys
|
||||
Then the registry should contain key "provider.default_provider"
|
||||
And the registry should contain key "provider.default_model"
|
||||
And the registry should contain key "provider.temperature"
|
||||
|
||||
Scenario: Registry contains sandbox keys
|
||||
Then the registry should contain key "sandbox.strategy"
|
||||
And the registry should contain key "sandbox.auto_cleanup"
|
||||
And the registry should contain key "sandbox.max_age_hours"
|
||||
|
||||
Scenario: Registry contains context keys
|
||||
Then the registry should contain key "context.max_files"
|
||||
And the registry should contain key "context.max_tokens"
|
||||
|
||||
Scenario: Registry contains index keys
|
||||
Then the registry should contain key "index.enabled"
|
||||
And the registry should contain key "index.backend"
|
||||
|
||||
# --- TOML file management ---
|
||||
|
||||
Scenario: Writing and reading TOML config roundtrips
|
||||
Given I write config data with key "core.log_level" and value "TRACE"
|
||||
When I read the config file
|
||||
Then the config data should contain key "core.log_level" with value "TRACE"
|
||||
|
||||
Scenario: Config directory is auto-created
|
||||
When I write config data to a new directory
|
||||
Then the config directory should exist
|
||||
|
||||
Scenario: Non-project-scopable keys ignore project scope
|
||||
Given I have written project "proj" config key "core.database_url" with value "sqlite:///other.db"
|
||||
When I resolve config key "core.database_url" for project "proj"
|
||||
Then the resolved source should be "default"
|
||||
|
||||
# --- Resolve all ---
|
||||
|
||||
Scenario: Resolve all returns all registered keys
|
||||
When I resolve all keys
|
||||
Then the result should contain all registered keys
|
||||
|
||||
# --- Set value ---
|
||||
|
||||
Scenario: Set value persists to TOML file
|
||||
When I set config value "core.log_level" to "CRITICAL"
|
||||
And I read the config file
|
||||
Then the config data should contain key "core.log_level" with value "CRITICAL"
|
||||
|
||||
# --- Entry lookup ---
|
||||
|
||||
Scenario: Get entry returns ConfigEntry for valid key
|
||||
When I get entry for key "core.log_level"
|
||||
Then the entry should have section "core"
|
||||
And the entry should have python type "str"
|
||||
|
||||
Scenario: Get entry returns None for unknown key
|
||||
When I get entry for key "nonexistent.key"
|
||||
Then the entry should be None
|
||||
@@ -0,0 +1,110 @@
|
||||
Feature: Phase Reversion State Machine
|
||||
As a plan lifecycle manager
|
||||
I want to revert plans to earlier phases
|
||||
So that plans can be re-strategized when constraints prove unworkable
|
||||
|
||||
Background:
|
||||
Given a plan lifecycle service is initialized
|
||||
|
||||
Scenario: Auto-reversion from constrained Apply to Strategize
|
||||
Given an action "local/test-action" exists
|
||||
And a plan is created from action "local/test-action"
|
||||
And the plan is advanced to Apply phase with constrained state
|
||||
And the plan has automation profile "ci" allowing auto-reversion
|
||||
When auto-reversion from apply is attempted with reason "constraints too strict"
|
||||
Then the plan should be in Strategize phase
|
||||
And the plan processing state should be queued
|
||||
And the plan reversion count should be 1
|
||||
And a reversion decision should be recorded with source "apply" and target "strategize"
|
||||
|
||||
Scenario: Manual reversion via revert_plan method
|
||||
Given an action "local/manual-revert" exists
|
||||
And a plan is created from action "local/manual-revert"
|
||||
And the plan is advanced to Execute phase
|
||||
When the plan is manually reverted to Strategize with reason "need different strategy"
|
||||
Then the plan should be in Strategize phase
|
||||
And the plan processing state should be queued
|
||||
And the plan reversion count should be 1
|
||||
And a reversion decision should be recorded with reason "need different strategy"
|
||||
|
||||
Scenario: Loop guard prevents more than 3 reversions
|
||||
Given an action "local/loop-guard" exists
|
||||
And a plan is created from action "local/loop-guard"
|
||||
And the plan has been reverted 3 times
|
||||
When a reversion is attempted on the maxed-out plan
|
||||
Then a PlanError should be raised about max reversions
|
||||
|
||||
Scenario: Auto-reversion blocked by manual profile threshold
|
||||
Given an action "local/profile-gate" exists
|
||||
And a plan is created from action "local/profile-gate"
|
||||
And the plan is advanced to Apply phase with constrained state
|
||||
And the plan has automation profile "manual" blocking auto-reversion
|
||||
When auto-reversion from apply is attempted with reason "profile blocks it"
|
||||
Then the plan should remain in Apply phase
|
||||
And the plan processing state should be constrained
|
||||
|
||||
Scenario: Auto-reversion from Execute to Strategize
|
||||
Given an action "local/exec-revert" exists
|
||||
And a plan is created from action "local/exec-revert"
|
||||
And the plan is advanced to Execute phase
|
||||
And the plan has automation profile "ci" allowing auto-reversion
|
||||
When auto-reversion from execute is attempted with reason "validation failures"
|
||||
Then the plan should be in Strategize phase
|
||||
And the plan processing state should be queued
|
||||
And the plan reversion count should be 1
|
||||
|
||||
Scenario: Invalid reversion on terminal plan (applied)
|
||||
Given an action "local/terminal-plan" exists
|
||||
And a plan is created from action "local/terminal-plan"
|
||||
And the plan has reached applied terminal state
|
||||
When a reversion is attempted on the terminal plan
|
||||
Then a PlanError should be raised about terminal state
|
||||
|
||||
Scenario: Invalid reversion to wrong phase
|
||||
Given an action "local/wrong-phase" exists
|
||||
And a plan is created from action "local/wrong-phase"
|
||||
And the plan is in Strategize phase
|
||||
When a reversion to Apply phase is attempted
|
||||
Then an InvalidPhaseTransitionError should be raised
|
||||
|
||||
Scenario: Reversion preserves plan context
|
||||
Given an action "local/context-preserve" exists
|
||||
And a plan is created from action "local/context-preserve" with arguments
|
||||
And the plan is advanced to Execute phase
|
||||
When the plan is manually reverted to Strategize with reason "context check"
|
||||
Then the plan arguments should be preserved
|
||||
And the plan project links should be preserved
|
||||
And the plan invariants should be preserved
|
||||
|
||||
Scenario: Multiple reversions tracked in decisions list
|
||||
Given an action "local/multi-revert" exists
|
||||
And a plan is created from action "local/multi-revert"
|
||||
And the plan is advanced to Execute phase
|
||||
When the plan is manually reverted to Strategize with reason "first reversion"
|
||||
And the plan is re-advanced to Execute phase
|
||||
And the plan is manually reverted to Strategize with reason "second reversion"
|
||||
Then the plan reversion count should be 2
|
||||
And there should be 2 reversion decisions recorded
|
||||
|
||||
Scenario: can_revert_to validates reversion paths
|
||||
Given an action "local/revert-validation" exists
|
||||
And a plan is created from action "local/revert-validation"
|
||||
Then the plan in Strategize phase cannot revert to Apply
|
||||
And the plan in Strategize phase cannot revert to Action
|
||||
And the plan in Execute phase can revert to Strategize
|
||||
And the plan in Apply phase can revert to Strategize
|
||||
|
||||
Scenario: Auto-reversion from Execute blocked by profile threshold
|
||||
Given an action "local/exec-blocked" exists
|
||||
And a plan is created from action "local/exec-blocked"
|
||||
And the plan is advanced to Execute phase
|
||||
And the plan has automation profile "manual" blocking auto-reversion
|
||||
When auto-reversion from execute is attempted with reason "blocked by profile"
|
||||
Then the plan should remain in Execute phase
|
||||
|
||||
Scenario: Cancelled plan cannot be reverted
|
||||
Given an action "local/cancelled-plan" exists
|
||||
And a plan is created from action "local/cancelled-plan"
|
||||
And the plan has been cancelled
|
||||
When a reversion is attempted on the cancelled plan
|
||||
Then a PlanError should be raised about terminal state
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Step definitions for CLI extensions feature (plan + action)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.action import app as action_app
|
||||
from cleveragents.cli.commands.plan import app as plan_app
|
||||
from cleveragents.cli.commands.plan import validate_namespaced_actor
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
InvariantSource,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
|
||||
|
||||
|
||||
def _make_plan(
|
||||
*,
|
||||
name: str = "local/test-plan",
|
||||
action_name: str = "local/test-action",
|
||||
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
||||
state: ProcessingState = ProcessingState.QUEUED,
|
||||
project_links: list[ProjectLink] | None = None,
|
||||
arguments: dict[str, object] | None = None,
|
||||
automation_profile: AutomationProfileRef | None = None,
|
||||
invariants: list[PlanInvariant] | None = None,
|
||||
strategy_actor: str | None = "openai/gpt-4",
|
||||
execution_actor: str | None = "openai/gpt-4",
|
||||
estimation_actor: str | None = None,
|
||||
invariant_actor: str | None = None,
|
||||
) -> Plan:
|
||||
"""Create a Plan instance for CLI extension tests."""
|
||||
now = datetime.now()
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ULID),
|
||||
namespaced_name=NamespacedName.parse(name),
|
||||
description="Test plan description",
|
||||
definition_of_done="All tests pass",
|
||||
action_name=action_name,
|
||||
phase=phase,
|
||||
processing_state=state,
|
||||
project_links=project_links or [],
|
||||
arguments=dict(arguments) if arguments else {},
|
||||
arguments_order=[],
|
||||
automation_profile=automation_profile,
|
||||
invariants=invariants or [],
|
||||
strategy_actor=strategy_actor,
|
||||
execution_actor=execution_actor,
|
||||
estimation_actor=estimation_actor,
|
||||
invariant_actor=invariant_actor,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_by=None,
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
)
|
||||
|
||||
|
||||
def _make_action(
|
||||
name: str = "local/test-action",
|
||||
*,
|
||||
estimation_actor: str | None = None,
|
||||
invariant_actor: str | None = None,
|
||||
invariants: list[str] | None = None,
|
||||
inputs_schema: dict[str, object] | None = None,
|
||||
automation_profile: str | None = None,
|
||||
) -> Action:
|
||||
"""Create an Action for CLI extension tests."""
|
||||
return Action(
|
||||
namespaced_name=NamespacedName.parse(name),
|
||||
description="Test action description",
|
||||
long_description=None,
|
||||
definition_of_done="All tests pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
estimation_actor=estimation_actor,
|
||||
invariant_actor=invariant_actor,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
state=ActionState.AVAILABLE,
|
||||
automation_profile=automation_profile,
|
||||
invariants=invariants or [],
|
||||
inputs_schema=inputs_schema,
|
||||
created_by=None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a cli extensions test runner")
|
||||
def step_cli_ext_runner(context: Context) -> None:
|
||||
"""Set up the CLI runner."""
|
||||
context.runner = CliRunner()
|
||||
|
||||
|
||||
@given("a cli extensions mocked lifecycle service")
|
||||
def step_cli_ext_mock_service(context: Context) -> None:
|
||||
"""Set up the mocked lifecycle service."""
|
||||
context.mock_service = MagicMock()
|
||||
context.plan_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
context.action_patcher = patch(
|
||||
"cleveragents.cli.commands.action._get_lifecycle_service",
|
||||
return_value=context.mock_service,
|
||||
)
|
||||
context.plan_patcher.start()
|
||||
context.action_patcher.start()
|
||||
# Store the last plan created for inspection
|
||||
context.last_plan = None
|
||||
context.last_result = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: action setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a cli extensions action exists")
|
||||
def step_cli_ext_action_exists(context: Context) -> None:
|
||||
"""Set up an action for plan use tests."""
|
||||
context.mock_service.get_action_by_name.return_value = _make_action()
|
||||
context.mock_service.use_action.return_value = _make_plan()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: automation profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run plan use with automation profile flag "{profile}"')
|
||||
def step_run_plan_use_with_profile(context: Context, profile: str) -> None:
|
||||
"""Run plan use with --automation-profile."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--automation-profile", profile],
|
||||
)
|
||||
context.last_result = result
|
||||
context.last_profile = profile
|
||||
|
||||
|
||||
@then("the cli extensions plan use should succeed")
|
||||
def step_plan_use_should_succeed(context: Context) -> None:
|
||||
"""Verify plan use succeeded."""
|
||||
assert context.last_result is not None
|
||||
assert context.last_result.exit_code == 0, (
|
||||
f"Expected exit code 0, got {context.last_result.exit_code}. "
|
||||
f"Output: {context.last_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the cli extensions plan use should fail")
|
||||
def step_plan_use_should_fail(context: Context) -> None:
|
||||
"""Verify plan use failed."""
|
||||
assert context.last_result is not None
|
||||
assert context.last_result.exit_code != 0
|
||||
|
||||
|
||||
@then('the cli extensions plan should have automation profile "{profile}"')
|
||||
def step_plan_has_profile(context: Context, profile: str) -> None:
|
||||
"""Verify plan output mentions automation profile."""
|
||||
output = context.last_result.output
|
||||
assert profile in output, f"Expected '{profile}' in output: {output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: invariant flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run plan use with invariant flags "{inv1}" and "{inv2}"')
|
||||
def step_run_plan_use_with_invariants(context: Context, inv1: str, inv2: str) -> None:
|
||||
"""Run plan use with two --invariant flags."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--invariant",
|
||||
inv1,
|
||||
"--invariant",
|
||||
inv2,
|
||||
],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then('the cli extensions service received invariants "{inv1}" and "{inv2}"')
|
||||
def step_service_received_invariants(context: Context, inv1: str, inv2: str) -> None:
|
||||
"""Verify invariants were passed to the service."""
|
||||
call_args = context.mock_service.use_action.call_args
|
||||
assert call_args is not None, "use_action was not called"
|
||||
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
|
||||
assert invariants is not None, "No invariants passed to use_action"
|
||||
texts = [inv.text for inv in invariants]
|
||||
assert inv1 in texts, f"Expected '{inv1}' in {texts}"
|
||||
assert inv2 in texts, f"Expected '{inv2}' in {texts}"
|
||||
|
||||
|
||||
@when('I run plan use with single invariant "{inv_text}"')
|
||||
def step_run_plan_use_with_single_invariant(context: Context, inv_text: str) -> None:
|
||||
"""Run plan use with a single --invariant flag."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--invariant", inv_text],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then('the cli extensions service received single invariant "{inv_text}"')
|
||||
def step_service_received_single_invariant(context: Context, inv_text: str) -> None:
|
||||
"""Verify single invariant was passed."""
|
||||
call_args = context.mock_service.use_action.call_args
|
||||
assert call_args is not None
|
||||
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
|
||||
assert invariants is not None
|
||||
texts = [inv.text for inv in invariants]
|
||||
assert inv_text in texts, f"Expected '{inv_text}' in {texts}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: actor override validation (valid)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run plan use with strategy actor flag "{actor}"')
|
||||
def step_run_plan_use_strategy_actor(context: Context, actor: str) -> None:
|
||||
"""Run plan use with --strategy-actor."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--strategy-actor", actor],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@when('I run plan use with execution actor flag "{actor}"')
|
||||
def step_run_plan_use_execution_actor(context: Context, actor: str) -> None:
|
||||
"""Run plan use with --execution-actor."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--execution-actor", actor],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@when('I run plan use with estimation actor flag "{actor}"')
|
||||
def step_run_plan_use_estimation_actor(context: Context, actor: str) -> None:
|
||||
"""Run plan use with --estimation-actor."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--estimation-actor", actor],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@when('I run plan use with invariant actor flag "{actor}"')
|
||||
def step_run_plan_use_invariant_actor(context: Context, actor: str) -> None:
|
||||
"""Run plan use with --invariant-actor."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
["use", "local/test-action", "--invariant-actor", actor],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then('the cli extensions plan has strategy actor "{actor}"')
|
||||
def step_plan_has_strategy_actor(context: Context, actor: str) -> None:
|
||||
"""Verify plan output shows the strategy actor."""
|
||||
output = context.last_result.output
|
||||
assert actor in output, f"Expected '{actor}' in output: {output}"
|
||||
|
||||
|
||||
@then('the cli extensions plan has execution actor "{actor}"')
|
||||
def step_plan_has_execution_actor(context: Context, actor: str) -> None:
|
||||
"""Verify plan output shows the execution actor."""
|
||||
output = context.last_result.output
|
||||
assert actor in output, f"Expected '{actor}' in output: {output}"
|
||||
|
||||
|
||||
@then('the cli extensions plan has estimation actor "{actor}"')
|
||||
def step_plan_has_estimation_actor(context: Context, actor: str) -> None:
|
||||
"""Verify plan output shows the estimation actor."""
|
||||
output = context.last_result.output
|
||||
assert actor in output, f"Expected '{actor}' in output: {output}"
|
||||
|
||||
|
||||
@then('the cli extensions plan has invariant actor "{actor}"')
|
||||
def step_plan_has_invariant_actor(context: Context, actor: str) -> None:
|
||||
"""Verify plan output shows the invariant actor."""
|
||||
output = context.last_result.output
|
||||
assert actor in output, f"Expected '{actor}' in output: {output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: actor override validation (invalid format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the cli extensions plan use should fail with actor validation error")
|
||||
def step_plan_use_fail_actor_validation(context: Context) -> None:
|
||||
"""Verify plan use failed due to actor validation."""
|
||||
assert context.last_result is not None
|
||||
assert context.last_result.exit_code != 0, (
|
||||
f"Expected non-zero exit, got {context.last_result.exit_code}. "
|
||||
f"Output: {context.last_result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan status: automation profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('cli extensions plans exist with automation profile "{profile}"')
|
||||
def step_plans_with_profile(context: Context, profile: str) -> None:
|
||||
"""Set up plans that have an automation profile."""
|
||||
plan = _make_plan(
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name=profile,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
)
|
||||
context.mock_service.list_plans.return_value = [plan]
|
||||
context.mock_service.get_plan.return_value = plan
|
||||
|
||||
|
||||
@when("I run cli extensions plan status")
|
||||
def step_run_plan_status(context: Context) -> None:
|
||||
"""Run plan status (no plan_id = list all)."""
|
||||
result = context.runner.invoke(plan_app, ["status"])
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then('the cli extensions status output should contain "{text}"')
|
||||
def step_status_contains(context: Context, text: str) -> None:
|
||||
"""Verify status output contains text."""
|
||||
assert context.last_result.exit_code == 0
|
||||
assert text in context.last_result.output, (
|
||||
f"Expected '{text}' in: {context.last_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@when('I run cli extensions plan status with format "{fmt}"')
|
||||
def step_run_plan_status_fmt(context: Context, fmt: str) -> None:
|
||||
"""Run plan status with --format."""
|
||||
result = context.runner.invoke(plan_app, ["status", "--format", fmt])
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then(
|
||||
"the cli extensions status json output should contain automation profile "
|
||||
'"{profile}"'
|
||||
)
|
||||
def step_status_json_profile(context: Context, profile: str) -> None:
|
||||
"""Verify JSON output contains automation profile."""
|
||||
assert context.last_result.exit_code == 0
|
||||
output = context.last_result.output
|
||||
assert profile in output, f"Expected '{profile}' in: {output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan lifecycle-list: invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("cli extensions plans exist with invariants")
|
||||
def step_plans_with_invariants(context: Context) -> None:
|
||||
"""Set up plans that have invariants."""
|
||||
plan = _make_plan(
|
||||
invariants=[
|
||||
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
|
||||
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
|
||||
]
|
||||
)
|
||||
context.mock_service.list_plans.return_value = [plan]
|
||||
|
||||
|
||||
@when("I run cli extensions plan lifecycle-list")
|
||||
def step_run_lifecycle_list(context: Context) -> None:
|
||||
"""Run plan lifecycle-list."""
|
||||
result = context.runner.invoke(plan_app, ["lifecycle-list"])
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then("the cli extensions list output should contain invariant count")
|
||||
def step_list_has_invariant_count(context: Context) -> None:
|
||||
"""Verify list output contains invariant count."""
|
||||
assert context.last_result.exit_code == 0
|
||||
# The table should show "2" for invariant count
|
||||
assert "2" in context.last_result.output, (
|
||||
f"Expected '2' invariant count in: {context.last_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@when('I run cli extensions plan lifecycle-list with format "{fmt}"')
|
||||
def step_run_lifecycle_list_fmt(context: Context, fmt: str) -> None:
|
||||
"""Run plan lifecycle-list with --format."""
|
||||
result = context.runner.invoke(plan_app, ["lifecycle-list", "--format", fmt])
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then("the cli extensions list json output should contain invariants")
|
||||
def step_list_json_invariants(context: Context) -> None:
|
||||
"""Verify JSON output contains invariants field."""
|
||||
assert context.last_result.exit_code == 0
|
||||
output = context.last_result.output
|
||||
assert "invariants" in output, f"Expected 'invariants' in: {output}"
|
||||
assert "No warnings" in output, f"Expected 'No warnings' in: {output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# action show: optional fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a cli extensions action with estimation actor "{actor}"')
|
||||
def step_action_with_estimation_actor(context: Context, actor: str) -> None:
|
||||
"""Set up an action with estimation actor."""
|
||||
action = _make_action(estimation_actor=actor)
|
||||
context.mock_service.get_action_by_name.return_value = action
|
||||
context.test_action = action
|
||||
|
||||
|
||||
@given('a cli extensions action with invariant actor "{actor}"')
|
||||
def step_action_with_invariant_actor(context: Context, actor: str) -> None:
|
||||
"""Set up an action with invariant actor."""
|
||||
action = _make_action(invariant_actor=actor)
|
||||
context.mock_service.get_action_by_name.return_value = action
|
||||
context.test_action = action
|
||||
|
||||
|
||||
@given("a cli extensions action with invariants")
|
||||
def step_action_with_invariants(context: Context) -> None:
|
||||
"""Set up an action with invariants."""
|
||||
action = _make_action(invariants=["No regressions", "Keep backward compat"])
|
||||
context.mock_service.get_action_by_name.return_value = action
|
||||
context.test_action = action
|
||||
|
||||
|
||||
@given("a cli extensions action with inputs schema")
|
||||
def step_action_with_inputs_schema(context: Context) -> None:
|
||||
"""Set up an action with inputs_schema."""
|
||||
action = _make_action(
|
||||
inputs_schema={
|
||||
"type": "object",
|
||||
"properties": {"coverage": {"type": "integer"}},
|
||||
}
|
||||
)
|
||||
context.mock_service.get_action_by_name.return_value = action
|
||||
context.test_action = action
|
||||
|
||||
|
||||
@when("I run cli extensions action show")
|
||||
def step_run_action_show(context: Context) -> None:
|
||||
"""Run action show."""
|
||||
result = context.runner.invoke(action_app, ["show", "local/test-action"])
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@when('I run cli extensions action show with format "{fmt}"')
|
||||
def step_run_action_show_fmt(context: Context, fmt: str) -> None:
|
||||
"""Run action show with --format."""
|
||||
result = context.runner.invoke(
|
||||
action_app, ["show", "local/test-action", "--format", fmt]
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
@then('the cli extensions action output should contain "{text}"')
|
||||
def step_action_output_contains(context: Context, text: str) -> None:
|
||||
"""Verify action output contains text."""
|
||||
assert context.last_result.exit_code == 0, (
|
||||
f"Exit code: {context.last_result.exit_code}, "
|
||||
f"output: {context.last_result.output}"
|
||||
)
|
||||
assert text in context.last_result.output, (
|
||||
f"Expected '{text}' in: {context.last_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cli extensions action json should contain "{key}"')
|
||||
def step_action_json_contains(context: Context, key: str) -> None:
|
||||
"""Verify JSON output contains key."""
|
||||
assert context.last_result.exit_code == 0
|
||||
output = context.last_result.output
|
||||
assert key in output, f"Expected '{key}' in: {output}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan use: combined profile + invariant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run plan use with profile "{profile}" and invariant "{inv_text}"')
|
||||
def step_run_plan_use_profile_invariant(
|
||||
context: Context, profile: str, inv_text: str
|
||||
) -> None:
|
||||
"""Run plan use with both --automation-profile and --invariant."""
|
||||
result = context.runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--automation-profile",
|
||||
profile,
|
||||
"--invariant",
|
||||
inv_text,
|
||||
],
|
||||
)
|
||||
context.last_result = result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_namespaced_actor unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('validate_namespaced_actor accepts "{actor}"')
|
||||
def step_validate_actor_accepts(context: Context, actor: str) -> None:
|
||||
"""Verify validate_namespaced_actor accepts a valid name."""
|
||||
result = validate_namespaced_actor(actor, "--test")
|
||||
assert result == actor
|
||||
|
||||
|
||||
@then('validate_namespaced_actor rejects "{actor}"')
|
||||
def step_validate_actor_rejects(context: Context, actor: str) -> None:
|
||||
"""Verify validate_namespaced_actor rejects an invalid name."""
|
||||
try:
|
||||
validate_namespaced_actor(actor, "--test")
|
||||
raise AssertionError(f"Expected ValidationError for '{actor}'")
|
||||
except ValidationError:
|
||||
pass # Expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def after_scenario(context: Context, scenario: object) -> None:
|
||||
"""Clean up patchers."""
|
||||
for name in ("plan_patcher", "action_patcher"):
|
||||
patcher = getattr(context, name, None)
|
||||
if patcher:
|
||||
patcher.stop()
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Step definitions for config_resolution.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.config_service import ConfigService
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_service(context: Context) -> ConfigService:
|
||||
"""Return the test-scoped ConfigService instance."""
|
||||
svc: ConfigService = context._config_service
|
||||
return svc
|
||||
|
||||
|
||||
def _cleanup(context: Context) -> None:
|
||||
"""Clean up temp dir and env vars."""
|
||||
tmpdir = getattr(context, "_config_tmpdir", None)
|
||||
if tmpdir and os.path.isdir(tmpdir):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
for var in getattr(context, "_env_vars_set", []):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a clean config service test environment")
|
||||
def step_clean_config_service_env(context: Context) -> None:
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
context._config_tmpdir = tmpdir
|
||||
tmp_path = Path(tmpdir)
|
||||
context._config_service = ConfigService(
|
||||
config_dir=tmp_path,
|
||||
config_path=tmp_path / "config.toml",
|
||||
)
|
||||
context._env_vars_set = []
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(lambda: _cleanup(context))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
r'I have written global config key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
|
||||
)
|
||||
def step_write_global_config(context: Context, key: str, value: str) -> None:
|
||||
svc = _get_service(context)
|
||||
data = svc.read_config()
|
||||
data[key] = value
|
||||
svc.write_config(data)
|
||||
|
||||
|
||||
@given(
|
||||
r'I have written project "(?P<project>[^"]+)" config key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
|
||||
)
|
||||
def step_write_project_config(
|
||||
context: Context, project: str, key: str, value: str
|
||||
) -> None:
|
||||
svc = _get_service(context)
|
||||
data = svc.read_config()
|
||||
if "project" not in data:
|
||||
data["project"] = {}
|
||||
proj_data: dict[str, Any] = data["project"]
|
||||
if project not in proj_data:
|
||||
proj_data[project] = {}
|
||||
proj_data[project][key] = value
|
||||
svc.write_config(data)
|
||||
|
||||
|
||||
@given(r'I write config data with key "(?P<key>[^"]+)" and value "(?P<value>[^"]+)"')
|
||||
def step_write_config_data(context: Context, key: str, value: str) -> None:
|
||||
svc = _get_service(context)
|
||||
svc.set_value(key, value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(r'I resolve config key "(?P<key>[^"]+)" with CLI value "(?P<cli_value>[^"]+)"')
|
||||
def step_resolve_key_cli(context: Context, key: str, cli_value: str) -> None:
|
||||
svc = _get_service(context)
|
||||
context.resolved = svc.resolve(key, cli_value=cli_value)
|
||||
|
||||
|
||||
@when(r'I resolve config key "(?P<key>[^"]+)" for project "(?P<project>[^"]+)"')
|
||||
def step_resolve_key_project(context: Context, key: str, project: str) -> None:
|
||||
svc = _get_service(context)
|
||||
context.resolved = svc.resolve(key, project_name=project)
|
||||
|
||||
|
||||
@when(r'I resolve config key "(?P<key>[^"]+)" with verbose mode')
|
||||
def step_resolve_key_verbose(context: Context, key: str) -> None:
|
||||
svc = _get_service(context)
|
||||
context.resolved = svc.resolve(key, verbose=True)
|
||||
|
||||
|
||||
@when(r'I resolve config key "(?P<key>[^"]+)"')
|
||||
def step_resolve_key(context: Context, key: str) -> None:
|
||||
svc = _get_service(context)
|
||||
context.resolved = svc.resolve(key)
|
||||
|
||||
|
||||
@when(r'I look up the env var for key "(?P<key>[^"]+)"')
|
||||
def step_lookup_env_var(context: Context, key: str) -> None:
|
||||
svc = _get_service(context)
|
||||
context.env_var_name = svc.env_var_for_key(key)
|
||||
|
||||
|
||||
@when(r'I attempt to resolve unknown key "(?P<key>[^"]+)"')
|
||||
def step_attempt_resolve_unknown(context: Context, key: str) -> None:
|
||||
svc = _get_service(context)
|
||||
try:
|
||||
svc.resolve(key)
|
||||
context.raised_error = None
|
||||
except ValueError as exc:
|
||||
context.raised_error = exc
|
||||
|
||||
|
||||
@when(r'I attempt to validate unknown key "(?P<key>[^"]+)"')
|
||||
def step_attempt_validate_unknown(context: Context, key: str) -> None:
|
||||
try:
|
||||
ConfigService.validate_key(key)
|
||||
context.raised_error = None
|
||||
except ValueError as exc:
|
||||
context.raised_error = exc
|
||||
|
||||
|
||||
@when(
|
||||
r'I attempt to validate type for key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
|
||||
)
|
||||
def step_attempt_validate_type(context: Context, key: str, value: str) -> None:
|
||||
try:
|
||||
ConfigService.validate_type(key, value)
|
||||
context.raised_error = None
|
||||
except (TypeError, ValueError) as exc:
|
||||
context.raised_error = exc
|
||||
|
||||
|
||||
@when("I read the config file")
|
||||
def step_read_config_file(context: Context) -> None:
|
||||
svc = _get_service(context)
|
||||
context.config_data = svc.read_config()
|
||||
|
||||
|
||||
@when("I write config data to a new directory")
|
||||
def step_write_to_new_dir(context: Context) -> None:
|
||||
base = Path(context._config_tmpdir) / "nested" / "subdir"
|
||||
context._new_config_dir = base
|
||||
svc = ConfigService(config_dir=base, config_path=base / "config.toml")
|
||||
svc.write_config({"test_key": "test_value"})
|
||||
|
||||
|
||||
@when("I resolve all keys")
|
||||
def step_resolve_all(context: Context) -> None:
|
||||
svc = _get_service(context)
|
||||
context.all_resolved = svc.resolve_all()
|
||||
|
||||
|
||||
@when(r'I set config value "(?P<key>[^"]+)" to "(?P<value>[^"]+)"')
|
||||
def step_set_config_value(context: Context, key: str, value: str) -> None:
|
||||
svc = _get_service(context)
|
||||
svc.set_value(key, value)
|
||||
|
||||
|
||||
@when(r'I get entry for key "(?P<key>[^"]+)"')
|
||||
def step_get_entry(context: Context, key: str) -> None:
|
||||
context.entry = ConfigService.get_entry(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then(r'the resolved value should be "(?P<value>[^"]+)"')
|
||||
def step_resolved_value(context: Context, value: str) -> None:
|
||||
assert str(context.resolved.value) == value, (
|
||||
f"Expected '{value}', got '{context.resolved.value}'"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the resolved source should be "(?P<source>[^"]+)"')
|
||||
def step_resolved_source(context: Context, source: str) -> None:
|
||||
assert context.resolved.source.value == source, (
|
||||
f"Expected source '{source}', got '{context.resolved.source.value}'"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the env var name should be "(?P<expected>[^"]+)"')
|
||||
def step_env_var_name(context: Context, expected: str) -> None:
|
||||
assert context.env_var_name == expected, (
|
||||
f"Expected '{expected}', got '{context.env_var_name}'"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the config ValueError message should contain "(?P<fragment>[^"]+)"')
|
||||
def step_config_value_error_raised(context: Context, fragment: str) -> None:
|
||||
assert context.raised_error is not None, "Expected ValueError but none was raised"
|
||||
assert isinstance(context.raised_error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.raised_error).__name__}"
|
||||
)
|
||||
assert fragment in str(context.raised_error), (
|
||||
f"Expected '{fragment}' in message: {context.raised_error}"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the config TypeError message should contain "(?P<fragment>[^"]+)"')
|
||||
def step_config_type_error_raised(context: Context, fragment: str) -> None:
|
||||
assert context.raised_error is not None, "Expected TypeError but none was raised"
|
||||
assert isinstance(context.raised_error, TypeError), (
|
||||
f"Expected TypeError, got {type(context.raised_error).__name__}"
|
||||
)
|
||||
assert fragment in str(context.raised_error), (
|
||||
f"Expected '{fragment}' in message: {context.raised_error}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the resolved value should be integer (?P<value>\d+)")
|
||||
def step_resolved_int(context: Context, value: str) -> None:
|
||||
expected = int(value)
|
||||
assert context.resolved.value == expected, (
|
||||
f"Expected {expected}, got {context.resolved.value}"
|
||||
)
|
||||
assert isinstance(context.resolved.value, int), (
|
||||
f"Expected int, got {type(context.resolved.value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resolved value should be boolean true")
|
||||
def step_resolved_bool_true(context: Context) -> None:
|
||||
assert context.resolved.value is True, (
|
||||
f"Expected True, got {context.resolved.value}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resolved value should be boolean false")
|
||||
def step_resolved_bool_false(context: Context) -> None:
|
||||
assert context.resolved.value is False, (
|
||||
f"Expected False, got {context.resolved.value}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the resolved value should be float (?P<value>[\d.]+)")
|
||||
def step_resolved_float(context: Context, value: str) -> None:
|
||||
expected = float(value)
|
||||
assert abs(context.resolved.value - expected) < 0.001, (
|
||||
f"Expected {expected}, got {context.resolved.value}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the resolution chain should have (?P<count>\d+) entries")
|
||||
def step_chain_length(context: Context, count: str) -> None:
|
||||
expected = int(count)
|
||||
assert len(context.resolved.chain) == expected, (
|
||||
f"Expected {expected} entries, got {len(context.resolved.chain)}"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the chain should include source "(?P<source>[^"]+)"')
|
||||
def step_chain_includes_source(context: Context, source: str) -> None:
|
||||
sources = [entry["source"] for entry in context.resolved.chain]
|
||||
assert source in sources, f"Expected source '{source}' in chain: {sources}"
|
||||
|
||||
|
||||
@then(r'the registry should contain key "(?P<key>[^"]+)"')
|
||||
def step_registry_contains(context: Context, key: str) -> None:
|
||||
registry = ConfigService.registry()
|
||||
assert key in registry, f"Key '{key}' not found in registry"
|
||||
|
||||
|
||||
@then(
|
||||
r'the config data should contain key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
|
||||
)
|
||||
def step_config_data_contains(context: Context, key: str, value: str) -> None:
|
||||
data = context.config_data
|
||||
assert key in data, f"Key '{key}' not in config data: {data}"
|
||||
assert str(data[key]) == value, (
|
||||
f"Expected '{value}' for key '{key}', got '{data[key]}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the config directory should exist")
|
||||
def step_config_dir_exists(context: Context) -> None:
|
||||
assert context._new_config_dir.exists(), (
|
||||
f"Config dir {context._new_config_dir} does not exist"
|
||||
)
|
||||
|
||||
|
||||
@then("the result should contain all registered keys")
|
||||
def step_result_has_all_keys(context: Context) -> None:
|
||||
registered = ConfigService.registered_keys()
|
||||
for key in registered:
|
||||
assert key in context.all_resolved, (
|
||||
f"Key '{key}' missing from resolve_all result"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the entry should have section "(?P<section>[^"]+)"')
|
||||
def step_entry_section(context: Context, section: str) -> None:
|
||||
assert context.entry is not None, "Entry should not be None"
|
||||
assert context.entry.section == section, (
|
||||
f"Expected section '{section}', got '{context.entry.section}'"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the entry should have python type "(?P<type_name>[^"]+)"')
|
||||
def step_entry_type(context: Context, type_name: str) -> None:
|
||||
assert context.entry is not None, "Entry should not be None"
|
||||
assert context.entry.python_type.__name__ == type_name, (
|
||||
f"Expected type '{type_name}', got '{context.entry.python_type.__name__}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the entry should be None")
|
||||
def step_entry_is_none(context: Context) -> None:
|
||||
assert context.entry is None, f"Expected None, got {context.entry}"
|
||||
|
||||
|
||||
# Restore default step matcher for other step files
|
||||
use_step_matcher("parse")
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Step definitions for phase reversion state machine feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
InvalidPhaseTransitionError,
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import PlanError
|
||||
from cleveragents.domain.models.core.action import ActionArgument
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
InvariantSource,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
|
||||
@given("a plan lifecycle service is initialized")
|
||||
def step_init_service(context: Context) -> None:
|
||||
"""Initialize a PlanLifecycleService for testing."""
|
||||
settings = Settings()
|
||||
context.service = PlanLifecycleService(settings=settings)
|
||||
|
||||
|
||||
@given('an action "{action_name}" exists')
|
||||
def step_create_action(context: Context, action_name: str) -> None:
|
||||
"""Create a test action."""
|
||||
service: PlanLifecycleService = context.service
|
||||
service.create_action(
|
||||
name=action_name,
|
||||
description=f"Test action {action_name}",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/test-strategist",
|
||||
execution_actor="local/test-executor",
|
||||
)
|
||||
context.action_name = action_name
|
||||
|
||||
|
||||
@given('a plan is created from action "{action_name}"')
|
||||
def step_create_plan(context: Context, action_name: str) -> None:
|
||||
"""Use an action to create a plan."""
|
||||
service: PlanLifecycleService = context.service
|
||||
plan = service.use_action(action_name)
|
||||
context.plan = plan
|
||||
context.plan_id = plan.identity.plan_id
|
||||
|
||||
|
||||
@given('a plan is created from action "{action_name}" with arguments')
|
||||
def step_create_plan_with_args(context: Context, action_name: str) -> None:
|
||||
"""Use an action to create a plan with arguments and project links."""
|
||||
service: PlanLifecycleService = context.service
|
||||
# Re-create the action with an argument definition
|
||||
service._actions.pop(str(action_name), None)
|
||||
service.create_action(
|
||||
name=action_name,
|
||||
description=f"Test action {action_name}",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/test-strategist",
|
||||
execution_actor="local/test-executor",
|
||||
arguments=[
|
||||
ActionArgument(name="target_coverage", arg_type="integer"),
|
||||
],
|
||||
)
|
||||
plan = service.use_action(
|
||||
action_name,
|
||||
project_links=[ProjectLink(project_name="local/test-project")],
|
||||
arguments={"target_coverage": 80},
|
||||
invariants=[
|
||||
PlanInvariant(text="No regressions", source=InvariantSource.PLAN),
|
||||
],
|
||||
)
|
||||
context.plan = plan
|
||||
context.plan_id = plan.identity.plan_id
|
||||
|
||||
|
||||
@given("the plan is advanced to Apply phase with constrained state")
|
||||
def step_advance_to_apply_constrained(context: Context) -> None:
|
||||
"""Advance the plan through Strategize and Execute to Apply/constrained."""
|
||||
service: PlanLifecycleService = context.service
|
||||
plan_id: str = context.plan_id
|
||||
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
# If auto-progressed past strategize, still need to handle execute
|
||||
if plan.phase == PlanPhase.EXECUTE:
|
||||
service.start_execute(plan_id)
|
||||
service.complete_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
elif plan.phase == PlanPhase.STRATEGIZE:
|
||||
service.execute_plan(plan_id)
|
||||
service.start_execute(plan_id)
|
||||
service.complete_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
if plan.phase == PlanPhase.EXECUTE:
|
||||
service.apply_plan(plan_id)
|
||||
if plan.phase != PlanPhase.APPLY:
|
||||
# Force phase for testing
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.phase = PlanPhase.APPLY
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.processing_state != ProcessingState.CONSTRAINED:
|
||||
service.start_apply(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
plan.error_message = "Constraints too restrictive"
|
||||
|
||||
context.plan = service.get_plan(plan_id)
|
||||
|
||||
|
||||
@given('the plan has automation profile "{profile_name}" allowing auto-reversion')
|
||||
def step_set_profile_allowing(context: Context, profile_name: str) -> None:
|
||||
"""Set a profile that allows auto-reversion."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name=profile_name,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
context.plan = plan
|
||||
|
||||
|
||||
@given('the plan has automation profile "{profile_name}" blocking auto-reversion')
|
||||
def step_set_profile_blocking(context: Context, profile_name: str) -> None:
|
||||
"""Set a profile that blocks auto-reversion."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name=profile_name,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
context.plan = plan
|
||||
|
||||
|
||||
@given("the plan is advanced to Execute phase")
|
||||
def step_advance_to_execute(context: Context) -> None:
|
||||
"""Advance the plan through Strategize to Execute phase."""
|
||||
service: PlanLifecycleService = context.service
|
||||
plan_id: str = context.plan_id
|
||||
|
||||
# Set manual profile to prevent auto-progress
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name="manual",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
context.plan = service.get_plan(plan_id)
|
||||
|
||||
|
||||
@given("the plan has been reverted 3 times")
|
||||
def step_set_reversion_count(context: Context) -> None:
|
||||
"""Set the reversion count to the maximum."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.reversion_count = 3
|
||||
# Advance to execute so reversion is structurally valid
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name="manual",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
service: PlanLifecycleService = context.service
|
||||
plan_id = context.plan_id
|
||||
if (
|
||||
plan.phase == PlanPhase.STRATEGIZE
|
||||
and plan.processing_state == ProcessingState.QUEUED
|
||||
):
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.reversion_count = 3
|
||||
context.plan = plan
|
||||
|
||||
|
||||
@given("the plan has reached applied terminal state")
|
||||
def step_set_applied(context: Context) -> None:
|
||||
"""Move the plan to the applied terminal state."""
|
||||
service: PlanLifecycleService = context.service
|
||||
plan_id: str = context.plan_id
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name="manual",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
service.start_execute(plan_id)
|
||||
service.complete_execute(plan_id)
|
||||
service.apply_plan(plan_id)
|
||||
service.start_apply(plan_id)
|
||||
service.complete_apply(plan_id)
|
||||
context.plan = service.get_plan(plan_id)
|
||||
|
||||
|
||||
@given("the plan is in Strategize phase")
|
||||
def step_plan_in_strategize(context: Context) -> None:
|
||||
"""Verify plan is in Strategize phase."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
assert plan.phase == PlanPhase.STRATEGIZE
|
||||
|
||||
|
||||
@given("the plan has been cancelled")
|
||||
def step_cancel_plan(context: Context) -> None:
|
||||
"""Cancel the plan."""
|
||||
service: PlanLifecycleService = context.service
|
||||
service.cancel_plan(context.plan_id, reason="test cancellation")
|
||||
context.plan = service.get_plan(context.plan_id)
|
||||
|
||||
|
||||
@when('auto-reversion from apply is attempted with reason "{reason}"')
|
||||
def step_try_auto_revert_apply(context: Context, reason: str) -> None:
|
||||
"""Try auto-reversion from apply."""
|
||||
service: PlanLifecycleService = context.service
|
||||
context.plan = service.try_auto_revert_from_apply(context.plan_id, reason)
|
||||
|
||||
|
||||
@when('the plan is manually reverted to Strategize with reason "{reason}"')
|
||||
def step_manual_revert(context: Context, reason: str) -> None:
|
||||
"""Manually revert the plan to Strategize."""
|
||||
service: PlanLifecycleService = context.service
|
||||
context.plan = service.revert_plan(
|
||||
context.plan_id, PlanPhase.STRATEGIZE, reason=reason
|
||||
)
|
||||
|
||||
|
||||
@when("a reversion is attempted on the maxed-out plan")
|
||||
def step_attempt_reversion_maxed(context: Context) -> None:
|
||||
"""Attempt reversion on a plan that has hit the max."""
|
||||
service: PlanLifecycleService = context.service
|
||||
try:
|
||||
context.plan = service.revert_plan(
|
||||
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
|
||||
)
|
||||
context.raised_error = None
|
||||
except PlanError as e:
|
||||
context.raised_error = e
|
||||
|
||||
|
||||
@when('auto-reversion from execute is attempted with reason "{reason}"')
|
||||
def step_try_auto_revert_execute(context: Context, reason: str) -> None:
|
||||
"""Try auto-reversion from execute."""
|
||||
service: PlanLifecycleService = context.service
|
||||
context.plan = service.try_auto_revert_from_execute(context.plan_id, reason)
|
||||
|
||||
|
||||
@when("a reversion is attempted on the terminal plan")
|
||||
def step_attempt_reversion_terminal(context: Context) -> None:
|
||||
"""Attempt reversion on a terminal plan."""
|
||||
service: PlanLifecycleService = context.service
|
||||
try:
|
||||
context.plan = service.revert_plan(
|
||||
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
|
||||
)
|
||||
context.raised_error = None
|
||||
except PlanError as e:
|
||||
context.raised_error = e
|
||||
|
||||
|
||||
@when("a reversion to Apply phase is attempted")
|
||||
def step_attempt_reversion_wrong_phase(context: Context) -> None:
|
||||
"""Attempt reversion to an invalid target phase."""
|
||||
service: PlanLifecycleService = context.service
|
||||
try:
|
||||
context.plan = service.revert_plan(
|
||||
context.plan_id, PlanPhase.APPLY, reason="should fail"
|
||||
)
|
||||
context.raised_error = None
|
||||
except InvalidPhaseTransitionError as e:
|
||||
context.raised_error = e
|
||||
|
||||
|
||||
@when("the plan is re-advanced to Execute phase")
|
||||
def step_re_advance_to_execute(context: Context) -> None:
|
||||
"""Re-advance the plan to Execute after reversion."""
|
||||
service: PlanLifecycleService = context.service
|
||||
plan_id: str = context.plan_id
|
||||
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
context.plan = service.get_plan(plan_id)
|
||||
|
||||
|
||||
@when("a reversion is attempted on the cancelled plan")
|
||||
def step_attempt_reversion_cancelled(context: Context) -> None:
|
||||
"""Attempt reversion on a cancelled plan."""
|
||||
service: PlanLifecycleService = context.service
|
||||
try:
|
||||
context.plan = service.revert_plan(
|
||||
context.plan_id, PlanPhase.STRATEGIZE, reason="should fail"
|
||||
)
|
||||
context.raised_error = None
|
||||
except PlanError as e:
|
||||
context.raised_error = e
|
||||
|
||||
|
||||
@then("the plan should be in Strategize phase")
|
||||
def step_check_strategize(context: Context) -> None:
|
||||
"""Verify plan is in Strategize phase."""
|
||||
assert context.plan.phase == PlanPhase.STRATEGIZE
|
||||
|
||||
|
||||
@then("the plan processing state should be queued")
|
||||
def step_check_queued(context: Context) -> None:
|
||||
"""Verify plan processing state is QUEUED."""
|
||||
assert context.plan.processing_state == ProcessingState.QUEUED
|
||||
|
||||
|
||||
@then("the plan reversion count should be {count:d}")
|
||||
def step_check_reversion_count(context: Context, count: int) -> None:
|
||||
"""Verify plan reversion count."""
|
||||
assert context.plan.reversion_count == count
|
||||
|
||||
|
||||
@then(
|
||||
'a reversion decision should be recorded with source "{source}" and target "{target}"'
|
||||
)
|
||||
def step_check_reversion_decision(context: Context, source: str, target: str) -> None:
|
||||
"""Verify a reversion decision was recorded."""
|
||||
decisions = context.plan.decisions
|
||||
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
|
||||
assert len(reversion_decisions) > 0
|
||||
latest = reversion_decisions[-1]
|
||||
assert latest["source_phase"] == source
|
||||
assert latest["target_phase"] == target
|
||||
assert "timestamp" in latest
|
||||
|
||||
|
||||
@then('a reversion decision should be recorded with reason "{reason}"')
|
||||
def step_check_reversion_reason(context: Context, reason: str) -> None:
|
||||
"""Verify a reversion decision with specific reason was recorded."""
|
||||
decisions = context.plan.decisions
|
||||
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
|
||||
assert len(reversion_decisions) > 0
|
||||
latest = reversion_decisions[-1]
|
||||
assert reason in latest["reason"]
|
||||
|
||||
|
||||
@then("a PlanError should be raised about max reversions")
|
||||
def step_check_max_reversion_error(context: Context) -> None:
|
||||
"""Verify a PlanError was raised about max reversions."""
|
||||
assert context.raised_error is not None
|
||||
assert isinstance(context.raised_error, PlanError)
|
||||
assert (
|
||||
"maximum reversion count" in str(context.raised_error).lower()
|
||||
or "max" in str(context.raised_error).lower()
|
||||
)
|
||||
|
||||
|
||||
@then("the plan should remain in Apply phase")
|
||||
def step_check_still_apply(context: Context) -> None:
|
||||
"""Verify plan is still in Apply phase."""
|
||||
assert context.plan.phase == PlanPhase.APPLY
|
||||
|
||||
|
||||
@then("the plan processing state should be constrained")
|
||||
def step_check_constrained(context: Context) -> None:
|
||||
"""Verify plan processing state is CONSTRAINED."""
|
||||
assert context.plan.processing_state == ProcessingState.CONSTRAINED
|
||||
|
||||
|
||||
@then("a PlanError should be raised about terminal state")
|
||||
def step_check_terminal_error(context: Context) -> None:
|
||||
"""Verify a PlanError was raised about terminal state."""
|
||||
assert context.raised_error is not None
|
||||
assert isinstance(context.raised_error, PlanError)
|
||||
assert "terminal" in str(context.raised_error).lower()
|
||||
|
||||
|
||||
@then("an InvalidPhaseTransitionError should be raised")
|
||||
def step_check_invalid_transition_error(context: Context) -> None:
|
||||
"""Verify an InvalidPhaseTransitionError was raised."""
|
||||
assert context.raised_error is not None
|
||||
assert isinstance(context.raised_error, InvalidPhaseTransitionError)
|
||||
|
||||
|
||||
@then("the plan arguments should be preserved")
|
||||
def step_check_arguments_preserved(context: Context) -> None:
|
||||
"""Verify plan arguments are preserved after reversion."""
|
||||
assert context.plan.arguments == {"target_coverage": 80}
|
||||
|
||||
|
||||
@then("the plan project links should be preserved")
|
||||
def step_check_project_links_preserved(context: Context) -> None:
|
||||
"""Verify plan project links are preserved after reversion."""
|
||||
assert len(context.plan.project_links) == 1
|
||||
assert context.plan.project_links[0].project_name == "local/test-project"
|
||||
|
||||
|
||||
@then("the plan invariants should be preserved")
|
||||
def step_check_invariants_preserved(context: Context) -> None:
|
||||
"""Verify plan invariants are preserved after reversion."""
|
||||
assert len(context.plan.invariants) >= 1
|
||||
texts = [inv.text for inv in context.plan.invariants]
|
||||
assert "No regressions" in texts
|
||||
|
||||
|
||||
@then("there should be {count:d} reversion decisions recorded")
|
||||
def step_check_decision_count(context: Context, count: int) -> None:
|
||||
"""Verify the number of reversion decisions."""
|
||||
decisions = context.plan.decisions
|
||||
reversion_decisions = [d for d in decisions if d["type"] == "reversion"]
|
||||
assert len(reversion_decisions) == count
|
||||
|
||||
|
||||
@then("the plan in Strategize phase cannot revert to Apply")
|
||||
def step_check_no_revert_strat_to_apply(context: Context) -> None:
|
||||
"""Verify Strategize cannot revert to Apply."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.phase = PlanPhase.STRATEGIZE
|
||||
assert not plan.can_revert_to(PlanPhase.APPLY)
|
||||
|
||||
|
||||
@then("the plan in Strategize phase cannot revert to Action")
|
||||
def step_check_no_revert_strat_to_action(context: Context) -> None:
|
||||
"""Verify Strategize cannot revert to Action."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.phase = PlanPhase.STRATEGIZE
|
||||
assert not plan.can_revert_to(PlanPhase.ACTION)
|
||||
|
||||
|
||||
@then("the plan in Execute phase can revert to Strategize")
|
||||
def step_check_revert_exec_to_strat(context: Context) -> None:
|
||||
"""Verify Execute can revert to Strategize."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.phase = PlanPhase.EXECUTE
|
||||
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
|
||||
@then("the plan in Apply phase can revert to Strategize")
|
||||
def step_check_revert_apply_to_strat(context: Context) -> None:
|
||||
"""Verify Apply can revert to Strategize."""
|
||||
plan = context.service.get_plan(context.plan_id)
|
||||
plan.phase = PlanPhase.APPLY
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
|
||||
@then("the plan should remain in Execute phase")
|
||||
def step_check_still_execute(context: Context) -> None:
|
||||
"""Verify plan is still in Execute phase."""
|
||||
assert context.plan.phase == PlanPhase.EXECUTE
|
||||
+106
-61
@@ -2168,6 +2168,50 @@ Each schedule update entry must include: timeline reference, summary, milestone
|
||||
| Total | 43/62 | 8/14 | 8/29 | 2/24 | 13/21 | 0/1 | 74/151 |
|
||||
|
||||
|
||||
### A9.service: Config Service with Multi-Level Resolution
|
||||
|
||||
**Status: [X] COMPLETE** (Day 22, 2026-02-23)
|
||||
|
||||
#### A9.service Implementation Notes
|
||||
|
||||
- 2026-02-23 [Jeff]: Implemented `ConfigService` in `src/cleveragents/application/services/config_service.py` with:
|
||||
- Five-level resolution chain: CLI flag > env var > project-scoped > global config > default
|
||||
- Typed config key registry with 24 entries across 6 sections: `core.*` (7), `plan.*` (3), `provider.*` (4), `sandbox.*` (3), `context.*` (3), `index.*` (4)
|
||||
- TOML file management (`~/.cleveragents/config.toml`) with auto-created parent directories via `tomlkit`
|
||||
- `ResolvedValue` return type includes winning value, source level, and optional verbose chain
|
||||
- Env var interpolation following `CLEVERAGENTS_<SECTION>_<KEY>` convention
|
||||
- Validation: unknown keys raise `ValueError` with sample of valid keys; type mismatches raise `TypeError` with actionable messages
|
||||
- Boolean coercion from string: true/false, 1/0, yes/no (case-insensitive)
|
||||
- 2026-02-23 [Jeff]: Added `docs/reference/config_resolution.md` documenting all 24 config keys with types, defaults, env var mappings, and precedence rules
|
||||
- 2026-02-23 [Jeff]: Added `features/config_resolution.feature` with 31 Behave scenarios covering every resolution level, env var overrides, type coercion, unknown key rejection, TOML roundtrips, and verbose chain output
|
||||
- 2026-02-23 [Jeff]: Added `robot/config_resolution.robot` with 8 Robot Framework end-to-end tests exercising default/global/env/CLI resolution, unknown key rejection, type coercion, and env var convention
|
||||
- 2026-02-23 [Jeff]: Added `benchmarks/config_resolution_bench.py` with 6 ASV benchmark suites for resolution performance at each level, bulk operations, and validation throughput
|
||||
- 2026-02-23 [Jeff]: Exported `ConfigService`, `ConfigEntry`, `ConfigLevel`, `ResolvedValue` from `application.services.__init__`; updated vulture whitelist for new public API
|
||||
|
||||
### CLI1.alpha: Plan/Action CLI Extensions
|
||||
|
||||
**Status: [X] COMPLETE**
|
||||
|
||||
#### CLI1.alpha Notes
|
||||
|
||||
- 2026-02-23: Implemented CLI1.alpha (Jeff). Key changes:
|
||||
- Added `validate_namespaced_actor()` to `plan.py` for strict `namespace/name` validation on actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`)
|
||||
- Updated `_plan_spec_dict()` to include `estimation_actor`, `invariant_actor`, and `invariants` fields in all output formats (json, yaml, plain, table)
|
||||
- Updated `_print_lifecycle_plan()` rich panel to display invariants with source provenance tags
|
||||
- Updated `plan_status` summary table to include Profile and Invariants columns
|
||||
- Updated `lifecycle_list_plans` table to include Profile and Invariants columns
|
||||
- Extended `action.py` `_action_spec_dict()` to conditionally include `estimation_actor`, `invariant_actor`, and `inputs_schema` fields
|
||||
- Extended `_print_action()` rich panel to display estimation actor, invariant actor, invariants list, inputs_schema, and automation_profile
|
||||
- `--automation-profile` flag on `plan use` validates against `BUILTIN_PROFILES` and persists to plan metadata
|
||||
- `--invariant` flags on `plan use` are passed through to `PlanLifecycleService.use_action()` as `PlanInvariant` objects with `InvariantSource.PLAN`
|
||||
- Created `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` reference documentation
|
||||
- Created `features/cli_extensions.feature` with 25 Behave scenarios covering all new functionality
|
||||
- Created `features/steps/cli_extensions_steps.py` with all step definitions
|
||||
- Created `robot/cli_extensions.robot` with 5 Robot Framework integration tests
|
||||
- Created `benchmarks/cli_extensions_bench.py` with 6 ASV benchmark suites
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
This comprehensive checklist tracks all implementation tasks for the CleverAgents project. Each phase item includes mandatory **Code**, **Document**, and **Tests** bullets. Only mark the parent complete when every sub-bullet (including any spawned `Fix - ...` remediation tasks) is checked.
|
||||
@@ -2864,27 +2908,27 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
|
||||
- [ ] Git [Luis]: `git push -u origin feature/m4-subplan-execution`
|
||||
- [ ] Forgejo PR [Luis]: Open PR from `feature/m4-subplan-execution` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: M4.5.phase-reversion | Branch: feature/m4-phase-reversion | Planned: Day 21 | Expected: Day 26) - Commit message: "feat(plan): add phase reversion state machine"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m4-phase-reversion`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Implement Execute-to-Strategize reversion in `PlanLifecycleService` when constraints are too restrictive (validation failures block apply and automation profile permits reversion).
|
||||
- [ ] Code [Jeff]: Implement Apply-to-Strategize reversion from `constrained` terminal state, resetting phase to Strategize with preserved context and sandbox state.
|
||||
- [ ] Code [Jeff]: Add `auto_reversion_from_apply` threshold check from the resolved `AutomationProfile` — only auto-revert if the profile's `auto_reversion_from_apply` flag is set.
|
||||
- [ ] Code [Jeff]: Record each reversion as a `reversion` decision in the plan's decision tree with source phase, target phase, reason, and timestamp.
|
||||
- [ ] Code [Jeff]: Wire reversion into `PlanLifecycleService` phase transition logic with explicit guard against infinite reversion loops (max 3 reversions per plan execution).
|
||||
- [ ] Code [Jeff]: Add `plan revert <plan_id> --to-phase <phase>` CLI command for manual reversion.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/phase_reversion.md` documenting reversion triggers, automation profile thresholds, and loop guards.
|
||||
- [ ] Tests (Behave) [Jeff]: Add `features/phase_reversion.feature` with scenarios for auto-reversion, manual reversion, loop guard, and profile-gated behavior.
|
||||
- [ ] Tests (Robot) [Jeff]: Add `robot/phase_reversion.robot` for end-to-end reversion flow.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/phase_reversion_bench.py` for reversion overhead.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(plan): add phase reversion state machine"`
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m4-phase-reversion`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-phase-reversion` to `master` with a suitable and thorough description
|
||||
- [X] **COMMIT (Owner: Jeff | Group: M4.5.phase-reversion | Branch: feature/m4-phase-reversion | Planned: Day 21 | Expected: Day 26) - Commit message: "feat(plan): add phase reversion state machine"** Done: 2026-02-23
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m4-phase-reversion`
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Code [Jeff]: Implement Execute-to-Strategize reversion in `PlanLifecycleService` when constraints are too restrictive (validation failures block apply and automation profile permits reversion).
|
||||
- [X] Code [Jeff]: Implement Apply-to-Strategize reversion from `constrained` terminal state, resetting phase to Strategize with preserved context and sandbox state.
|
||||
- [X] Code [Jeff]: Add `auto_reversion_from_apply` threshold check from the resolved `AutomationProfile` — only auto-revert if the profile's `auto_reversion_from_apply` flag is set.
|
||||
- [X] Code [Jeff]: Record each reversion as a `reversion` decision in the plan's decision tree with source phase, target phase, reason, and timestamp.
|
||||
- [X] Code [Jeff]: Wire reversion into `PlanLifecycleService` phase transition logic with explicit guard against infinite reversion loops (max 3 reversions per plan execution).
|
||||
- [X] Code [Jeff]: Add `plan revert <plan_id> --to-phase <phase>` CLI command for manual reversion.
|
||||
- [X] Docs [Jeff]: Add `docs/reference/phase_reversion.md` documenting reversion triggers, automation profile thresholds, and loop guards.
|
||||
- [X] Tests (Behave) [Jeff]: Add `features/phase_reversion.feature` with scenarios for auto-reversion, manual reversion, loop guard, and profile-gated behavior.
|
||||
- [X] Tests (Robot) [Jeff]: Add `robot/phase_reversion.robot` for end-to-end reversion flow.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/phase_reversion_bench.py` for reversion overhead.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(plan): add phase reversion state machine"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m4-phase-reversion`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m4-phase-reversion` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Luis | Group: M4.6.error-recovery | Branch: feature/m4-error-recovery | Planned: Day 22 | Expected: Day 26) - Commit message: "feat(plan): add error recovery patterns and CLI hints"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
@@ -3607,6 +3651,7 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
- 2026-02-13: A2b.beta (plan model alignment) completed on `feature/m1-plan-model`. Rebased onto A2b.alpha (`fd6d41b`), resolved 30 merge conflicts. Key decisions: `processing_state` field name (not `state`), `InvariantSource` enum (not `InvariantScope`), `project_links` (not `project_ids`), HEAD's `action.py` authoritative. All tests green: 130 Behave features (2246 scenarios), 206 Robot tests, plan.py 100% coverage. See Development Log entry for full details.
|
||||
- 2026-02-13: New test artifacts created for plan model coverage: `features/plan_model_coverage.feature` (39 scenarios), `features/steps/plan_model_coverage_steps.py` (489 lines), `benchmarks/plan_model_bench.py` (15 benchmarks), `docs/reference/plan_model.md` (~270 lines).
|
||||
- 2026-02-13: Robot integration test fixes applied post-rebase: `robot/helper_plan_lifecycle_v3.py` (missing `description` param), `robot/helper_db_lifecycle_models.py` (`state=` → `processing_state=`, `project_names` → `project_links`).
|
||||
- 2026-02-23: M4.5.phase-reversion completed on `feature/m4-phase-reversion`. Added phase reversion state machine allowing plans to revert from Execute→Strategize and Apply→Strategize when constraints prove unworkable. Key implementation: `reversion_count` field + loop guard (max 3), `decisions` list for reversion decision recording, `can_revert_to()` validation method on Plan model. `PlanLifecycleService` gained `revert_plan()` (manual), `try_auto_revert_from_apply()`, and `try_auto_revert_from_execute()` (both gated by `AutomationProfile.auto_reversion_from_apply` threshold). Pre-existing `VALID_PHASE_TRANSITIONS` already included reversion paths. New artifacts: `features/phase_reversion.feature` (12 scenarios), `features/steps/phase_reversion_steps.py`, `robot/phase_reversion.robot` (8 tests), `benchmarks/phase_reversion_bench.py`, `docs/reference/phase_reversion.md`, CLI `plan revert` command. All nox stages pass: lint, typecheck, unit_tests (12/12 behave), integration_tests (8/8 robot).
|
||||
|
||||
**WEEK 1 - CRITICAL PATH**
|
||||
|
||||
@@ -4318,27 +4363,27 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
**PARALLEL SUBTRACK A9.project [Luis]**: Project-scoped config persistence
|
||||
**SEQUENTIAL MERGE NOTE**: A9.service lands before A9.project.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: A9.service | Branch: feature/m3-config-service | Planned: Day 14 | Expected: Day 22) - Commit message: "feat(config): add config service with multi-level resolution"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m3-config-service`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Implement `ConfigService` with TOML file management (`~/.cleveragents/config.toml`), read/write operations, and parent directory auto-creation.
|
||||
- [ ] Code [Jeff]: Implement multi-level resolution chain: CLI flag > environment variable > project-scoped > global config > default. Each `config get` call returns the winning value plus the source level that provided it.
|
||||
- [ ] Code [Jeff]: Add config key registry with typed entries: key name, Python type, default value, env var mapping (`CLEVERAGENTS_*`), project-scopability flag, and description. Register the complete catalog per spec (`core.*`, `plan.*`, `provider.*`, `sandbox.*`, `context.*`, `index.*`).
|
||||
- [ ] Code [Jeff]: Wire `config get` output to display the resolution chain (which level provided the winning value) when `--verbose` is passed.
|
||||
- [ ] Code [Jeff]: Add env var interpolation for all registered keys following the `CLEVERAGENTS_<SECTION>_<KEY>` convention.
|
||||
- [ ] Code [Jeff]: Add validation that rejects unknown keys and type-mismatched values with actionable error messages.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/config_resolution.md` documenting the resolution chain, all config keys, their types, defaults, and env var mappings.
|
||||
- [ ] Tests (Behave) [Jeff]: Add `features/config_resolution.feature` with scenarios for each resolution level, env var overrides, unknown key rejection, and type validation.
|
||||
- [ ] Tests (Robot) [Jeff]: Add `robot/config_resolution.robot` for end-to-end config get/set with env var interactions.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/config_resolution_bench.py` for resolution chain performance.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(config): add config service with multi-level resolution"`
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m3-config-service`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-config-service` to `master` with a suitable and thorough description
|
||||
- [X] **COMMIT (Owner: Jeff | Group: A9.service | Branch: feature/m3-config-service | Planned: Day 14 | Expected: Day 22) - Commit message: "feat(config): add config service with multi-level resolution"**
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m3-config-service`
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Code [Jeff]: Implement `ConfigService` with TOML file management (`~/.cleveragents/config.toml`), read/write operations, and parent directory auto-creation.
|
||||
- [X] Code [Jeff]: Implement multi-level resolution chain: CLI flag > environment variable > project-scoped > global config > default. Each `config get` call returns the winning value plus the source level that provided it.
|
||||
- [X] Code [Jeff]: Add config key registry with typed entries: key name, Python type, default value, env var mapping (`CLEVERAGENTS_*`), project-scopability flag, and description. Register the complete catalog per spec (`core.*`, `plan.*`, `provider.*`, `sandbox.*`, `context.*`, `index.*`).
|
||||
- [X] Code [Jeff]: Wire `config get` output to display the resolution chain (which level provided the winning value) when `--verbose` is passed.
|
||||
- [X] Code [Jeff]: Add env var interpolation for all registered keys following the `CLEVERAGENTS_<SECTION>_<KEY>` convention.
|
||||
- [X] Code [Jeff]: Add validation that rejects unknown keys and type-mismatched values with actionable error messages.
|
||||
- [X] Docs [Jeff]: Add `docs/reference/config_resolution.md` documenting the resolution chain, all config keys, their types, defaults, and env var mappings.
|
||||
- [X] Tests (Behave) [Jeff]: Add `features/config_resolution.feature` with scenarios for each resolution level, env var overrides, unknown key rejection, and type validation.
|
||||
- [X] Tests (Robot) [Jeff]: Add `robot/config_resolution.robot` for end-to-end config get/set with env var interactions.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/config_resolution_bench.py` for resolution chain performance.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark).
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(config): add config service with multi-level resolution"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m3-config-service`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-config-service` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Luis | Group: A9.project | Branch: feature/m4-config-project-scope | Planned: Day 18 | Expected: Day 26) - Commit message: "feat(config): add project-scoped config overrides"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
@@ -6175,25 +6220,25 @@ By Day 30, the system must be able to (all in LOCAL MODE, server connectivity is
|
||||
**PARALLEL SUBTRACK CLI1.beta [Brent]**: Extended CLI tests + output snapshots
|
||||
**SEQUENTIAL MERGE NOTE**: CLI1.alpha depends on A6 automation profiles + D2 invariants; CLI1.beta runs after CLI1.alpha.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: CLI1.alpha | Branch: feature/m4-cli-extensions | Planned: Day 11 | Expected: Day 26) - Commit message: "feat(cli): add action and plan CLI extensions"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m4-cli-extensions`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Add `--automation-profile` and `--invariant` flags to `plan use` and persist to plan metadata.
|
||||
- [ ] Code [Jeff]: Add actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`) with namespaced validation.
|
||||
- [ ] Code [Jeff]: Update `plan status/list` output to include automation_profile + invariants when present.
|
||||
- [ ] Code [Jeff]: Extend `action show` output with optional actors, invariants, and inputs_schema when present.
|
||||
- [ ] Docs [Jeff]: Update `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` with extended flags + examples.
|
||||
- [ ] Tests (Behave) [Jeff]: Add scenarios for automation_profile/invariant flags and actor override validation.
|
||||
- [ ] Tests (Robot) [Jeff]: Add Robot flow for `plan use` with invariants + automation profile.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/cli_extensions_bench.py` for parsing overhead.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(cli): add action and plan CLI extensions"` (run after the coverage check below passes)
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m4-cli-extensions`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m4-cli-extensions` to `master` with a suitable and thorough description
|
||||
- [X] **COMMIT (Owner: Jeff | Group: CLI1.alpha | Branch: feature/m4-cli-extensions | Planned: Day 11 | Expected: Day 26) - Commit message: "feat(cli): add action and plan CLI extensions"**
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m4-cli-extensions`
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Code [Jeff]: Add `--automation-profile` and `--invariant` flags to `plan use` and persist to plan metadata.
|
||||
- [X] Code [Jeff]: Add actor override flags (`--strategy-actor`, `--execution-actor`, `--estimation-actor`, `--invariant-actor`) with namespaced validation.
|
||||
- [X] Code [Jeff]: Update `plan status/list` output to include automation_profile + invariants when present.
|
||||
- [X] Code [Jeff]: Extend `action show` output with optional actors, invariants, and inputs_schema when present.
|
||||
- [X] Docs [Jeff]: Update `docs/reference/plan_cli.md` and `docs/reference/action_cli.md` with extended flags + examples.
|
||||
- [X] Tests (Behave) [Jeff]: Add scenarios for automation_profile/invariant flags and actor override validation.
|
||||
- [X] Tests (Robot) [Jeff]: Add Robot flow for `plan use` with invariants + automation profile.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/cli_extensions_bench.py` for parsing overhead.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(cli): add action and plan CLI extensions"` (run after the coverage check below passes)
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m4-cli-extensions`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m4-cli-extensions` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Brent | Group: CLI1.beta | Branch: feature/m4-cli-extension-tests | Planned: Day 12 | Expected: Day 26) - Commit message: "test(cli): cover action and plan extensions"**
|
||||
- [ ] Git [Brent]: `git checkout master`
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for plan use with invariants, automation profiles, and actor validation
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_cli_extensions.py
|
||||
|
||||
*** Test Cases ***
|
||||
Plan Use With Invariants
|
||||
[Documentation] Verify that ``plan use --invariant`` passes invariants correctly
|
||||
${result}= Run Process ${PYTHON} ${HELPER} plan-use-invariants cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cli-ext-plan-use-invariants-ok
|
||||
|
||||
Plan Use With Automation Profile
|
||||
[Documentation] Verify that ``plan use --automation-profile`` works
|
||||
${result}= Run Process ${PYTHON} ${HELPER} plan-use-profile cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cli-ext-plan-use-profile-ok
|
||||
|
||||
Plan Use Actor Validation Valid
|
||||
[Documentation] Verify valid namespaced actor format is accepted
|
||||
${result}= Run Process ${PYTHON} ${HELPER} actor-valid cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cli-ext-actor-valid-ok
|
||||
|
||||
Plan Use Actor Validation Invalid
|
||||
[Documentation] Verify invalid actor format is rejected
|
||||
${result}= Run Process ${PYTHON} ${HELPER} actor-invalid cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cli-ext-actor-invalid-ok
|
||||
|
||||
Plan Use Combined Profile And Invariants
|
||||
[Documentation] Verify plan use with both profile and invariants
|
||||
${result}= Run Process ${PYTHON} ${HELPER} plan-combined cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} cli-ext-plan-combined-ok
|
||||
@@ -0,0 +1,73 @@
|
||||
*** Settings ***
|
||||
Documentation End-to-end tests for Config Service with multi-level resolution
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_config_resolution.py
|
||||
|
||||
*** Test Cases ***
|
||||
Config Resolution Returns Default Value
|
||||
[Documentation] Verify that resolving a key with no overrides returns the default
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-default cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-default-ok
|
||||
|
||||
Config Resolution Uses Global Config File
|
||||
[Documentation] Verify that a value set in the global TOML file overrides default
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-global cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-global-ok
|
||||
|
||||
Config Resolution Uses Environment Variable
|
||||
[Documentation] Verify that env var overrides global config
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-env cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-env-ok
|
||||
|
||||
Config Resolution Uses CLI Flag
|
||||
[Documentation] Verify that CLI flag has highest priority
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-cli cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-cli-ok
|
||||
|
||||
Config Resolution Rejects Unknown Key
|
||||
[Documentation] Verify that unknown keys raise ValueError
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-unknown-key cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-unknown-key-ok
|
||||
|
||||
Config Resolution Performs Type Coercion
|
||||
[Documentation] Verify that string env vars are coerced to proper types
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resolve-type-coercion cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-type-coercion-ok
|
||||
|
||||
Config Registry Contains Expected Keys
|
||||
[Documentation] Verify that the registry has the expected key catalog
|
||||
${result}= Run Process ${PYTHON} ${HELPER} registry-keys cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-registry-ok
|
||||
|
||||
Config Env Var Naming Convention
|
||||
[Documentation] Verify CLEVERAGENTS_<SECTION>_<KEY> naming convention
|
||||
${result}= Run Process ${PYTHON} ${HELPER} env-var-convention cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} config-resolution-env-convention-ok
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Helper script for cli_extensions.robot smoke tests.
|
||||
|
||||
Each subcommand is a self-contained check that prints a sentinel on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, 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 typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
|
||||
from cleveragents.domain.models.core.action import ( # noqa: E402
|
||||
Action,
|
||||
ActionState,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import ( # noqa: E402
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
InvariantSource,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
|
||||
|
||||
|
||||
def _mock_plan(
|
||||
*,
|
||||
automation_profile: AutomationProfileRef | None = None,
|
||||
invariants: list[PlanInvariant] | None = None,
|
||||
) -> Plan:
|
||||
now = datetime.now()
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=_PLAN_ULID),
|
||||
namespaced_name=NamespacedName.parse("local/test-plan"),
|
||||
description="Test plan for robot",
|
||||
definition_of_done="All tests pass",
|
||||
action_name="local/test-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
project_links=[ProjectLink(project_name="my-project")],
|
||||
arguments={},
|
||||
arguments_order=[],
|
||||
automation_profile=automation_profile,
|
||||
invariants=invariants or [],
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
timestamps=PlanTimestamps(created_at=now, updated_at=now),
|
||||
)
|
||||
|
||||
|
||||
def _mock_action() -> Action:
|
||||
return Action(
|
||||
namespaced_name=NamespacedName.parse("local/test-action"),
|
||||
description="Test action for robot",
|
||||
definition_of_done="All tests pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def plan_use_with_invariants() -> None:
|
||||
"""Verify plan use with --invariant flags works."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_action_by_name.return_value = _mock_action()
|
||||
mock_svc.use_action.return_value = _mock_plan(
|
||||
invariants=[
|
||||
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
|
||||
]
|
||||
)
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_svc,
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--invariant",
|
||||
"No warnings",
|
||||
],
|
||||
)
|
||||
if result.exit_code == 0:
|
||||
print("cli-ext-plan-use-invariants-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} output={result.output}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def plan_use_with_automation_profile() -> None:
|
||||
"""Verify plan use with --automation-profile works."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_action_by_name.return_value = _mock_action()
|
||||
mock_svc.use_action.return_value = _mock_plan(
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="trusted",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_svc,
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--automation-profile",
|
||||
"trusted",
|
||||
],
|
||||
)
|
||||
if result.exit_code == 0 and "trusted" in result.output:
|
||||
print("cli-ext-plan-use-profile-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} output={result.output}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def plan_use_actor_validation_valid() -> None:
|
||||
"""Verify valid namespaced actors are accepted."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_action_by_name.return_value = _mock_action()
|
||||
mock_svc.use_action.return_value = _mock_plan()
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_svc,
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--strategy-actor",
|
||||
"openai/gpt-4",
|
||||
],
|
||||
)
|
||||
if result.exit_code == 0:
|
||||
print("cli-ext-actor-valid-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} output={result.output}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def plan_use_actor_validation_invalid() -> None:
|
||||
"""Verify invalid actor format is rejected."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_action_by_name.return_value = _mock_action()
|
||||
mock_svc.use_action.return_value = _mock_plan()
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_svc,
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--strategy-actor",
|
||||
"bad-format",
|
||||
],
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
print("cli-ext-actor-invalid-ok")
|
||||
else:
|
||||
print(f"FAIL: expected non-zero exit, got {result.exit_code}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def plan_use_combined() -> None:
|
||||
"""Verify plan use with profile + invariants together."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_action_by_name.return_value = _mock_action()
|
||||
mock_svc.use_action.return_value = _mock_plan(
|
||||
automation_profile=AutomationProfileRef(
|
||||
profile_name="trusted",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
),
|
||||
invariants=[
|
||||
PlanInvariant(text="No regressions", source=InvariantSource.PLAN),
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_svc,
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"use",
|
||||
"local/test-action",
|
||||
"--automation-profile",
|
||||
"trusted",
|
||||
"--invariant",
|
||||
"No regressions",
|
||||
],
|
||||
)
|
||||
if result.exit_code == 0 and "trusted" in result.output:
|
||||
print("cli-ext-plan-combined-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} output={result.output}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMANDS: dict[str, object] = {
|
||||
"plan-use-invariants": plan_use_with_invariants,
|
||||
"plan-use-profile": plan_use_with_automation_profile,
|
||||
"actor-valid": plan_use_actor_validation_valid,
|
||||
"actor-invalid": plan_use_actor_validation_invalid,
|
||||
"plan-combined": plan_use_combined,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
|
||||
sys.exit(1)
|
||||
fn = _COMMANDS[sys.argv[1]]
|
||||
fn() # type: ignore[operator]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
def _make_service() -> tuple[ConfigService, Path]:
|
||||
"""Create a ConfigService with a temporary directory."""
|
||||
tmpdir = Path(tempfile.mkdtemp())
|
||||
svc = ConfigService(config_dir=tmpdir, config_path=tmpdir / "config.toml")
|
||||
return svc, tmpdir
|
||||
|
||||
|
||||
def resolve_default() -> None:
|
||||
"""Verify default resolution works."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
result = svc.resolve("core.log_level")
|
||||
if result.value == "INFO" and result.source == ConfigLevel.DEFAULT:
|
||||
print("config-resolution-default-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected INFO/default, got {result.value}/{result.source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_global() -> None:
|
||||
"""Verify global config file overrides 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:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_env() -> None:
|
||||
"""Verify env var overrides global config."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
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
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_cli() -> None:
|
||||
"""Verify CLI flag overrides env var."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_LOG_LEVEL"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
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
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_unknown_key() -> None:
|
||||
"""Verify unknown key raises ValueError."""
|
||||
svc, tmpdir = _make_service()
|
||||
try:
|
||||
svc.resolve("bogus.nonexistent")
|
||||
print("FAIL: expected ValueError", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
print("config-resolution-unknown-key-ok")
|
||||
finally:
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def resolve_type_coercion() -> None:
|
||||
"""Verify integer type coercion from env var."""
|
||||
svc, tmpdir = _make_service()
|
||||
env_key = "CLEVERAGENTS_CORE_SERVER_PORT"
|
||||
old_val = os.environ.get(env_key)
|
||||
try:
|
||||
os.environ[env_key] = "9090"
|
||||
result = svc.resolve("core.server_port")
|
||||
if result.value == 9090 and isinstance(result.value, int):
|
||||
print("config-resolution-type-coercion-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected 9090 (int), got {result.value} "
|
||||
f"({type(result.value).__name__})",
|
||||
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
|
||||
shutil.rmtree(str(tmpdir), ignore_errors=True)
|
||||
|
||||
|
||||
def registry_keys() -> None:
|
||||
"""Verify the registry contains expected keys."""
|
||||
registry = ConfigService.registry()
|
||||
expected = ["core.log_level", "plan.max_retries", "provider.temperature"]
|
||||
for key in expected:
|
||||
if key not in registry:
|
||||
print(f"FAIL: {key} not in registry", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("config-resolution-registry-ok")
|
||||
|
||||
|
||||
def env_var_convention() -> None:
|
||||
"""Verify env var naming convention."""
|
||||
env_name = ConfigService.env_var_for_key("core.log_level")
|
||||
if env_name == "CLEVERAGENTS_CORE_LOG_LEVEL":
|
||||
print("config-resolution-env-convention-ok")
|
||||
else:
|
||||
print(
|
||||
f"FAIL: expected CLEVERAGENTS_CORE_LOG_LEVEL, got {env_name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], None]] = {
|
||||
"resolve-default": resolve_default,
|
||||
"resolve-global": resolve_global,
|
||||
"resolve-env": resolve_env,
|
||||
"resolve-cli": resolve_cli,
|
||||
"resolve-unknown-key": resolve_unknown_key,
|
||||
"resolve-type-coercion": resolve_type_coercion,
|
||||
"registry-keys": registry_keys,
|
||||
"env-var-convention": env_var_convention,
|
||||
}
|
||||
|
||||
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]]()
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Helper script for Robot Framework phase reversion tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import PlanError
|
||||
from cleveragents.domain.models.core.action import (
|
||||
ActionArgument,
|
||||
ArgumentRequirement,
|
||||
ArgumentType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
InvariantSource,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
|
||||
def _make_service() -> PlanLifecycleService:
|
||||
"""Create a fresh PlanLifecycleService."""
|
||||
return PlanLifecycleService(settings=Settings())
|
||||
|
||||
|
||||
def _create_plan(
|
||||
service: PlanLifecycleService,
|
||||
action_name: str = "local/test-action",
|
||||
profile: str = "manual",
|
||||
) -> str:
|
||||
"""Create an action and plan, return plan_id."""
|
||||
service.create_action(
|
||||
name=action_name,
|
||||
description=f"Test {action_name}",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
)
|
||||
plan = service.use_action(action_name)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name=profile,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
return plan.identity.plan_id
|
||||
|
||||
|
||||
def _advance_to_execute(service: PlanLifecycleService, plan_id: str) -> None:
|
||||
"""Advance plan to Execute phase."""
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.phase != PlanPhase.EXECUTE:
|
||||
service.execute_plan(plan_id)
|
||||
|
||||
|
||||
def _advance_to_apply_constrained(
|
||||
service: PlanLifecycleService,
|
||||
plan_id: str,
|
||||
) -> None:
|
||||
"""Advance plan to Apply/constrained."""
|
||||
_advance_to_execute(service, plan_id)
|
||||
service.start_execute(plan_id)
|
||||
service.complete_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.phase != PlanPhase.APPLY:
|
||||
service.apply_plan(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.processing_state == ProcessingState.QUEUED:
|
||||
service.start_apply(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
plan.error_message = "Constraints too restrictive"
|
||||
|
||||
|
||||
def _advance_to_applied(service: PlanLifecycleService, plan_id: str) -> None:
|
||||
"""Advance plan to Applied terminal state."""
|
||||
_advance_to_execute(service, plan_id)
|
||||
service.start_execute(plan_id)
|
||||
service.complete_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.phase != PlanPhase.APPLY:
|
||||
service.apply_plan(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan.processing_state == ProcessingState.QUEUED:
|
||||
service.start_apply(plan_id)
|
||||
service.complete_apply(plan_id)
|
||||
|
||||
|
||||
def test_manual_revert() -> None:
|
||||
"""Test manual reversion from Execute to Strategize."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service)
|
||||
_advance_to_execute(service, plan_id)
|
||||
|
||||
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="test revert")
|
||||
assert plan.phase == PlanPhase.STRATEGIZE
|
||||
assert plan.processing_state == ProcessingState.QUEUED
|
||||
assert plan.reversion_count == 1
|
||||
assert len(plan.decisions) == 1
|
||||
assert plan.decisions[0]["type"] == "reversion"
|
||||
print("reversion-manual-ok")
|
||||
|
||||
|
||||
def test_auto_revert_apply() -> None:
|
||||
"""Test auto-reversion from constrained Apply."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service, profile="ci")
|
||||
_advance_to_apply_constrained(service, plan_id)
|
||||
|
||||
plan = service.try_auto_revert_from_apply(plan_id, "constraints too strict")
|
||||
assert plan.phase == PlanPhase.STRATEGIZE
|
||||
assert plan.reversion_count == 1
|
||||
print("reversion-auto-apply-ok")
|
||||
|
||||
|
||||
def test_loop_guard() -> None:
|
||||
"""Test loop guard prevents more than 3 reversions."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service)
|
||||
_advance_to_execute(service, plan_id)
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
plan.reversion_count = 3
|
||||
|
||||
try:
|
||||
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
|
||||
print("ERROR: should have raised PlanError")
|
||||
sys.exit(1)
|
||||
except PlanError as e:
|
||||
assert "maximum reversion count" in str(e).lower() or "max" in str(e).lower()
|
||||
print("loop-guard-ok")
|
||||
|
||||
|
||||
def test_profile_gate() -> None:
|
||||
"""Test that manual profile blocks auto-reversion."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service, profile="manual")
|
||||
_advance_to_apply_constrained(service, plan_id)
|
||||
|
||||
plan = service.try_auto_revert_from_apply(plan_id, "should stay")
|
||||
assert plan.phase == PlanPhase.APPLY
|
||||
assert plan.processing_state == ProcessingState.CONSTRAINED
|
||||
print("profile-gate-ok")
|
||||
|
||||
|
||||
def test_terminal_block() -> None:
|
||||
"""Test that applied terminal plan cannot be reverted."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service)
|
||||
_advance_to_applied(service, plan_id)
|
||||
|
||||
try:
|
||||
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
|
||||
print("ERROR: should have raised PlanError")
|
||||
sys.exit(1)
|
||||
except PlanError as e:
|
||||
assert "terminal" in str(e).lower()
|
||||
print("terminal-block-ok")
|
||||
|
||||
|
||||
def test_context_preserved() -> None:
|
||||
"""Test that plan context is preserved through reversion."""
|
||||
service = _make_service()
|
||||
service.create_action(
|
||||
name="local/ctx-test",
|
||||
description="Context test",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
arguments=[
|
||||
ActionArgument(
|
||||
name="coverage",
|
||||
arg_type=ArgumentType.INTEGER,
|
||||
requirement=ArgumentRequirement.OPTIONAL,
|
||||
description="Coverage threshold",
|
||||
),
|
||||
],
|
||||
)
|
||||
plan = service.use_action(
|
||||
"local/ctx-test",
|
||||
project_links=[ProjectLink(project_name="local/my-project")],
|
||||
arguments={"coverage": 95},
|
||||
invariants=[PlanInvariant(text="No regressions", source=InvariantSource.PLAN)],
|
||||
)
|
||||
plan.automation_profile = AutomationProfileRef(
|
||||
profile_name="manual",
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
service.start_strategize(plan_id)
|
||||
service.complete_strategize(plan_id)
|
||||
service.execute_plan(plan_id)
|
||||
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="context check")
|
||||
|
||||
assert plan.arguments == {"coverage": 95}
|
||||
assert len(plan.project_links) == 1
|
||||
assert plan.project_links[0].project_name == "local/my-project"
|
||||
inv_texts = [inv.text for inv in plan.invariants]
|
||||
assert "No regressions" in inv_texts
|
||||
print("context-preserved-ok")
|
||||
|
||||
|
||||
def test_decision_recording() -> None:
|
||||
"""Test that each reversion is recorded as a decision."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service)
|
||||
_advance_to_execute(service, plan_id)
|
||||
|
||||
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="first")
|
||||
assert len(plan.decisions) == 1
|
||||
assert plan.decisions[0]["type"] == "reversion"
|
||||
assert plan.decisions[0]["source_phase"] == "execute"
|
||||
assert plan.decisions[0]["target_phase"] == "strategize"
|
||||
assert plan.decisions[0]["reversion_number"] == 1
|
||||
assert "timestamp" in plan.decisions[0]
|
||||
print("decision-recording-ok")
|
||||
|
||||
|
||||
def test_can_revert_to() -> None:
|
||||
"""Test can_revert_to validation."""
|
||||
service = _make_service()
|
||||
plan_id = _create_plan(service)
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
# Strategize cannot revert to Apply or Action
|
||||
plan.phase = PlanPhase.STRATEGIZE
|
||||
plan.processing_state = ProcessingState.QUEUED
|
||||
assert not plan.can_revert_to(PlanPhase.APPLY)
|
||||
assert not plan.can_revert_to(PlanPhase.ACTION)
|
||||
|
||||
# Execute can revert to Strategize
|
||||
plan.phase = PlanPhase.EXECUTE
|
||||
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
# Apply can revert to Strategize
|
||||
plan.phase = PlanPhase.APPLY
|
||||
plan.processing_state = ProcessingState.CONSTRAINED
|
||||
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
# Applied cannot revert
|
||||
plan.processing_state = ProcessingState.APPLIED
|
||||
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
# Max reversions reached blocks reversion
|
||||
plan.phase = PlanPhase.EXECUTE
|
||||
plan.processing_state = ProcessingState.QUEUED
|
||||
plan.reversion_count = 3
|
||||
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
|
||||
|
||||
print("can-revert-to-ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "manual_revert"
|
||||
dispatch = {
|
||||
"manual_revert": test_manual_revert,
|
||||
"auto_revert_apply": test_auto_revert_apply,
|
||||
"loop_guard": test_loop_guard,
|
||||
"profile_gate": test_profile_gate,
|
||||
"terminal_block": test_terminal_block,
|
||||
"context_preserved": test_context_preserved,
|
||||
"decision_recording": test_decision_recording,
|
||||
"can_revert_to": test_can_revert_to,
|
||||
}
|
||||
fn = dispatch.get(cmd)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,57 @@
|
||||
*** Settings ***
|
||||
Documentation End-to-end tests for phase reversion state machine
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_phase_reversion.py
|
||||
|
||||
*** Test Cases ***
|
||||
Manual Reversion From Execute To Strategize
|
||||
[Documentation] Revert a plan from Execute to Strategize manually
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} manual_revert cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} reversion-manual-ok
|
||||
|
||||
Auto Reversion From Constrained Apply
|
||||
[Documentation] Automatically revert when Apply is constrained and profile permits
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} auto_revert_apply cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} reversion-auto-apply-ok
|
||||
|
||||
Loop Guard Blocks Excessive Reversions
|
||||
[Documentation] Verify the max 3 reversions guard
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} loop_guard cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} loop-guard-ok
|
||||
|
||||
Profile Gated Auto Reversion
|
||||
[Documentation] Manual profile blocks auto-reversion
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} profile_gate cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} profile-gate-ok
|
||||
|
||||
Terminal Plan Cannot Be Reverted
|
||||
[Documentation] Applied plan cannot be reverted
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} terminal_block cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} terminal-block-ok
|
||||
|
||||
Reversion Preserves Context
|
||||
[Documentation] Arguments and project links survive reversion
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} context_preserved cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-preserved-ok
|
||||
|
||||
Decision Recording On Reversion
|
||||
[Documentation] Each reversion is logged as a decision
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} decision_recording cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} decision-recording-ok
|
||||
|
||||
Can Revert To Validation
|
||||
[Documentation] Validate allowed reversion paths
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} can_revert_to cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} can-revert-to-ok
|
||||
@@ -3,6 +3,12 @@
|
||||
Contains service classes that orchestrate business operations.
|
||||
"""
|
||||
|
||||
from cleveragents.application.services.config_service import (
|
||||
ConfigEntry,
|
||||
ConfigLevel,
|
||||
ConfigService,
|
||||
ResolvedValue,
|
||||
)
|
||||
from cleveragents.application.services.correction_service import (
|
||||
CorrectionService,
|
||||
)
|
||||
@@ -25,10 +31,14 @@ from cleveragents.application.services.tool_registry_service import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfigEntry",
|
||||
"ConfigLevel",
|
||||
"ConfigService",
|
||||
"CorrectionService",
|
||||
"InvariantService",
|
||||
"PersistentSessionService",
|
||||
"PlanExecutionContext",
|
||||
"ResolvedValue",
|
||||
"RuntimeExecuteActor",
|
||||
"RuntimeExecuteResult",
|
||||
"SkillRegistryService",
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Config service with multi-level resolution chain and typed key registry.
|
||||
|
||||
Provides a resolution layer on top of ``Settings`` (pydantic-settings) that
|
||||
manages TOML file persistence and implements a five-level precedence chain:
|
||||
|
||||
CLI flag > environment variable > project-scoped > global config > default
|
||||
|
||||
Each ``get`` call returns the winning value **and** the source level that
|
||||
provided it, enabling transparent debugging of configuration provenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomlkit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConfigLevel(Enum):
|
||||
"""Precedence levels from highest to lowest priority."""
|
||||
|
||||
CLI_FLAG = "cli_flag"
|
||||
ENV_VAR = "env_var"
|
||||
PROJECT = "project"
|
||||
GLOBAL = "global"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigEntry:
|
||||
"""Metadata for a registered configuration key."""
|
||||
|
||||
key: str
|
||||
python_type: type[Any]
|
||||
default: Any
|
||||
env_var: str
|
||||
project_scopable: bool
|
||||
description: str
|
||||
section: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedValue:
|
||||
"""Result of resolving a configuration key through the precedence chain."""
|
||||
|
||||
key: str
|
||||
value: Any
|
||||
source: ConfigLevel
|
||||
chain: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config key registry catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: dict[str, ConfigEntry] = {}
|
||||
|
||||
|
||||
def _env_name(section: str, key: str) -> str:
|
||||
"""Build ``CLEVERAGENTS_<SECTION>_<KEY>`` environment variable name."""
|
||||
return f"CLEVERAGENTS_{section.upper()}_{key.upper()}"
|
||||
|
||||
|
||||
def _register(
|
||||
section: str,
|
||||
name: str,
|
||||
python_type: type[Any],
|
||||
default: Any,
|
||||
*,
|
||||
project_scopable: bool = True,
|
||||
description: str = "",
|
||||
env_var: str | None = None,
|
||||
) -> None:
|
||||
full_key = f"{section}.{name}"
|
||||
resolved_env = env_var if env_var is not None else _env_name(section, name)
|
||||
_REGISTRY[full_key] = ConfigEntry(
|
||||
key=full_key,
|
||||
python_type=python_type,
|
||||
default=default,
|
||||
env_var=resolved_env,
|
||||
project_scopable=project_scopable,
|
||||
description=description,
|
||||
section=section,
|
||||
)
|
||||
|
||||
|
||||
def _build_catalog() -> None:
|
||||
"""Register the complete configuration catalog per spec."""
|
||||
# -- core.* ---------------------------------------------------------------
|
||||
_register("core", "log_level", str, "INFO", description="Logging verbosity level.")
|
||||
_register(
|
||||
"core",
|
||||
"debug_enabled",
|
||||
bool,
|
||||
False,
|
||||
description="Enable debug mode.",
|
||||
)
|
||||
_register(
|
||||
"core",
|
||||
"env",
|
||||
str,
|
||||
"development",
|
||||
description="Runtime environment name.",
|
||||
)
|
||||
_register(
|
||||
"core",
|
||||
"data_dir",
|
||||
str,
|
||||
"data",
|
||||
description="Base data directory path.",
|
||||
)
|
||||
_register(
|
||||
"core",
|
||||
"database_url",
|
||||
str,
|
||||
"sqlite:///cleveragents.db",
|
||||
project_scopable=False,
|
||||
description="Primary database URL.",
|
||||
)
|
||||
_register(
|
||||
"core",
|
||||
"server_host",
|
||||
str,
|
||||
"0.0.0.0",
|
||||
project_scopable=False,
|
||||
description="Server bind host.",
|
||||
)
|
||||
_register(
|
||||
"core",
|
||||
"server_port",
|
||||
int,
|
||||
8080,
|
||||
project_scopable=False,
|
||||
description="Server bind port.",
|
||||
)
|
||||
|
||||
# -- plan.* ---------------------------------------------------------------
|
||||
_register(
|
||||
"plan",
|
||||
"auto_apply",
|
||||
bool,
|
||||
False,
|
||||
description="Automatically apply plans on completion.",
|
||||
)
|
||||
_register(
|
||||
"plan",
|
||||
"max_retries",
|
||||
int,
|
||||
3,
|
||||
description="Maximum plan retry count.",
|
||||
)
|
||||
_register(
|
||||
"plan",
|
||||
"timeout_seconds",
|
||||
int,
|
||||
300,
|
||||
description="Plan execution timeout in seconds.",
|
||||
)
|
||||
|
||||
# -- provider.* -----------------------------------------------------------
|
||||
_register(
|
||||
"provider",
|
||||
"default_provider",
|
||||
str,
|
||||
"",
|
||||
project_scopable=False,
|
||||
description="Default LLM provider name.",
|
||||
)
|
||||
_register(
|
||||
"provider",
|
||||
"default_model",
|
||||
str,
|
||||
"",
|
||||
project_scopable=False,
|
||||
description="Default LLM model name.",
|
||||
)
|
||||
_register(
|
||||
"provider",
|
||||
"temperature",
|
||||
float,
|
||||
0.7,
|
||||
description="LLM sampling temperature.",
|
||||
)
|
||||
_register(
|
||||
"provider",
|
||||
"max_tokens",
|
||||
int,
|
||||
4096,
|
||||
description="Maximum tokens per LLM response.",
|
||||
)
|
||||
|
||||
# -- sandbox.* ------------------------------------------------------------
|
||||
_register(
|
||||
"sandbox",
|
||||
"strategy",
|
||||
str,
|
||||
"git_worktree",
|
||||
description="Default sandbox strategy.",
|
||||
)
|
||||
_register(
|
||||
"sandbox",
|
||||
"auto_cleanup",
|
||||
bool,
|
||||
True,
|
||||
description="Automatically clean up sandbox after use.",
|
||||
)
|
||||
_register(
|
||||
"sandbox",
|
||||
"max_age_hours",
|
||||
int,
|
||||
48,
|
||||
description="Max sandbox age in hours before cleanup.",
|
||||
)
|
||||
|
||||
# -- context.* ------------------------------------------------------------
|
||||
_register(
|
||||
"context",
|
||||
"max_files",
|
||||
int,
|
||||
100,
|
||||
description="Maximum context files per session.",
|
||||
)
|
||||
_register(
|
||||
"context",
|
||||
"max_tokens",
|
||||
int,
|
||||
128000,
|
||||
description="Maximum context token window.",
|
||||
)
|
||||
_register(
|
||||
"context",
|
||||
"auto_include_gitignore",
|
||||
bool,
|
||||
True,
|
||||
description="Auto-include gitignore patterns in context filtering.",
|
||||
)
|
||||
|
||||
# -- index.* --------------------------------------------------------------
|
||||
_register(
|
||||
"index",
|
||||
"enabled",
|
||||
bool,
|
||||
False,
|
||||
description="Enable vector-store indexing.",
|
||||
)
|
||||
_register(
|
||||
"index",
|
||||
"backend",
|
||||
str,
|
||||
"faiss",
|
||||
description="Vector store backend name.",
|
||||
)
|
||||
_register(
|
||||
"index",
|
||||
"embedding_model",
|
||||
str,
|
||||
"fake",
|
||||
description="Embedding model for vector indexing.",
|
||||
)
|
||||
_register(
|
||||
"index",
|
||||
"embedding_dimension",
|
||||
int,
|
||||
1536,
|
||||
description="Embedding vector dimension.",
|
||||
)
|
||||
|
||||
|
||||
# Build catalog on module import
|
||||
_build_catalog()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConfigService
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_CONFIG_DIR: Path = Path.home() / ".cleveragents"
|
||||
_DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Service providing multi-level config resolution with TOML persistence.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config_dir:
|
||||
Override for the configuration directory (default ``~/.cleveragents``).
|
||||
config_path:
|
||||
Override for the TOML file path (default ``config_dir / config.toml``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_dir: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR
|
||||
self._config_path: Path = config_path or (self._config_dir / "config.toml")
|
||||
|
||||
# -- public registry API --------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def registry() -> dict[str, ConfigEntry]:
|
||||
"""Return a copy of the full key registry."""
|
||||
return dict(_REGISTRY)
|
||||
|
||||
@staticmethod
|
||||
def get_entry(key: str) -> ConfigEntry | None:
|
||||
"""Look up a registry entry by dotted key (e.g. ``core.log_level``)."""
|
||||
return _REGISTRY.get(key)
|
||||
|
||||
@staticmethod
|
||||
def registered_keys() -> list[str]:
|
||||
"""Return all registered key names sorted alphabetically."""
|
||||
return sorted(_REGISTRY.keys())
|
||||
|
||||
# -- TOML file management -------------------------------------------------
|
||||
|
||||
def read_config(self) -> dict[str, Any]:
|
||||
"""Read the global TOML config file and return its contents."""
|
||||
if not self._config_path.exists():
|
||||
return {}
|
||||
with open(self._config_path, "rb") as fh:
|
||||
return tomllib.load(fh)
|
||||
|
||||
def write_config(self, data: dict[str, Any]) -> None:
|
||||
"""Write *data* to the TOML config file, creating dirs if needed."""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self._config_path.exists():
|
||||
with open(self._config_path) as fh:
|
||||
doc = tomlkit.load(fh)
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
|
||||
for key, value in data.items():
|
||||
doc[key] = value
|
||||
|
||||
with open(self._config_path, "w") as fh:
|
||||
tomlkit.dump(doc, fh)
|
||||
|
||||
def set_value(self, key: str, value: Any) -> None:
|
||||
"""Persist a single key-value pair into the global TOML config."""
|
||||
data = self.read_config()
|
||||
data[key] = value
|
||||
self.write_config(data)
|
||||
|
||||
# -- validation -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def validate_key(key: str) -> ConfigEntry:
|
||||
"""Validate that *key* is a known registered key.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
With an actionable message when the key is unknown.
|
||||
"""
|
||||
entry = _REGISTRY.get(key)
|
||||
if entry is None:
|
||||
available = ", ".join(sorted(_REGISTRY.keys())[:8])
|
||||
msg = (
|
||||
f"Unknown configuration key: '{key}'. "
|
||||
f"Valid keys include: {available} ..."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def validate_type(key: str, value: Any) -> Any:
|
||||
"""Coerce *value* to the registered type for *key*.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
With an actionable message on type mismatch.
|
||||
"""
|
||||
entry = _REGISTRY.get(key)
|
||||
if entry is None:
|
||||
msg = f"Cannot validate type for unknown key: '{key}'"
|
||||
raise ValueError(msg)
|
||||
|
||||
if isinstance(value, entry.python_type):
|
||||
return value
|
||||
|
||||
# Coerce from string representations
|
||||
try:
|
||||
if entry.python_type is bool and isinstance(value, str):
|
||||
if value.lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
if value.lower() in ("false", "0", "no"):
|
||||
return False
|
||||
msg = (
|
||||
f"Cannot convert '{value}' to bool for key '{key}'. "
|
||||
f"Use 'true'/'false', '1'/'0', or 'yes'/'no'."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
if entry.python_type is int:
|
||||
return int(value)
|
||||
if entry.python_type is float:
|
||||
return float(value)
|
||||
if entry.python_type is str:
|
||||
return str(value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
if isinstance(exc, TypeError):
|
||||
raise
|
||||
msg = (
|
||||
f"Type mismatch for key '{key}': expected "
|
||||
f"{entry.python_type.__name__}, got {type(value).__name__} "
|
||||
f"with value '{value}'."
|
||||
)
|
||||
raise TypeError(msg) from exc
|
||||
|
||||
msg = (
|
||||
f"Type mismatch for key '{key}': expected "
|
||||
f"{entry.python_type.__name__}, got {type(value).__name__}."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
# -- resolution chain -----------------------------------------------------
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
cli_value: Any | None = None,
|
||||
project_name: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> ResolvedValue:
|
||||
"""Resolve *key* through the five-level precedence chain.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key:
|
||||
Dotted config key (e.g. ``core.log_level``).
|
||||
cli_value:
|
||||
Value explicitly passed via CLI flag (highest priority).
|
||||
project_name:
|
||||
Optional project name for project-scoped lookup.
|
||||
verbose:
|
||||
When ``True``, the full resolution chain is populated.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResolvedValue
|
||||
Contains the winning value, its source level, and (if
|
||||
*verbose*) the full resolution chain for display.
|
||||
"""
|
||||
entry = self.validate_key(key)
|
||||
chain: list[dict[str, Any]] = []
|
||||
winner_value: Any | None = None
|
||||
winner_source: ConfigLevel | None = None
|
||||
|
||||
# Level 1: CLI flag
|
||||
if cli_value is not None:
|
||||
coerced = self.validate_type(key, cli_value)
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.CLI_FLAG.value, "value": coerced})
|
||||
if winner_source is None:
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.CLI_FLAG
|
||||
elif verbose:
|
||||
chain.append({"source": ConfigLevel.CLI_FLAG.value, "value": None})
|
||||
|
||||
# Level 2: Environment variable
|
||||
env_val = os.environ.get(entry.env_var)
|
||||
if env_val is not None and winner_source is None:
|
||||
coerced = self.validate_type(key, env_val)
|
||||
if verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.ENV_VAR.value,
|
||||
"value": coerced,
|
||||
"env_name": entry.env_var,
|
||||
}
|
||||
)
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.ENV_VAR
|
||||
elif verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.ENV_VAR.value,
|
||||
"value": None,
|
||||
"env_name": entry.env_var,
|
||||
}
|
||||
)
|
||||
|
||||
# Level 3: Project-scoped config
|
||||
project_val: Any | None = None
|
||||
if project_name and entry.project_scopable and winner_source is None:
|
||||
config_data = self.read_config()
|
||||
project_section = config_data.get("project", {})
|
||||
if isinstance(project_section, dict):
|
||||
project_overrides = project_section.get(project_name, {})
|
||||
if isinstance(project_overrides, dict):
|
||||
project_val = project_overrides.get(key)
|
||||
if project_val is not None and winner_source is None:
|
||||
coerced = self.validate_type(key, project_val)
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.PROJECT.value, "value": coerced})
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.PROJECT
|
||||
elif verbose:
|
||||
chain.append({"source": ConfigLevel.PROJECT.value, "value": None})
|
||||
|
||||
# Level 4: Global config file
|
||||
global_val: Any | None = None
|
||||
if winner_source is None:
|
||||
config_data = self.read_config()
|
||||
global_val = config_data.get(key)
|
||||
if global_val is not None and winner_source is None:
|
||||
coerced = self.validate_type(key, global_val)
|
||||
if verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.GLOBAL.value,
|
||||
"value": coerced,
|
||||
"path": str(self._config_path),
|
||||
}
|
||||
)
|
||||
winner_value = coerced
|
||||
winner_source = ConfigLevel.GLOBAL
|
||||
elif verbose:
|
||||
chain.append(
|
||||
{
|
||||
"source": ConfigLevel.GLOBAL.value,
|
||||
"value": None,
|
||||
"path": str(self._config_path),
|
||||
}
|
||||
)
|
||||
|
||||
# Level 5: Default
|
||||
if verbose:
|
||||
chain.append({"source": ConfigLevel.DEFAULT.value, "value": entry.default})
|
||||
if winner_source is None:
|
||||
winner_value = entry.default
|
||||
winner_source = ConfigLevel.DEFAULT
|
||||
|
||||
return ResolvedValue(
|
||||
key=key,
|
||||
value=winner_value,
|
||||
source=winner_source,
|
||||
chain=chain,
|
||||
)
|
||||
|
||||
# -- env var interpolation ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def env_var_for_key(key: str) -> str:
|
||||
"""Return the env var name for a registered key.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the key is not registered.
|
||||
"""
|
||||
entry = _REGISTRY.get(key)
|
||||
if entry is None:
|
||||
msg = f"Unknown configuration key: '{key}'"
|
||||
raise ValueError(msg)
|
||||
return entry.env_var
|
||||
|
||||
# -- bulk helpers ---------------------------------------------------------
|
||||
|
||||
def resolve_all(
|
||||
self,
|
||||
*,
|
||||
cli_overrides: dict[str, Any] | None = None,
|
||||
project_name: str | None = None,
|
||||
) -> dict[str, ResolvedValue]:
|
||||
"""Resolve every registered key and return the results."""
|
||||
overrides = cli_overrides or {}
|
||||
results: dict[str, ResolvedValue] = {}
|
||||
for key in _REGISTRY:
|
||||
results[key] = self.resolve(
|
||||
key,
|
||||
cli_value=overrides.get(key),
|
||||
project_name=project_name,
|
||||
)
|
||||
return results
|
||||
@@ -1188,3 +1188,209 @@ class PlanLifecycleService:
|
||||
|
||||
# Attempt immediate auto-progression
|
||||
return self.auto_progress(plan_id)
|
||||
|
||||
# -- Phase reversion methods -----------------------------------------
|
||||
|
||||
def revert_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
to_phase: PlanPhase,
|
||||
reason: str | None = None,
|
||||
) -> Plan:
|
||||
"""Revert a plan to a previous phase (manual reversion).
|
||||
|
||||
This is the manual reversion entry point, invoked by the CLI
|
||||
``plan revert`` command. It validates that the reversion is
|
||||
allowed, records a reversion decision, and resets the plan to
|
||||
the target phase.
|
||||
|
||||
Args:
|
||||
plan_id: The plan ULID.
|
||||
to_phase: The target phase to revert to.
|
||||
reason: Human-readable reason for the reversion.
|
||||
|
||||
Returns:
|
||||
The updated Plan in the reverted phase.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If plan not found.
|
||||
InvalidPhaseTransitionError: If reversion is not valid.
|
||||
PlanError: If the plan has exceeded the max reversion count.
|
||||
"""
|
||||
plan = self.get_plan(plan_id)
|
||||
|
||||
if plan.processing_state in (
|
||||
ProcessingState.APPLIED,
|
||||
ProcessingState.CANCELLED,
|
||||
):
|
||||
raise PlanError(
|
||||
f"Plan {plan_id} is in terminal state "
|
||||
f"({plan.processing_state.value}) and cannot be reverted"
|
||||
)
|
||||
|
||||
if plan.reversion_count >= plan.MAX_REVERSIONS:
|
||||
raise PlanError(
|
||||
f"Plan {plan_id} has exceeded the maximum reversion count "
|
||||
f"({plan.MAX_REVERSIONS}). Manual intervention required."
|
||||
)
|
||||
|
||||
if not can_transition(plan.phase, to_phase):
|
||||
raise InvalidPhaseTransitionError(
|
||||
plan.phase,
|
||||
to_phase,
|
||||
message=(f"Cannot revert from {plan.phase.value} to {to_phase.value}"),
|
||||
)
|
||||
|
||||
return self._perform_reversion(plan, to_phase, reason or "manual reversion")
|
||||
|
||||
def try_auto_revert_from_apply(self, plan_id: str, reason: str) -> Plan:
|
||||
"""Attempt automatic reversion from a constrained Apply phase.
|
||||
|
||||
Called after ``constrain_apply`` when the automation profile
|
||||
permits automatic reversion (``auto_reversion_from_apply < 1.0``).
|
||||
|
||||
Args:
|
||||
plan_id: The plan ULID.
|
||||
reason: Why the apply was constrained.
|
||||
|
||||
Returns:
|
||||
The (possibly reverted) Plan.
|
||||
"""
|
||||
plan = self.get_plan(plan_id)
|
||||
|
||||
if plan.phase != PlanPhase.APPLY:
|
||||
return plan
|
||||
|
||||
if plan.processing_state != ProcessingState.CONSTRAINED:
|
||||
return plan
|
||||
|
||||
profile = self._resolve_profile_for_plan(plan)
|
||||
if profile.auto_reversion_from_apply >= 1.0:
|
||||
self._logger.info(
|
||||
"Auto-reversion blocked by profile threshold",
|
||||
plan_id=plan_id,
|
||||
threshold=profile.auto_reversion_from_apply,
|
||||
)
|
||||
return plan
|
||||
|
||||
if plan.reversion_count >= plan.MAX_REVERSIONS:
|
||||
self._logger.warning(
|
||||
"Auto-reversion blocked by loop guard",
|
||||
plan_id=plan_id,
|
||||
reversion_count=plan.reversion_count,
|
||||
max_reversions=plan.MAX_REVERSIONS,
|
||||
)
|
||||
return plan
|
||||
|
||||
self._logger.info(
|
||||
"Auto-reverting plan from Apply to Strategize",
|
||||
plan_id=plan_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return self._perform_reversion(
|
||||
plan,
|
||||
PlanPhase.STRATEGIZE,
|
||||
f"auto-reversion: {reason}",
|
||||
)
|
||||
|
||||
def try_auto_revert_from_execute(self, plan_id: str, reason: str) -> Plan:
|
||||
"""Attempt automatic reversion from Execute to Strategize.
|
||||
|
||||
Called when validation failures block apply and the automation
|
||||
profile permits reversion.
|
||||
|
||||
Args:
|
||||
plan_id: The plan ULID.
|
||||
reason: Why the execute needs reversion.
|
||||
|
||||
Returns:
|
||||
The (possibly reverted) Plan.
|
||||
"""
|
||||
plan = self.get_plan(plan_id)
|
||||
|
||||
if plan.phase != PlanPhase.EXECUTE:
|
||||
return plan
|
||||
|
||||
profile = self._resolve_profile_for_plan(plan)
|
||||
if profile.auto_strategy_revision >= 1.0:
|
||||
self._logger.info(
|
||||
"Execute-to-Strategize reversion blocked by profile threshold",
|
||||
plan_id=plan_id,
|
||||
threshold=profile.auto_strategy_revision,
|
||||
)
|
||||
return plan
|
||||
|
||||
if plan.reversion_count >= plan.MAX_REVERSIONS:
|
||||
self._logger.warning(
|
||||
"Execute-to-Strategize reversion blocked by loop guard",
|
||||
plan_id=plan_id,
|
||||
reversion_count=plan.reversion_count,
|
||||
max_reversions=plan.MAX_REVERSIONS,
|
||||
)
|
||||
return plan
|
||||
|
||||
self._logger.info(
|
||||
"Auto-reverting plan from Execute to Strategize",
|
||||
plan_id=plan_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return self._perform_reversion(
|
||||
plan,
|
||||
PlanPhase.STRATEGIZE,
|
||||
f"auto-reversion: {reason}",
|
||||
)
|
||||
|
||||
def _perform_reversion(
|
||||
self,
|
||||
plan: Plan,
|
||||
to_phase: PlanPhase,
|
||||
reason: str,
|
||||
) -> Plan:
|
||||
"""Execute a phase reversion on a plan.
|
||||
|
||||
Records the reversion decision, increments the reversion count,
|
||||
and resets the plan to the target phase in QUEUED state.
|
||||
|
||||
Args:
|
||||
plan: The plan to revert.
|
||||
to_phase: Target phase.
|
||||
reason: Reason for the reversion.
|
||||
|
||||
Returns:
|
||||
The updated Plan.
|
||||
"""
|
||||
source_phase = plan.phase
|
||||
plan_id = plan.identity.plan_id
|
||||
|
||||
# Record reversion decision
|
||||
decision: dict[str, Any] = {
|
||||
"type": "reversion",
|
||||
"source_phase": source_phase.value,
|
||||
"target_phase": to_phase.value,
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"reversion_number": plan.reversion_count + 1,
|
||||
}
|
||||
plan.decisions.append(decision)
|
||||
|
||||
# Update plan state
|
||||
plan.phase = to_phase
|
||||
plan.processing_state = ProcessingState.QUEUED
|
||||
plan.reversion_count += 1
|
||||
plan.error_message = None
|
||||
plan.timestamps.updated_at = datetime.now()
|
||||
|
||||
self._commit_plan(plan)
|
||||
|
||||
self._logger.info(
|
||||
"Plan reverted",
|
||||
plan_id=plan_id,
|
||||
from_phase=source_phase.value,
|
||||
to_phase=to_phase.value,
|
||||
reason=reason,
|
||||
reversion_count=plan.reversion_count,
|
||||
)
|
||||
|
||||
return plan
|
||||
|
||||
@@ -95,7 +95,8 @@ def _action_spec_dict(action: Action) -> dict[str, object]:
|
||||
|
||||
Keys: namespaced_name, short_name, state, description,
|
||||
definition_of_done, strategy_actor, execution_actor,
|
||||
automation_profile, arguments, invariants.
|
||||
estimation_actor, invariant_actor, automation_profile,
|
||||
arguments, inputs_schema, invariants.
|
||||
"""
|
||||
result: dict[str, object] = {
|
||||
"namespaced_name": str(action.namespaced_name),
|
||||
@@ -105,21 +106,27 @@ def _action_spec_dict(action: Action) -> dict[str, object]:
|
||||
"definition_of_done": action.definition_of_done,
|
||||
"strategy_actor": action.strategy_actor,
|
||||
"execution_actor": action.execution_actor,
|
||||
"automation_profile": action.automation_profile,
|
||||
"arguments": [
|
||||
{
|
||||
"name": arg.name,
|
||||
"type": arg.arg_type.value,
|
||||
"required": arg.requirement.value == "required",
|
||||
"description": arg.description,
|
||||
}
|
||||
for arg in action.arguments
|
||||
],
|
||||
"invariants": action.invariants,
|
||||
"reusable": action.reusable,
|
||||
"read_only": action.read_only,
|
||||
"created_at": action.created_at.isoformat(),
|
||||
}
|
||||
if action.estimation_actor:
|
||||
result["estimation_actor"] = action.estimation_actor
|
||||
if action.invariant_actor:
|
||||
result["invariant_actor"] = action.invariant_actor
|
||||
result["automation_profile"] = action.automation_profile
|
||||
result["arguments"] = [
|
||||
{
|
||||
"name": arg.name,
|
||||
"type": arg.arg_type.value,
|
||||
"required": arg.requirement.value == "required",
|
||||
"description": arg.description,
|
||||
}
|
||||
for arg in action.arguments
|
||||
]
|
||||
if action.inputs_schema:
|
||||
result["inputs_schema"] = action.inputs_schema
|
||||
result["invariants"] = action.invariants
|
||||
result["reusable"] = action.reusable
|
||||
result["read_only"] = action.read_only
|
||||
result["created_at"] = action.created_at.isoformat()
|
||||
return result
|
||||
|
||||
|
||||
@@ -158,13 +165,39 @@ def _print_action(
|
||||
f"[bold]State:[/bold] {action.state.value}\n"
|
||||
f"[bold]Strategy Actor:[/bold] {action.strategy_actor}\n"
|
||||
f"[bold]Execution Actor:[/bold] {action.execution_actor}\n"
|
||||
)
|
||||
|
||||
# Optional actors
|
||||
if action.estimation_actor:
|
||||
details += f"[bold]Estimation Actor:[/bold] {action.estimation_actor}\n"
|
||||
if action.invariant_actor:
|
||||
details += f"[bold]Invariant Actor:[/bold] {action.invariant_actor}\n"
|
||||
|
||||
details += (
|
||||
f"[bold]Reusable:[/bold] {'yes' if action.reusable else 'no'}\n"
|
||||
f"[bold]Read Only:[/bold] {'yes' if action.read_only else 'no'}\n"
|
||||
f"[bold]Definition of Done:[/bold]\n {dod_summary}\n"
|
||||
f"[bold]Arguments:[/bold]\n{args_display}\n"
|
||||
f"[bold]Created:[/bold] {action.created_at}"
|
||||
)
|
||||
|
||||
# Invariants
|
||||
if action.invariants:
|
||||
inv_lines = "\n".join(f" • {inv}" for inv in action.invariants)
|
||||
details += f"[bold]Invariants:[/bold]\n{inv_lines}\n"
|
||||
|
||||
# Inputs schema
|
||||
if action.inputs_schema:
|
||||
import json
|
||||
|
||||
schema_str = json.dumps(action.inputs_schema, indent=2)
|
||||
details += f"[bold]Inputs Schema:[/bold]\n {schema_str}\n"
|
||||
|
||||
# Automation profile
|
||||
if action.automation_profile:
|
||||
details += f"[bold]Automation Profile:[/bold] {action.automation_profile}\n"
|
||||
|
||||
details += f"[bold]Created:[/bold] {action.created_at}"
|
||||
|
||||
console.print(Panel(details, title=title, expand=False))
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ plan lifecycle.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
@@ -48,6 +49,31 @@ from cleveragents.core.exceptions import (
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
# Regex for validating namespaced actor names (namespace/name format)
|
||||
_NAMESPACED_ACTOR_RE = re.compile(r"^[a-z][a-z0-9-]*/[a-z][a-z0-9._-]*$")
|
||||
|
||||
|
||||
def validate_namespaced_actor(value: str, flag_name: str) -> str:
|
||||
"""Validate that an actor name follows ``namespace/name`` format.
|
||||
|
||||
Args:
|
||||
value: The actor name string to validate.
|
||||
flag_name: CLI flag name for error messages.
|
||||
|
||||
Returns:
|
||||
The validated actor name (unchanged).
|
||||
|
||||
Raises:
|
||||
ValidationError: If the value does not match namespace/name.
|
||||
"""
|
||||
if not _NAMESPACED_ACTOR_RE.match(value):
|
||||
raise ValidationError(
|
||||
f"Actor '{value}' for {flag_name} must follow "
|
||||
f"'namespace/name' format (e.g., 'openai/gpt-4')."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
_LEGACY_DEPRECATION_MSG = (
|
||||
"This command uses the legacy plan workflow and is deprecated. "
|
||||
"Use 'agents plan use <action> [project]' for the v3 lifecycle."
|
||||
@@ -102,10 +128,17 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]:
|
||||
"definition_of_done": plan.definition_of_done,
|
||||
"strategy_actor": plan.strategy_actor,
|
||||
"execution_actor": plan.execution_actor,
|
||||
"estimation_actor": plan.estimation_actor,
|
||||
"invariant_actor": plan.invariant_actor,
|
||||
"created_at": plan.timestamps.created_at.isoformat(),
|
||||
"updated_at": plan.timestamps.updated_at.isoformat(),
|
||||
"is_terminal": plan.is_terminal,
|
||||
}
|
||||
if plan.invariants:
|
||||
result["invariants"] = [
|
||||
{"text": inv.text, "source": inv.source.value}
|
||||
for inv in plan.invariants
|
||||
]
|
||||
if plan.error_message:
|
||||
result["error_message"] = plan.error_message
|
||||
return result
|
||||
@@ -1138,6 +1171,12 @@ def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
|
||||
f"(source: {plan.automation_profile.provenance.value})\n"
|
||||
)
|
||||
|
||||
# Invariants
|
||||
if plan.invariants:
|
||||
details += "[bold]Invariants:[/bold]\n"
|
||||
for inv in plan.invariants:
|
||||
details += f" [{inv.source.value}] {inv.text}\n"
|
||||
|
||||
details += f"[bold]Terminal:[/bold] {'yes' if plan.is_terminal else 'no'}\n"
|
||||
|
||||
# Timestamps
|
||||
@@ -1339,14 +1378,18 @@ def use_action(
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
|
||||
# Apply actor overrides if provided
|
||||
# Validate and apply actor overrides if provided
|
||||
if strategy_actor:
|
||||
validate_namespaced_actor(strategy_actor, "--strategy-actor")
|
||||
plan.strategy_actor = strategy_actor
|
||||
if execution_actor:
|
||||
validate_namespaced_actor(execution_actor, "--execution-actor")
|
||||
plan.execution_actor = execution_actor
|
||||
if estimation_actor:
|
||||
validate_namespaced_actor(estimation_actor, "--estimation-actor")
|
||||
plan.estimation_actor = estimation_actor
|
||||
if invariant_actor:
|
||||
validate_namespaced_actor(invariant_actor, "--invariant-actor")
|
||||
plan.invariant_actor = invariant_actor
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
@@ -1567,14 +1610,24 @@ def plan_status(
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Phase", style="yellow")
|
||||
table.add_column("State", style="magenta")
|
||||
table.add_column("Profile", style="blue")
|
||||
table.add_column("Invariants", style="dim")
|
||||
table.add_column("Terminal", justify="center")
|
||||
|
||||
for plan in plans:
|
||||
profile = (
|
||||
plan.automation_profile.profile_name
|
||||
if plan.automation_profile
|
||||
else ""
|
||||
)
|
||||
inv_count = str(len(plan.invariants)) if plan.invariants else ""
|
||||
table.add_row(
|
||||
plan.identity.plan_id[:8] + "...",
|
||||
str(plan.namespaced_name),
|
||||
plan.phase.value,
|
||||
plan.state.value if plan.state else "",
|
||||
profile,
|
||||
inv_count,
|
||||
"✓" if plan.is_terminal else "",
|
||||
)
|
||||
|
||||
@@ -1736,6 +1789,8 @@ def lifecycle_list_plans(
|
||||
table.add_column("Action", style="blue")
|
||||
table.add_column("Phase", style="yellow")
|
||||
table.add_column("State", style="magenta")
|
||||
table.add_column("Profile", style="blue")
|
||||
table.add_column("Invariants", style="dim")
|
||||
table.add_column("Projects", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
@@ -1745,12 +1800,19 @@ def lifecycle_list_plans(
|
||||
if len(link_names) > 2:
|
||||
projects += f" +{len(link_names) - 2} more"
|
||||
|
||||
profile = (
|
||||
plan.automation_profile.profile_name if plan.automation_profile else ""
|
||||
)
|
||||
inv_count = str(len(plan.invariants)) if plan.invariants else ""
|
||||
|
||||
table.add_row(
|
||||
plan.identity.plan_id[:8] + "...",
|
||||
str(plan.namespaced_name),
|
||||
plan.action_name,
|
||||
plan.phase.value,
|
||||
plan.state.value if plan.state else "",
|
||||
profile,
|
||||
inv_count,
|
||||
projects or "(none)",
|
||||
plan.timestamps.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
@@ -1812,6 +1874,89 @@ def cancel_plan(
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
@app.command("revert")
|
||||
def revert_plan(
|
||||
plan_id: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Plan ID to revert"),
|
||||
],
|
||||
to_phase: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--to-phase",
|
||||
help="Target phase to revert to (e.g., strategize)",
|
||||
),
|
||||
] = "strategize",
|
||||
reason: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--reason",
|
||||
"-r",
|
||||
help="Reason for the reversion",
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help=_FORMAT_HELP,
|
||||
),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Revert a plan to a previous phase.
|
||||
|
||||
This command reverts a plan back to a previous phase (typically
|
||||
Strategize) so it can be re-planned with different constraints.
|
||||
|
||||
Examples:
|
||||
agents plan revert PLAN123 --to-phase strategize
|
||||
agents plan revert PLAN123 --to-phase strategize \
|
||||
--reason "constraints too strict"
|
||||
"""
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
InvalidPhaseTransitionError,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import PlanPhase
|
||||
|
||||
try:
|
||||
service = _get_lifecycle_service()
|
||||
|
||||
# Parse the target phase
|
||||
try:
|
||||
target_phase = PlanPhase(to_phase.lower())
|
||||
except ValueError as exc:
|
||||
valid_phases = ", ".join(p.value for p in PlanPhase)
|
||||
console.print(
|
||||
f"[red]Invalid phase:[/red] {to_phase}. Valid phases: {valid_phases}"
|
||||
)
|
||||
raise typer.Abort() from exc
|
||||
|
||||
plan = service.revert_plan(plan_id, target_phase, reason=reason)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _plan_spec_dict(plan)
|
||||
data["reversion_reason"] = reason
|
||||
console.print(format_output(data, fmt))
|
||||
else:
|
||||
_print_lifecycle_plan(plan, title="Plan Reverted")
|
||||
console.print(
|
||||
"\n[dim]Plan reverted to "
|
||||
f"{target_phase.value} phase. "
|
||||
f"Reversion count: {plan.reversion_count}/{plan.MAX_REVERSIONS}[/dim]"
|
||||
)
|
||||
|
||||
except InvalidPhaseTransitionError as e:
|
||||
console.print(f"[red]Invalid reversion:[/red] {e}")
|
||||
raise typer.Abort() from e
|
||||
except PlanError as e:
|
||||
console.print(f"[red]Cannot revert:[/red] {e.message}")
|
||||
raise typer.Abort() from e
|
||||
except CleverAgentsError as e:
|
||||
console.print(f"[red]Error:[/red] {e.message}")
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
def _get_apply_service():
|
||||
"""Get the PlanApplyService from the lifecycle service."""
|
||||
from cleveragents.application.services.plan_apply_service import (
|
||||
|
||||
@@ -662,6 +662,19 @@ class Plan(BaseModel):
|
||||
description="Status tracking for spawned subplans",
|
||||
)
|
||||
|
||||
# Phase reversion tracking
|
||||
reversion_count: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of phase reversions performed on this plan",
|
||||
)
|
||||
decisions: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Decision log entries including reversions",
|
||||
)
|
||||
|
||||
MAX_REVERSIONS: ClassVar[int] = 3
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_phase_state_consistency(self) -> Plan:
|
||||
"""Ensure phase and state are consistent.
|
||||
@@ -772,6 +785,29 @@ class Plan(BaseModel):
|
||||
"""Check if this plan has spawned subplans."""
|
||||
return len(self.subplan_statuses) > 0
|
||||
|
||||
def can_revert_to(self, phase: PlanPhase) -> bool:
|
||||
"""Check if reversion to the given phase is valid.
|
||||
|
||||
Reversion is valid when:
|
||||
- The plan is not in a permanently terminal state (APPLIED or CANCELLED).
|
||||
- The target phase is a valid reversion target from the current phase.
|
||||
- The reversion count has not exceeded MAX_REVERSIONS.
|
||||
|
||||
Args:
|
||||
phase: The target phase to revert to.
|
||||
|
||||
Returns:
|
||||
True if reversion is allowed.
|
||||
"""
|
||||
if self.processing_state in (
|
||||
ProcessingState.APPLIED,
|
||||
ProcessingState.CANCELLED,
|
||||
):
|
||||
return False
|
||||
if self.reversion_count >= self.MAX_REVERSIONS:
|
||||
return False
|
||||
return can_transition(self.phase, phase)
|
||||
|
||||
def as_cli_dict(self) -> dict[str, Any]:
|
||||
"""Return a stable dictionary representation for CLI output.
|
||||
|
||||
|
||||
@@ -234,6 +234,23 @@ _get_apply_service # noqa: B018, F821
|
||||
plan_diff # noqa: B018, F821
|
||||
plan_artifacts # noqa: B018, F821
|
||||
|
||||
# Config service — public API for multi-level resolution
|
||||
ConfigService # noqa: B018, F821
|
||||
ConfigEntry # noqa: B018, F821
|
||||
ConfigLevel # noqa: B018, F821
|
||||
ResolvedValue # noqa: B018, F821
|
||||
_build_catalog # noqa: B018, F821
|
||||
_DEFAULT_CONFIG_DIR # noqa: B018, F821
|
||||
_DEFAULT_CONFIG_PATH # noqa: B018, F821
|
||||
resolve_all # noqa: B018, F821
|
||||
env_var_for_key # noqa: B018, F821
|
||||
registered_keys # noqa: B018, F821
|
||||
validate_type # noqa: B018, F821
|
||||
set_value # noqa: B018, F821
|
||||
write_config # noqa: B018, F821
|
||||
read_config # noqa: B018, F821
|
||||
get_entry # noqa: B018, F821
|
||||
|
||||
# ToolCallRouter — public API for provider-agnostic tool routing
|
||||
ToolCallRouter # noqa: B018, F821
|
||||
ToolCallRequest # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user