feat(repo): add resource repositories
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 30s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 5m12s
CI / build (pull_request) Successful in 16s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 29s
CI / security (push) Successful in 23s
CI / quality (push) Successful in 16s
CI / unit_tests (pull_request) Successful in 13m42s
CI / integration_tests (push) Successful in 4m53s
CI / build (push) Successful in 16s
CI / unit_tests (push) Successful in 15m9s
CI / coverage (pull_request) Successful in 7m58s
CI / coverage (push) Successful in 8m14s
CI / docker (pull_request) Successful in 9s
CI / docker (push) Successful in 8s
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 30s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 5m12s
CI / build (pull_request) Successful in 16s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 29s
CI / security (push) Successful in 23s
CI / quality (push) Successful in 16s
CI / unit_tests (pull_request) Successful in 13m42s
CI / integration_tests (push) Successful in 4m53s
CI / build (push) Successful in 16s
CI / unit_tests (push) Successful in 15m9s
CI / coverage (pull_request) Successful in 7m58s
CI / coverage (push) Successful in 8m14s
CI / docker (pull_request) Successful in 9s
CI / docker (push) Successful in 8s
This commit was merged in pull request #69.
This commit is contained in:
committed by
Forgejo
parent
c5617b0457
commit
36c571db83
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PYPROJECT_PATH = Path("pyproject.toml")
|
||||
NOXFILE_PATH = Path("noxfile.py")
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ from cleveragents.domain.models.core.plan import (
|
||||
can_transition,
|
||||
)
|
||||
|
||||
|
||||
# Valid ULID constants for benchmarks
|
||||
_ULID_A = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
||||
_ULID_B = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""ASV benchmarks for resource repository operations."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def _setup_db():
|
||||
"""Create an in-memory database with schema and return session factory."""
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk(conn, _rec):
|
||||
conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)
|
||||
|
||||
|
||||
def _seed_type(rt_repo, name="bench/seed-type"):
|
||||
"""Seed a resource type for benchmarks."""
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
spec = ResourceTypeSpec(
|
||||
name=name,
|
||||
description="Bench type",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy.NONE,
|
||||
user_addable=True,
|
||||
cli_args=[],
|
||||
parent_types=[],
|
||||
child_types=[],
|
||||
auto_discovery=None,
|
||||
equivalence=None,
|
||||
handler=None,
|
||||
capabilities={
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
built_in=False,
|
||||
)
|
||||
rt_repo.create(spec)
|
||||
return spec
|
||||
|
||||
|
||||
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
_BENCH_CTR = 0
|
||||
|
||||
|
||||
def _bench_ulid():
|
||||
global _BENCH_CTR
|
||||
_BENCH_CTR += 1
|
||||
n = _BENCH_CTR
|
||||
suffix = ""
|
||||
for _ in range(8):
|
||||
suffix = _CB32[n % 32] + suffix
|
||||
n //= 32
|
||||
return f"01HGZ6FE0AQDYTR4BX{suffix}"
|
||||
|
||||
|
||||
class TimeResourceTypeCreate:
|
||||
"""Benchmark ResourceTypeRepository.create() throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
self.factory = _setup_db()
|
||||
self.repo = ResourceTypeRepository(self.factory)
|
||||
self._counter = 0
|
||||
|
||||
def time_create_resource_type(self):
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
self._counter += 1
|
||||
spec = ResourceTypeSpec(
|
||||
name=f"bench/type-{self._counter}",
|
||||
description="Bench",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy.NONE,
|
||||
user_addable=True,
|
||||
cli_args=[],
|
||||
parent_types=[],
|
||||
child_types=[],
|
||||
auto_discovery=None,
|
||||
equivalence=None,
|
||||
handler=None,
|
||||
capabilities={
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
built_in=False,
|
||||
)
|
||||
self.repo.create(spec)
|
||||
|
||||
|
||||
class TimeResourceTypeList:
|
||||
"""Benchmark ResourceTypeRepository.list_types() throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
self.factory = _setup_db()
|
||||
self.repo = ResourceTypeRepository(self.factory)
|
||||
for i in range(50):
|
||||
_seed_type(self.repo, name=f"bench/list-type-{i}")
|
||||
|
||||
def time_list_all_types(self):
|
||||
self.repo.list_types()
|
||||
|
||||
def time_list_by_namespace(self):
|
||||
self.repo.list_types(namespace="bench")
|
||||
|
||||
|
||||
class TimeResourceCreate:
|
||||
"""Benchmark ResourceRepository.create() throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceRepository,
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
self.factory = _setup_db()
|
||||
self.rt_repo = ResourceTypeRepository(self.factory)
|
||||
self.res_repo = ResourceRepository(self.factory)
|
||||
_seed_type(self.rt_repo)
|
||||
self._counter = 0
|
||||
|
||||
def time_create_resource(self):
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
|
||||
self._counter += 1
|
||||
res = Resource(
|
||||
resource_id=_bench_ulid(),
|
||||
name=f"bench/res-{self._counter}",
|
||||
resource_type_name="bench/seed-type",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Bench resource",
|
||||
properties={},
|
||||
location="/tmp/bench",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
updated_at=datetime.now(tz=UTC),
|
||||
)
|
||||
self.res_repo.create(res)
|
||||
|
||||
|
||||
class TimeResourceResolve:
|
||||
"""Benchmark ResourceRepository.resolve_namespaced_name() throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceRepository,
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
self.factory = _setup_db()
|
||||
self.rt_repo = ResourceTypeRepository(self.factory)
|
||||
self.res_repo = ResourceRepository(self.factory)
|
||||
_seed_type(self.rt_repo)
|
||||
self.ulid = _bench_ulid()
|
||||
res = Resource(
|
||||
resource_id=self.ulid,
|
||||
name="bench/resolve-target",
|
||||
resource_type_name="bench/seed-type",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Resolve target",
|
||||
properties={},
|
||||
location="/tmp/bench",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
updated_at=datetime.now(tz=UTC),
|
||||
)
|
||||
self.res_repo.create(res)
|
||||
|
||||
def time_resolve_by_name(self):
|
||||
self.res_repo.resolve_namespaced_name("bench/resolve-target")
|
||||
|
||||
def time_resolve_by_ulid(self):
|
||||
self.res_repo.resolve_namespaced_name(self.ulid)
|
||||
@@ -13,7 +13,6 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from cleveragents.tool.builtins import (
|
||||
|
||||
@@ -37,7 +37,6 @@ except ModuleNotFoundError:
|
||||
ToolCapability,
|
||||
ToolLifecycle,
|
||||
ToolSource,
|
||||
ToolType,
|
||||
Validation,
|
||||
ValidationMode,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
from cleveragents.tool.runner import ToolRunner
|
||||
from cleveragents.tool.runtime import ToolResult, ToolSpec
|
||||
from cleveragents.tool.runtime import ToolSpec
|
||||
|
||||
|
||||
def _echo(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -84,6 +84,76 @@ See `ActionRepository` class documentation in `repositories.py` for full method
|
||||
| `DuplicateActionError` | `DatabaseError` | Raised when creating an action with a name that already exists. |
|
||||
| `ActionInUseError` | `BusinessRuleViolation` | Raised when deleting an action still referenced by plans. |
|
||||
|
||||
## Resource Registry Repositories
|
||||
|
||||
The resource registry persistence layer is implemented by two repositories in
|
||||
`src/cleveragents/infrastructure/database/repositories.py`:
|
||||
|
||||
### ResourceTypeRepository
|
||||
|
||||
Persists and retrieves `ResourceTypeSpec` domain objects backed by the
|
||||
`resource_types` table.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create(resource_type)` | Persist a new resource type. Raises `DuplicateResourceTypeError` on conflict. |
|
||||
| `get(name)` | Retrieve by primary key (namespaced name string). Returns `None` if missing. |
|
||||
| `list_types(namespace?, user_addable?)` | List with optional namespace and user_addable filters, ordered by name. |
|
||||
| `update(resource_type)` | Update mutable fields. Raises `ResourceTypeNotFoundError` if missing. |
|
||||
| `delete(name)` | Delete by name. Raises `ResourceTypeHasResourcesError` if resources reference the type. Returns `False` if not found. |
|
||||
|
||||
**Session pattern**: Each method obtains its own session from the factory.
|
||||
Mutating methods flush but do not commit.
|
||||
|
||||
### ResourceRepository
|
||||
|
||||
Persists and retrieves `Resource` domain objects backed by the
|
||||
`resources` table.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create(resource)` | Persist a new resource. Validates type existence. Raises `ResourceTypeNotFoundError` or `DuplicateResourceError`. |
|
||||
| `get(resource_id)` | Retrieve by ULID primary key. Returns `None` if missing. |
|
||||
| `get_by_name(namespaced_name)` | Retrieve by unique namespaced name. Returns `None` if missing. |
|
||||
| `list_resources(type_name?, namespace?, limit?, offset?)` | List with filters and pagination. |
|
||||
| `update(resource)` | Update metadata. Raises `ResourceNotFoundRepoError` if missing. |
|
||||
| `delete(resource_id)` | Delete by ULID. Raises `ResourceHasEdgesError` if DAG edges exist. |
|
||||
| `resolve_namespaced_name(name_or_id)` | Try name lookup first, then fall back to ULID. Returns `None` if not found. |
|
||||
|
||||
### Resource Registry Custom Exceptions
|
||||
|
||||
| Exception | Base | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `ResourceTypeNotFoundError` | `DatabaseError` | Resource type lookup failed |
|
||||
| `ResourceNotFoundRepoError` | `DatabaseError` | Resource lookup failed |
|
||||
| `ResourceTypeHasResourcesError` | `BusinessRuleViolation` | Delete blocked by referencing resources |
|
||||
| `ResourceHasEdgesError` | `BusinessRuleViolation` | Delete blocked by DAG edges |
|
||||
| `DuplicateResourceTypeError` | `DatabaseError` | Name uniqueness violation |
|
||||
| `DuplicateResourceError` | `DatabaseError` | Name uniqueness violation |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```python
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceTypeRepository,
|
||||
ResourceRepository,
|
||||
)
|
||||
|
||||
factory = sessionmaker(bind=engine)
|
||||
rt_repo = ResourceTypeRepository(factory)
|
||||
res_repo = ResourceRepository(factory)
|
||||
|
||||
# Create a resource type
|
||||
rt_repo.create(my_resource_type_spec)
|
||||
|
||||
# Create a resource
|
||||
res_repo.create(my_resource)
|
||||
|
||||
# Resolve by name or ULID
|
||||
result = res_repo.resolve_namespaced_name("myorg/my-resource")
|
||||
```
|
||||
|
||||
## UnitOfWork Lifecycle Usage
|
||||
|
||||
**Module**: `src/cleveragents/infrastructure/database/unit_of_work.py`
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
Feature: Copy-on-write sandbox coverage boost
|
||||
As a developer
|
||||
I want to exercise uncovered edge-case paths in CopyOnWriteSandbox
|
||||
So that code coverage for copy_on_write.py is comprehensive
|
||||
|
||||
# --- SandboxCreationError re-raise when original path is a file ---
|
||||
|
||||
Scenario: Creating a sandbox on a file (not directory) raises SandboxCreationError
|
||||
Given a cowcb temporary file instead of a directory
|
||||
When a cowcb sandbox is created on that file for plan "plan-err"
|
||||
Then a cowcb SandboxCreationError should be raised
|
||||
And the cowcb sandbox status should be "errored"
|
||||
|
||||
# --- get_path with _sandbox_path manually set to None ---
|
||||
|
||||
Scenario: get_path raises SandboxStateError when sandbox_path is None
|
||||
Given a cowcb sandbox with status CREATED and sandbox_path None
|
||||
When cowcb get_path is called with "file.txt"
|
||||
Then a cowcb SandboxStateError should be raised with message "Sandbox path not set"
|
||||
|
||||
# --- commit with _sandbox_path manually set to None ---
|
||||
|
||||
Scenario: commit raises SandboxStateError when sandbox_path is None
|
||||
Given a cowcb sandbox with status ACTIVE and sandbox_path None
|
||||
When cowcb commit is called
|
||||
Then a cowcb SandboxStateError should be raised with message "Sandbox path not set"
|
||||
|
||||
# --- commit triggers OSError during sync ---
|
||||
|
||||
Scenario: commit wraps OSError into SandboxCommitError
|
||||
Given a cowcb test directory is initialised
|
||||
And a cowcb sandbox is created and activated for plan "plan-oserr"
|
||||
When the cowcb original directory is removed before commit
|
||||
And cowcb commit is called
|
||||
Then a cowcb SandboxCommitError should be raised
|
||||
And the cowcb sandbox status should be "errored"
|
||||
|
||||
# --- rollback with _sandbox_path manually set to None ---
|
||||
|
||||
Scenario: rollback raises SandboxStateError when sandbox_path is None
|
||||
Given a cowcb sandbox with status ACTIVE and sandbox_path None
|
||||
When cowcb rollback is called
|
||||
Then a cowcb SandboxStateError should be raised with message "Sandbox path not set"
|
||||
|
||||
# --- rollback triggers OSError during re-copy ---
|
||||
|
||||
Scenario: rollback wraps OSError into SandboxRollbackError
|
||||
Given a cowcb test directory is initialised
|
||||
And a cowcb sandbox is created and activated for plan "plan-rbfail"
|
||||
When the cowcb original directory is removed before rollback
|
||||
And cowcb rollback is called
|
||||
Then a cowcb SandboxRollbackError should be raised
|
||||
And the cowcb sandbox status should be "errored"
|
||||
|
||||
# --- cleanup when _sandbox_path is None ---
|
||||
|
||||
Scenario: cleanup succeeds when sandbox_path is None
|
||||
Given a cowcb sandbox with status PENDING and sandbox_path None
|
||||
When cowcb cleanup is called
|
||||
Then the cowcb sandbox status should be "cleaned_up"
|
||||
|
||||
# --- cleanup when sandbox parent directory does not exist ---
|
||||
|
||||
Scenario: cleanup succeeds when sandbox parent directory is already gone
|
||||
Given a cowcb test directory is initialised
|
||||
And a cowcb sandbox is created for plan "plan-cleanparent"
|
||||
When the cowcb sandbox parent directory is removed
|
||||
And cowcb cleanup is called
|
||||
Then the cowcb sandbox status should be "cleaned_up"
|
||||
|
||||
# --- commit with dst_dir that needs creation (added file in subdirectory) ---
|
||||
|
||||
Scenario: commit creates missing destination subdirectories for added files
|
||||
Given a cowcb test directory is initialised
|
||||
And a cowcb sandbox is created and activated for plan "plan-subdir"
|
||||
When a cowcb file "newdir/nested/added.txt" is created in the sandbox
|
||||
And cowcb commit is called
|
||||
Then the cowcb commit should succeed
|
||||
And the cowcb file "newdir/nested/added.txt" should exist in the original directory
|
||||
|
||||
# --- commit with file deleted from sandbox that is already missing from original ---
|
||||
|
||||
Scenario: commit handles deleted file not present in original
|
||||
Given a cowcb test directory is initialised
|
||||
And a cowcb sandbox is created and activated for plan "plan-delmissing"
|
||||
When the cowcb file "existing.txt" is deleted from both sandbox and original
|
||||
And cowcb commit is called
|
||||
Then the cowcb commit should succeed
|
||||
@@ -0,0 +1,132 @@
|
||||
@phase1 @database @models @coverage_boost
|
||||
Feature: Database model coverage boost for uncovered branches
|
||||
As a developer
|
||||
I want to exercise uncovered branches in to_domain and from_domain conversions
|
||||
So that database model code coverage increases
|
||||
|
||||
Background:
|
||||
Given the coverage boost database is ready
|
||||
And a coverage boost database session is open
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# LifecycleActionModel.to_domain: default_value_json and inputs_schema_json
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@action @to_domain @default_value
|
||||
Scenario: Action to_domain deserializes argument default values from JSON
|
||||
Given an action model exists with arguments that have default values set
|
||||
When the action model is converted to a domain object
|
||||
Then the domain action argument should have the correct default value
|
||||
And the domain action argument without a default should have None
|
||||
|
||||
@action @to_domain @inputs_schema
|
||||
Scenario: Action to_domain deserializes inputs_schema from JSON
|
||||
Given an action model exists with inputs_schema_json populated
|
||||
When the action model is converted to a domain object
|
||||
Then the domain action should have the parsed inputs_schema dict
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# LifecycleActionModel.from_domain: inputs_schema, default_value, invariants
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@action @from_domain @inputs_schema
|
||||
Scenario: Action from_domain serializes inputs_schema to JSON
|
||||
Given an action domain object with inputs_schema set
|
||||
When the action domain object is converted to a database model
|
||||
Then the database model should have inputs_schema_json as a JSON string
|
||||
|
||||
@action @from_domain @default_value
|
||||
Scenario: Action from_domain serializes argument default values to JSON
|
||||
Given an action domain object with arguments that have default values
|
||||
When the action domain object is converted to a database model
|
||||
Then the database model arguments should have default_value_json set
|
||||
And the argument without a default should have null default_value_json
|
||||
|
||||
@action @from_domain @invariants
|
||||
Scenario: Action from_domain populates invariants child models
|
||||
Given an action domain object with invariants defined
|
||||
When the action domain object is converted to a database model
|
||||
Then the database model should have invariant child records
|
||||
And each invariant child record should have correct text and position
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# LifecyclePlanModel.to_domain: automation_profile, sandbox_refs, validation_summary, error_details
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@plan @to_domain @automation_profile
|
||||
Scenario: Plan to_domain deserializes automation_profile from JSON
|
||||
Given a plan model exists with automation_profile JSON set
|
||||
When the plan model is converted to a domain object
|
||||
Then the domain plan should have an automation profile with the correct name
|
||||
And the domain plan automation profile should have the correct provenance
|
||||
|
||||
@plan @to_domain @sandbox_refs
|
||||
Scenario: Plan to_domain deserializes sandbox_refs from JSON
|
||||
Given a plan model exists with sandbox_refs_json populated
|
||||
When the plan model is converted to a domain object
|
||||
Then the domain plan should have the parsed sandbox refs list
|
||||
|
||||
@plan @to_domain @validation_summary
|
||||
Scenario: Plan to_domain deserializes validation_summary from JSON
|
||||
Given a plan model exists with validation_summary_json populated
|
||||
When the plan model is converted to a domain object
|
||||
Then the domain plan should have the parsed validation summary dict
|
||||
|
||||
@plan @to_domain @error_details
|
||||
Scenario: Plan to_domain deserializes error_details from JSON
|
||||
Given a plan model exists with error_details_json populated
|
||||
When the plan model is converted to a domain object
|
||||
Then the domain plan should have the parsed error details dict
|
||||
|
||||
@plan @to_domain @arguments
|
||||
Scenario: Plan to_domain deserializes arguments from child table
|
||||
Given a plan model exists with argument child records
|
||||
When the plan model is converted to a domain object
|
||||
Then the domain plan should have the arguments dict populated
|
||||
And the domain plan should have arguments_order populated
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# LifecyclePlanModel.from_domain: project_links, invariants, arguments
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@plan @from_domain @project_links
|
||||
Scenario: Plan from_domain populates project link child models
|
||||
Given a plan domain object with project links defined
|
||||
When the plan domain object is converted to a database model via from_domain
|
||||
Then the database plan model should have project link child records
|
||||
And each project link child record should have the correct project name
|
||||
|
||||
@plan @from_domain @invariants
|
||||
Scenario: Plan from_domain populates invariant child models
|
||||
Given a plan domain object with plan invariants defined
|
||||
When the plan domain object is converted to a database model via from_domain
|
||||
Then the database plan model should have invariant child records with source
|
||||
And each plan invariant child record should have correct text and source
|
||||
|
||||
@plan @from_domain @arguments
|
||||
Scenario: Plan from_domain populates argument child models
|
||||
Given a plan domain object with arguments and arguments_order defined
|
||||
When the plan domain object is converted to a database model via from_domain
|
||||
Then the database plan model should have argument child records
|
||||
And each plan argument child record should have correct name and value
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# NamespacedProjectModel.to_domain: resource_links
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@project @to_domain @resource_links
|
||||
Scenario: Project to_domain builds linked resources from child relationship
|
||||
Given a project model exists with resource link child records
|
||||
When the project model is converted to a domain object
|
||||
Then the domain project should have linked resources populated
|
||||
And each linked resource should have the correct resource id and alias
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# NamespacedProjectModel.to_domain: context_config parsing
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@project @to_domain @context_config
|
||||
Scenario: Project to_domain parses context_policy_json into ContextConfig
|
||||
Given a project model exists with context_policy_json populated
|
||||
When the project model is converted to a domain object
|
||||
Then the domain project should have a context config with custom values
|
||||
@@ -0,0 +1,108 @@
|
||||
Feature: Git worktree sandbox coverage boost
|
||||
As a developer
|
||||
I want to exercise uncovered edge cases in the git worktree sandbox
|
||||
So that code coverage is maximised for git_worktree.py
|
||||
|
||||
# --- _sanitise_branch_name edge case ---
|
||||
|
||||
Scenario: Sanitise branch name with all-special-chars falls back to sandbox
|
||||
When gwtcb _sanitise_branch_name is called with "!!!"
|
||||
Then gwtcb the result should be "sandbox"
|
||||
|
||||
# --- create fails when path is not repo root ---
|
||||
|
||||
Scenario: Create fails when path is not the repository root
|
||||
Given a gwtcb sandbox pointed at a non-root subdirectory
|
||||
When gwtcb create is called for plan "plan-001"
|
||||
Then a gwtcb SandboxCreationError should be raised
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- create times out ---
|
||||
|
||||
Scenario: Create raises SandboxCreationError on timeout
|
||||
Given a gwtcb sandbox with mocked _run_git that times out
|
||||
When gwtcb create is called expecting a timeout error
|
||||
Then a gwtcb SandboxCreationError should be raised with message "timed out"
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- get_path with None worktree_path ---
|
||||
|
||||
Scenario: get_path raises SandboxStateError when worktree_path is None
|
||||
Given a gwtcb sandbox in CREATED state with None worktree_path
|
||||
When gwtcb get_path is called with "some/file.py"
|
||||
Then a gwtcb SandboxStateError should be raised with message "Worktree path not set"
|
||||
|
||||
# --- commit with uninitialised worktree ---
|
||||
|
||||
Scenario: Commit raises SandboxStateError when worktree not initialised
|
||||
Given a gwtcb sandbox in ACTIVE state with no worktree or branch
|
||||
When gwtcb commit is called with message "should fail"
|
||||
Then a gwtcb SandboxStateError should be raised with message "Worktree not initialised"
|
||||
|
||||
# --- commit with modified files in diff output ---
|
||||
|
||||
Scenario: Commit parses modified files in diff output
|
||||
Given a gwtcb sandbox ready to commit with modified-file diff output
|
||||
When gwtcb commit is called with message "modify files"
|
||||
Then the gwtcb commit result should include changed file "src/modified.py"
|
||||
And the gwtcb commit result should include added file "src/new.py"
|
||||
And the gwtcb commit result should include deleted file "src/old.py"
|
||||
|
||||
# --- commit timeout ---
|
||||
|
||||
Scenario: Commit raises SandboxCommitError on timeout
|
||||
Given a gwtcb sandbox in ACTIVE state ready to commit
|
||||
And gwtcb _run_git is mocked to raise TimeoutExpired on commit
|
||||
When gwtcb commit is called expecting a timeout error
|
||||
Then a gwtcb SandboxCommitError should be raised with message "timed out"
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- commit CalledProcessError ---
|
||||
|
||||
Scenario: Commit raises SandboxCommitError on CalledProcessError
|
||||
Given a gwtcb sandbox in ACTIVE state ready to commit
|
||||
And gwtcb _run_git is mocked to raise CalledProcessError on commit
|
||||
When gwtcb commit is called expecting a process error
|
||||
Then a gwtcb SandboxCommitError should be raised with message "Failed to commit"
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- rollback with uninitialised worktree ---
|
||||
|
||||
Scenario: Rollback raises SandboxStateError when worktree not initialised
|
||||
Given a gwtcb sandbox in ACTIVE state with no worktree or base commit
|
||||
When gwtcb rollback is called expecting a state error
|
||||
Then a gwtcb SandboxStateError should be raised with message "Worktree not initialised"
|
||||
|
||||
# --- rollback timeout ---
|
||||
|
||||
Scenario: Rollback raises SandboxRollbackError on timeout
|
||||
Given a gwtcb sandbox in ACTIVE state ready to rollback
|
||||
And gwtcb _run_git is mocked to raise TimeoutExpired on rollback
|
||||
When gwtcb rollback is called expecting a timeout error
|
||||
Then a gwtcb SandboxRollbackError should be raised with message "timed out"
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- rollback CalledProcessError ---
|
||||
|
||||
Scenario: Rollback raises SandboxRollbackError on CalledProcessError
|
||||
Given a gwtcb sandbox in ACTIVE state ready to rollback
|
||||
And gwtcb _run_git is mocked to raise CalledProcessError on rollback
|
||||
When gwtcb rollback is called expecting a process error
|
||||
Then a gwtcb SandboxRollbackError should be raised with message "Failed to rollback"
|
||||
And the gwtcb sandbox should be in the "errored" state
|
||||
|
||||
# --- cleanup with worktree remove failure ---
|
||||
|
||||
Scenario: Cleanup falls back to shutil when git worktree remove fails
|
||||
Given a gwtcb sandbox with a worktree directory that exists
|
||||
And gwtcb _run_git is mocked to fail on worktree remove
|
||||
When gwtcb cleanup is called
|
||||
Then the gwtcb sandbox should be in the "cleaned_up" state
|
||||
|
||||
# --- cleanup with branch delete failure ---
|
||||
|
||||
Scenario: Cleanup warns and continues when branch delete fails
|
||||
Given a gwtcb sandbox with a branch name set but no worktree directory
|
||||
And gwtcb _run_git is mocked to fail on branch delete
|
||||
When gwtcb cleanup is called
|
||||
Then the gwtcb sandbox should be in the "cleaned_up" state
|
||||
@@ -0,0 +1,165 @@
|
||||
Feature: Plan CLI coverage boost
|
||||
As a developer
|
||||
I want to exercise uncovered branches in plan.py
|
||||
So that code coverage is improved for the plan CLI module
|
||||
|
||||
# ---- _plan_spec_dict helper ----
|
||||
|
||||
Scenario: _plan_spec_dict returns error_message when truthy
|
||||
Given a v3 Plan with error_message set to "Strategy failed"
|
||||
When I call _plan_spec_dict on the plan
|
||||
Then the spec dict should contain key "error_message" with value "Strategy failed"
|
||||
|
||||
Scenario: _plan_spec_dict falls back to legacy format for non-Plan objects
|
||||
Given a non-Plan object with string value "legacy plan data"
|
||||
When I call _plan_spec_dict on the object
|
||||
Then the spec dict should equal {"plan": "legacy plan data"}
|
||||
|
||||
Scenario: _plan_spec_dict omits error_message when it is None
|
||||
Given a v3 Plan with error_message set to None
|
||||
When I call _plan_spec_dict on the plan
|
||||
Then the spec dict should not contain key "error_message"
|
||||
|
||||
# ---- _print_lifecycle_plan helper ----
|
||||
|
||||
Scenario: _print_lifecycle_plan prints all optional timestamps
|
||||
Given a v3 Plan with all timestamps populated
|
||||
When I call _print_lifecycle_plan on the plan
|
||||
Then the printed output should contain "Strategize Started"
|
||||
And the printed output should contain "Strategize Completed"
|
||||
And the printed output should contain "Execute Started"
|
||||
And the printed output should contain "Execute Completed"
|
||||
And the printed output should contain "Applied At"
|
||||
|
||||
Scenario: _print_lifecycle_plan prints estimation_actor when set
|
||||
Given a v3 Plan with estimation_actor set to "local/cost-estimator"
|
||||
When I call _print_lifecycle_plan on the plan
|
||||
Then the printed output should contain "Estimation Actor"
|
||||
And the printed output should contain "local/cost-estimator"
|
||||
|
||||
Scenario: _print_lifecycle_plan prints invariant_actor when set
|
||||
Given a v3 Plan with invariant_actor set to "local/invariant-checker"
|
||||
When I call _print_lifecycle_plan on the plan
|
||||
Then the printed output should contain "Invariant Actor"
|
||||
And the printed output should contain "local/invariant-checker"
|
||||
|
||||
Scenario: _print_lifecycle_plan falls back for non-Plan objects
|
||||
Given a non-Plan object with string value "legacy-plan-object"
|
||||
When I call _print_lifecycle_plan on the object
|
||||
Then the printed output should contain "legacy-plan-object"
|
||||
|
||||
# ---- list_plans non-rich format ----
|
||||
|
||||
Scenario: list_plans outputs JSON when format is json
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the mocked plan service returns legacy plans
|
||||
When I invoke list_plans with "--format" "json"
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "plan_id"
|
||||
|
||||
# ---- execute_plan non-rich format ----
|
||||
|
||||
Scenario: execute_plan outputs JSON when format is json
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has a complete strategize plan for execute
|
||||
When I invoke execute with "--format" "json" and plan id
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "plan_id"
|
||||
And the plan coverage output should contain "namespaced_name"
|
||||
|
||||
# ---- lifecycle_apply_plan non-rich format ----
|
||||
|
||||
Scenario: lifecycle_apply_plan outputs JSON when format is json
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has a complete execute plan for apply
|
||||
When I invoke lifecycle-apply with "--format" "json" and plan id
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "plan_id"
|
||||
|
||||
# ---- lifecycle_list_plans regex and state/processing_state filtering ----
|
||||
|
||||
Scenario: lifecycle_list_plans filters by regex pattern
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has multiple plans for lifecycle list
|
||||
When I invoke lifecycle-list with regex "alpha"
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "alpha"
|
||||
And the plan coverage output should not contain "beta"
|
||||
|
||||
Scenario: lifecycle_list_plans filters by state
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has plans in different processing states
|
||||
When I invoke lifecycle-list with "--state" "processing"
|
||||
Then the plan coverage command should succeed
|
||||
|
||||
Scenario: lifecycle_list_plans filters by processing_state alias
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has plans in different processing states
|
||||
When I invoke lifecycle-list with "--processing-state" "complete"
|
||||
Then the plan coverage command should succeed
|
||||
|
||||
Scenario: lifecycle_list_plans rejects invalid regex
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has multiple plans for lifecycle list
|
||||
When I invoke lifecycle-list with regex "[invalid"
|
||||
Then the plan coverage command should abort
|
||||
And the plan coverage output should contain "Invalid regex"
|
||||
|
||||
Scenario: lifecycle_list_plans filters by action name
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has multiple plans for lifecycle list
|
||||
When I invoke lifecycle-list with "--action" "local/test-action"
|
||||
Then the plan coverage command should succeed
|
||||
|
||||
Scenario: lifecycle_list_plans outputs JSON when format is json
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service has multiple plans for lifecycle list
|
||||
When I invoke lifecycle-list with "--format" "json"
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "plan_id"
|
||||
|
||||
# ---- cancel_plan ----
|
||||
|
||||
Scenario: cancel_plan in non-rich format without reason
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service can cancel a plan
|
||||
When I invoke cancel with "--format" "json" and no reason
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "plan_id"
|
||||
And the plan coverage output should not contain "cancel_reason"
|
||||
|
||||
Scenario: cancel_plan in non-rich format with reason
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service can cancel a plan
|
||||
When I invoke cancel with "--format" "json" and reason "not needed"
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "cancel_reason"
|
||||
And the plan coverage output should contain "not needed"
|
||||
|
||||
Scenario: cancel_plan in rich format with reason
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service can cancel a plan
|
||||
When I invoke cancel in rich format with reason "obsolete"
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "Plan cancelled"
|
||||
And the plan coverage output should contain "Reason: obsolete"
|
||||
|
||||
Scenario: cancel_plan in rich format without reason
|
||||
Given a plan lifecycle CLI runner for coverage
|
||||
And a mocked lifecycle service for plan coverage commands
|
||||
And the service can cancel a plan
|
||||
When I invoke cancel in rich format without reason
|
||||
Then the plan coverage command should succeed
|
||||
And the plan coverage output should contain "Plan cancelled"
|
||||
@@ -0,0 +1,72 @@
|
||||
Feature: Plan Lifecycle Service coverage boost
|
||||
As a developer
|
||||
I want to exercise uncovered branches in PlanLifecycleService
|
||||
So that code coverage improves for the specific gaps identified
|
||||
|
||||
Background:
|
||||
Given I have a fresh plan lifecycle service for coverage boost
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Line 284: get_action_by_name linear scan fallback
|
||||
# Branch 283:284 — the dict key doesn't match but the action's
|
||||
# namespaced_name string representation does.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: get_action_by_name falls back to linear scan when dict key does not match directly
|
||||
Given an action stored under a mismatched dict key
|
||||
When I look up the action by its namespaced name via get_action_by_name
|
||||
Then the action should be found via linear scan fallback
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Line 420: use_action appends PlanInvariant from action.invariants
|
||||
# Branch 419:420 — the loop body over action.invariants
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: use_action copies action invariants as PlanInvariants onto the new plan
|
||||
Given an action with invariants "no-breaking-changes" and "preserve-api-compat"
|
||||
When I use that action to create a plan
|
||||
Then the plan should contain 2 action-sourced invariants
|
||||
And the invariant texts should include "no-breaking-changes"
|
||||
And the invariant texts should include "preserve-api-compat"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Line 643: execute_plan raises InvalidPhaseTransitionError
|
||||
# Branch 642:643 — can_transition returns False
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: execute_plan raises InvalidPhaseTransitionError when plan is in Execute phase
|
||||
Given a plan that is already in execute phase
|
||||
When I try to execute the plan from execute phase
|
||||
Then an InvalidPhaseTransitionError should be raised for the coverage boost
|
||||
|
||||
Scenario: execute_plan raises InvalidPhaseTransitionError when plan is in Apply phase
|
||||
Given a plan that is already in apply phase
|
||||
When I try to execute the plan from apply phase
|
||||
Then an InvalidPhaseTransitionError should be raised for the coverage boost
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Lines 829-846: constrain_apply method — happy path and wrong phase
|
||||
# Branches 831:832,836
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: constrain_apply marks an Apply-phase plan as constrained
|
||||
Given a plan in apply phase with processing state for coverage boost
|
||||
When I constrain the apply with reason "invariant violated"
|
||||
Then the plan processing state should be "constrained" for coverage boost
|
||||
And the plan error message should be "invariant violated" for coverage boost
|
||||
|
||||
Scenario: constrain_apply raises PlanError when plan is not in Apply phase
|
||||
Given a plan in strategize phase for coverage boost
|
||||
When I try to constrain the apply with reason "should fail"
|
||||
Then a PlanError should be raised for coverage boost
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Line 959: auto_progress final return — plan doesn't match either
|
||||
# auto-progress condition.
|
||||
# Branch 948:959 — the else/fallback in auto_progress
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Scenario: auto_progress returns plan unchanged when neither auto-progress condition matches
|
||||
Given a plan in strategize queued state with full automation for coverage boost
|
||||
When I call auto_progress on the plan
|
||||
Then the plan should be returned unchanged in strategize queued state
|
||||
@@ -0,0 +1,137 @@
|
||||
@phase1 @domain @repository @coverage_boost
|
||||
Feature: Repository coverage boost for actions, plans, and resources
|
||||
As a developer ensuring high test coverage
|
||||
I want to exercise uncovered repository branches
|
||||
So that the persistence layer is thoroughly tested
|
||||
|
||||
Background:
|
||||
Given a fresh in-memory database with full lifecycle schema
|
||||
And an action repository using the session factory
|
||||
And a lifecycle plan repository using the session factory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ActionRepository.update – non-existent action raises DatabaseError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@action_update @error_handling
|
||||
Scenario: Updating a non-existent action raises DatabaseError with "not found for update"
|
||||
Given a valid action object named "local/ghost-action"
|
||||
When the action is updated without being persisted first
|
||||
Then a DatabaseError mentioning "not found for update" should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ActionRepository.update – with arguments (including default_value) and invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@action_update
|
||||
Scenario: Updating an action replaces its child arguments and invariants
|
||||
Given a valid action object named "local/updatable-with-children"
|
||||
And the action has been persisted in the database
|
||||
When the action arguments are replaced with new arguments including defaults
|
||||
And the action invariants are set to new invariant texts
|
||||
And the action is updated via the repository
|
||||
Then the update should succeed without error
|
||||
And retrieving the action should show the new arguments
|
||||
And retrieving the action should show the new invariants
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LifecyclePlanRepository.get_by_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@plan_read
|
||||
Scenario: A lifecycle plan is retrieved by its namespaced name
|
||||
Given a valid action object named "local/plan-action-lookup"
|
||||
And the action has been persisted in the database
|
||||
And a lifecycle plan domain object linked to "local/plan-action-lookup"
|
||||
And the lifecycle plan has been persisted in the database
|
||||
When the lifecycle plan is looked up by namespaced name
|
||||
Then the returned plan should match the original plan identity
|
||||
|
||||
@plan_read
|
||||
Scenario: Looking up a non-existent plan by name returns nothing
|
||||
When a lifecycle plan is looked up by name "local/nonexistent-plan"
|
||||
Then no lifecycle plan should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LifecyclePlanRepository.update – PlanNotFoundError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@plan_update @error_handling
|
||||
Scenario: Updating a non-existent lifecycle plan raises PlanNotFoundError
|
||||
Given a valid action object named "local/plan-action-missing"
|
||||
And the action has been persisted in the database
|
||||
And a lifecycle plan domain object linked to "local/plan-action-missing"
|
||||
When the lifecycle plan is updated without being persisted first
|
||||
Then a PlanNotFoundError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LifecyclePlanRepository.update – with project_links, arguments, invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@plan_update
|
||||
Scenario: Updating a plan replaces project links, arguments, and invariants
|
||||
Given a valid action object named "local/plan-action-update"
|
||||
And the action has been persisted in the database
|
||||
And a lifecycle plan domain object linked to "local/plan-action-update"
|
||||
And the lifecycle plan has been persisted in the database
|
||||
When the plan is updated with project links, arguments, and invariants
|
||||
Then the plan update should succeed without error
|
||||
And retrieving the plan should show the new project links
|
||||
And retrieving the plan should show the new plan arguments
|
||||
And retrieving the plan should show the new plan invariants
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LifecyclePlanRepository.list_plans filtered by phase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@plan_list
|
||||
Scenario: Listing plans filtered by phase returns only matching plans
|
||||
Given a valid action object named "local/plan-action-phase"
|
||||
And the action has been persisted in the database
|
||||
And lifecycle plans in different phases linked to "local/plan-action-phase"
|
||||
When plans are listed filtered by phase "strategize"
|
||||
Then only the strategize-phase plans should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository – create, get, list_types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type
|
||||
Scenario: A resource type is created and retrieved by name
|
||||
Given a resource type repository using the session factory
|
||||
And a valid resource type domain object named "test/my-git-checkout"
|
||||
When the resource type is created through the repository
|
||||
Then the resource type should be retrievable by name "test/my-git-checkout"
|
||||
|
||||
@resource_type @error_handling
|
||||
Scenario: Creating a duplicate resource type raises DuplicateResourceTypeError
|
||||
Given a resource type repository using the session factory
|
||||
And a valid resource type domain object named "test/dup-type"
|
||||
And the resource type has been persisted in the database
|
||||
When the same resource type is created again
|
||||
Then a DuplicateResourceTypeError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository – create, get, get_by_name, list_resources by type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource
|
||||
Scenario: A resource is created and retrieved by ID and name
|
||||
Given a resource type repository using the session factory
|
||||
And a resource repository using the session factory
|
||||
And a valid resource type domain object named "test/res-type-a"
|
||||
And the resource type has been persisted in the database
|
||||
And a valid resource domain object of type "test/res-type-a"
|
||||
When the resource is created through the repository
|
||||
Then the resource should be retrievable by its ID
|
||||
And the resource should be retrievable by its namespaced name
|
||||
|
||||
@resource
|
||||
Scenario: Resources are listed by type name
|
||||
Given a resource type repository using the session factory
|
||||
And a resource repository using the session factory
|
||||
And a valid resource type domain object named "test/list-type"
|
||||
And the resource type has been persisted in the database
|
||||
And multiple resources of type "test/list-type" have been created
|
||||
When resources are listed by type "test/list-type"
|
||||
Then all resources of that type should be returned
|
||||
@@ -0,0 +1,256 @@
|
||||
@phase1 @domain @repository @resource_repository
|
||||
Feature: Resource Repository Persistence
|
||||
As a system operator managing resources and resource types
|
||||
I want the resource repositories to reliably persist and retrieve entries
|
||||
So that the resource registry is durable and queryable
|
||||
|
||||
Background:
|
||||
Given a clean resource repository database
|
||||
And a resource type repository backed by the database
|
||||
And a resource repository backed by the database
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: Creating resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_create
|
||||
Scenario: A new resource type is persisted and retrievable
|
||||
Given a valid resource type spec named "myorg/test-type"
|
||||
When the resource type is saved through the repository
|
||||
Then the resource type repository should not raise an error
|
||||
And the persisted resource type should have name "myorg/test-type"
|
||||
|
||||
@resource_type_create @error_handling
|
||||
Scenario: Saving a resource type with a duplicate name is rejected
|
||||
Given a valid resource type spec named "myorg/dup-type"
|
||||
And the resource type has already been saved once
|
||||
When a second resource type with the same name "myorg/dup-type" is saved
|
||||
Then a DuplicateResourceTypeError should be raised mentioning "myorg/dup-type"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: Retrieving resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_read
|
||||
Scenario: A resource type is retrieved by its name
|
||||
Given a valid resource type spec named "myorg/lookup-type"
|
||||
And the resource type has been saved through the repository
|
||||
When the resource type is looked up by name "myorg/lookup-type"
|
||||
Then the returned resource type should have name "myorg/lookup-type"
|
||||
|
||||
@resource_type_read
|
||||
Scenario: Looking up a non-existent resource type returns nothing
|
||||
When the resource type is looked up by name "myorg/does-not-exist"
|
||||
Then no resource type should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: Listing resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_list
|
||||
Scenario: All resource types are listed in order
|
||||
Given resource types "myorg/b-type" and "myorg/a-type" have been saved
|
||||
When all resource types are listed
|
||||
Then the resource type list should have 2 entries
|
||||
And the first resource type in the list should be "myorg/a-type"
|
||||
|
||||
@resource_type_list
|
||||
Scenario: Resource types are filtered by namespace
|
||||
Given resource types "alpha/type-1" and "beta/type-2" have been saved
|
||||
When resource types are listed with namespace "alpha"
|
||||
Then the resource type list should have 1 entries
|
||||
And the first resource type in the list should be "alpha/type-1"
|
||||
|
||||
@resource_type_list
|
||||
Scenario: Resource types are filtered by user_addable flag
|
||||
Given a resource type "myorg/addable-type" with user_addable true is saved
|
||||
And a resource type "myorg/non-addable" with user_addable false is saved
|
||||
When resource types are listed with user_addable true
|
||||
Then the resource type list should have 1 entries
|
||||
And the first resource type in the list should be "myorg/addable-type"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: Updating resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_update
|
||||
Scenario: A resource type description is updated
|
||||
Given a valid resource type spec named "myorg/update-type"
|
||||
And the resource type has been saved through the repository
|
||||
When the resource type description is updated to "New description"
|
||||
Then the resource type should be retrievable with description "New description"
|
||||
|
||||
@resource_type_update @error_handling
|
||||
Scenario: Updating a non-existent resource type raises error
|
||||
When a non-existent resource type "myorg/phantom" is updated
|
||||
Then a ResourceTypeNotFoundError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: Deleting resource types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_delete
|
||||
Scenario: A resource type with no resources is deleted
|
||||
Given a valid resource type spec named "myorg/deletable-type"
|
||||
And the resource type has been saved through the repository
|
||||
When the resource type "myorg/deletable-type" is deleted
|
||||
Then the resource type deletion should return true
|
||||
And the resource type "myorg/deletable-type" should no longer exist
|
||||
|
||||
@resource_type_delete
|
||||
Scenario: Deleting a non-existent resource type returns false
|
||||
When the resource type "myorg/phantom" is deleted
|
||||
Then the resource type deletion should return false
|
||||
|
||||
@resource_type_delete @error_handling
|
||||
Scenario: Deleting a resource type with existing resources is rejected
|
||||
Given a valid resource type spec named "myorg/in-use-type"
|
||||
And the resource type has been saved through the repository
|
||||
And a resource of type "myorg/in-use-type" exists in the database
|
||||
When the resource type "myorg/in-use-type" is deleted
|
||||
Then a ResourceTypeHasResourcesError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: Creating resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_create
|
||||
Scenario: A new resource is persisted and retrievable
|
||||
Given a resource type "myorg/res-type" exists for resource tests
|
||||
And a valid resource domain object with name "myorg/test-resource"
|
||||
When the resource is saved through the repository
|
||||
Then the resource repository should not raise an error
|
||||
And the persisted resource should have name "myorg/test-resource"
|
||||
|
||||
@resource_create @error_handling
|
||||
Scenario: Creating a resource with invalid type is rejected
|
||||
Given a valid resource domain object with type "myorg/nonexistent-type"
|
||||
When the resource is saved through the repository
|
||||
Then a ResourceTypeNotFoundError should be raised for type "myorg/nonexistent-type"
|
||||
|
||||
@resource_create @error_handling
|
||||
Scenario: Creating a resource with duplicate name is rejected
|
||||
Given a resource type "myorg/dup-res-type" exists for resource tests
|
||||
And a resource with name "myorg/dup-resource" has been saved
|
||||
When a second resource with name "myorg/dup-resource" is saved
|
||||
Then a DuplicateResourceError should be raised mentioning "myorg/dup-resource"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: Retrieving resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_read
|
||||
Scenario: A resource is retrieved by its ULID
|
||||
Given a resource type "myorg/get-type" exists for resource tests
|
||||
And a resource with known ULID has been saved
|
||||
When the resource is looked up by its ULID
|
||||
Then the returned resource should have the correct ULID
|
||||
|
||||
@resource_read
|
||||
Scenario: A resource is retrieved by its namespaced name
|
||||
Given a resource type "myorg/name-type" exists for resource tests
|
||||
And a resource with name "myorg/named-resource" has been saved
|
||||
When the resource is looked up by name "myorg/named-resource"
|
||||
Then the returned resource should have name "myorg/named-resource"
|
||||
|
||||
@resource_read
|
||||
Scenario: Looking up a non-existent resource by ULID returns nothing
|
||||
When the resource is looked up by ULID "01ZZZZZZZZZZZZZZZZZZZZZZZZ"
|
||||
Then no resource should be returned
|
||||
|
||||
@resource_read
|
||||
Scenario: Looking up a non-existent resource by name returns nothing
|
||||
When the resource is looked up by name "myorg/no-such-resource"
|
||||
Then no resource should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: Listing resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_list
|
||||
Scenario: Resources are listed with pagination
|
||||
Given a resource type "myorg/list-type" exists for resource tests
|
||||
And 5 resources of type "myorg/list-type" have been saved
|
||||
When resources are listed with limit 3 and offset 0
|
||||
Then the resource list should have 3 entries
|
||||
|
||||
@resource_list
|
||||
Scenario: Resources are filtered by type_name
|
||||
Given resource types "myorg/type-a" and "myorg/type-b" exist for resource tests
|
||||
And 2 resources of type "myorg/type-a" have been saved
|
||||
And 3 resources of type "myorg/type-b" have been saved
|
||||
When resources are listed with type_name "myorg/type-a"
|
||||
Then the resource list should have 2 entries
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: Updating resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_update
|
||||
Scenario: A resource description is updated
|
||||
Given a resource type "myorg/upd-type" exists for resource tests
|
||||
And a resource with name "myorg/updatable-resource" has been saved
|
||||
When the resource description is updated to "Updated description"
|
||||
Then the resource should be retrievable with description "Updated description"
|
||||
|
||||
@resource_update @error_handling
|
||||
Scenario: Updating a non-existent resource raises error
|
||||
When a non-existent resource with ULID "01ZZZZZZZZZZZZZZZZZZZZZZZZ" is updated
|
||||
Then a ResourceNotFoundRepoError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: Deleting resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_delete
|
||||
Scenario: A resource with no edges is deleted
|
||||
Given a resource type "myorg/del-type" exists for resource tests
|
||||
And a resource with name "myorg/deletable-resource" has been saved
|
||||
When the resource is deleted by its ULID
|
||||
Then the resource deletion should return true
|
||||
And the resource should no longer exist
|
||||
|
||||
@resource_delete
|
||||
Scenario: Deleting a non-existent resource returns false
|
||||
When the resource with ULID "01ZZZZZZZZZZZZZZZZZZZZZZZZ" is deleted
|
||||
Then the resource deletion should return false
|
||||
|
||||
@resource_delete @error_handling
|
||||
Scenario: Deleting a resource with edges is rejected
|
||||
Given a resource type "myorg/edge-type" exists for resource tests
|
||||
And a parent and child resource of type "myorg/edge-type" are linked
|
||||
When the parent resource is deleted
|
||||
Then a ResourceHasEdgesError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceRepository: resolve_namespaced_name helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_resolve
|
||||
Scenario: Resolve by name returns the resource
|
||||
Given a resource type "myorg/resolve-type" exists for resource tests
|
||||
And a resource with name "myorg/resolve-me" has been saved
|
||||
When the resource is resolved by name_or_id "myorg/resolve-me"
|
||||
Then the resolved resource should have name "myorg/resolve-me"
|
||||
|
||||
@resource_resolve
|
||||
Scenario: Resolve by ULID returns the resource
|
||||
Given a resource type "myorg/resolve-ulid-type" exists for resource tests
|
||||
And a resource with known ULID has been saved for resolve test
|
||||
When the resource is resolved by its ULID
|
||||
Then the resolved resource should have the correct ULID
|
||||
|
||||
@resource_resolve
|
||||
Scenario: Resolve with unknown name returns nothing
|
||||
When the resource is resolved by name_or_id "myorg/unknown"
|
||||
Then no resolved resource should be returned
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeRepository: domain round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@resource_type_roundtrip
|
||||
Scenario: Resource type persists and restores all fields
|
||||
Given a full resource type spec with cli_args and capabilities
|
||||
When the full resource type is saved and retrieved
|
||||
Then the restored resource type should match the original fields
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Step definitions for copy-on-write sandbox coverage boost.
|
||||
|
||||
Covers uncovered lines and branches in copy_on_write.py:
|
||||
- SandboxCreationError re-raise (lines 140-142) when original path is a file
|
||||
- get_path / commit / rollback with _sandbox_path = None
|
||||
- OSError handlers in commit (lines 252-254) and rollback (lines 311-313)
|
||||
- cleanup with _sandbox_path = None (branch 341:347)
|
||||
- cleanup when parent dir already removed (branch 344:347)
|
||||
- commit dst_dir creation branch (branch 242:244)
|
||||
- commit deleted-file-not-in-original branch (branch 249:247)
|
||||
|
||||
All steps use the ``cowcb`` prefix to avoid collisions with existing steps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxCommitError,
|
||||
SandboxCreationError,
|
||||
SandboxRollbackError,
|
||||
SandboxStateError,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _init_cowcb_test_dir() -> str:
|
||||
"""Create a temporary directory with some test files."""
|
||||
test_dir = tempfile.mkdtemp(prefix="cowcb-test-dir-")
|
||||
with open(os.path.join(test_dir, "existing.txt"), "w") as f:
|
||||
f.write("original content")
|
||||
with open(os.path.join(test_dir, "to_delete.txt"), "w") as f:
|
||||
f.write("will be deleted")
|
||||
os.makedirs(os.path.join(test_dir, "data"), exist_ok=True)
|
||||
with open(os.path.join(test_dir, "data", "file.txt"), "w") as f:
|
||||
f.write("nested file")
|
||||
return test_dir
|
||||
|
||||
|
||||
def _cleanup_dir(path: str) -> None:
|
||||
"""Remove a directory tree if it exists, ignoring errors."""
|
||||
if path and os.path.exists(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a cowcb temporary file instead of a directory")
|
||||
def step_cowcb_temp_file(ctx: Context) -> None:
|
||||
"""Create a temporary regular file (not a directory) to use as original_path."""
|
||||
fd, path = tempfile.mkstemp(prefix="cowcb-file-")
|
||||
os.write(fd, b"not a directory")
|
||||
os.close(fd)
|
||||
ctx.cowcb_file_path = path
|
||||
ctx.cowcb_sandbox = None
|
||||
ctx.cowcb_error = None
|
||||
ctx.cowcb_commit_result = None
|
||||
# Register cleanup
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(
|
||||
lambda: os.unlink(path) if os.path.exists(path) else None
|
||||
)
|
||||
|
||||
|
||||
@given("a cowcb test directory is initialised")
|
||||
def step_cowcb_init_dir(ctx: Context) -> None:
|
||||
ctx.cowcb_test_dir = _init_cowcb_test_dir()
|
||||
ctx.cowcb_sandbox = None
|
||||
ctx.cowcb_error = None
|
||||
ctx.cowcb_commit_result = None
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(ctx.cowcb_test_dir))
|
||||
|
||||
|
||||
@given("a cowcb sandbox with status CREATED and sandbox_path None")
|
||||
def step_cowcb_created_none_path(ctx: Context) -> None:
|
||||
"""Create a sandbox and force internal state: status=CREATED, _sandbox_path=None."""
|
||||
test_dir = _init_cowcb_test_dir()
|
||||
ctx.cowcb_test_dir = test_dir
|
||||
sandbox = CopyOnWriteSandbox(resource_id="res-cowcb", original_path=test_dir)
|
||||
# Force internal state to simulate the edge case
|
||||
sandbox._status = SandboxStatus.CREATED
|
||||
sandbox._sandbox_path = None
|
||||
ctx.cowcb_sandbox = sandbox
|
||||
ctx.cowcb_error = None
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(test_dir))
|
||||
|
||||
|
||||
@given("a cowcb sandbox with status ACTIVE and sandbox_path None")
|
||||
def step_cowcb_active_none_path(ctx: Context) -> None:
|
||||
"""Create a sandbox and force internal state: status=ACTIVE, _sandbox_path=None."""
|
||||
test_dir = _init_cowcb_test_dir()
|
||||
ctx.cowcb_test_dir = test_dir
|
||||
sandbox = CopyOnWriteSandbox(resource_id="res-cowcb", original_path=test_dir)
|
||||
# Force internal state to simulate the edge case
|
||||
sandbox._status = SandboxStatus.ACTIVE
|
||||
sandbox._sandbox_path = None
|
||||
ctx.cowcb_sandbox = sandbox
|
||||
ctx.cowcb_error = None
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(test_dir))
|
||||
|
||||
|
||||
@given("a cowcb sandbox with status PENDING and sandbox_path None")
|
||||
def step_cowcb_pending_none_path(ctx: Context) -> None:
|
||||
"""Create a sandbox in PENDING state with _sandbox_path=None (the default)."""
|
||||
test_dir = _init_cowcb_test_dir()
|
||||
ctx.cowcb_test_dir = test_dir
|
||||
sandbox = CopyOnWriteSandbox(resource_id="res-cowcb", original_path=test_dir)
|
||||
# PENDING is the default; _sandbox_path is already None
|
||||
ctx.cowcb_sandbox = sandbox
|
||||
ctx.cowcb_error = None
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(test_dir))
|
||||
|
||||
|
||||
@given('a cowcb sandbox is created for plan "{plan_id}"')
|
||||
def step_cowcb_create_sandbox(ctx: Context, plan_id: str) -> None:
|
||||
"""Create a sandbox normally (CREATED state)."""
|
||||
ctx.cowcb_sandbox = CopyOnWriteSandbox(
|
||||
resource_id="res-cowcb",
|
||||
original_path=ctx.cowcb_test_dir,
|
||||
)
|
||||
ctx.cowcb_sandbox.create(plan_id)
|
||||
# Register cleanup for the sandbox temp directory
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
if sandbox_path:
|
||||
parent = os.path.dirname(sandbox_path)
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(parent))
|
||||
|
||||
|
||||
@given('a cowcb sandbox is created and activated for plan "{plan_id}"')
|
||||
def step_cowcb_create_and_activate(ctx: Context, plan_id: str) -> None:
|
||||
"""Create a sandbox and activate it by calling get_path."""
|
||||
ctx.cowcb_sandbox = CopyOnWriteSandbox(
|
||||
resource_id="res-cowcb",
|
||||
original_path=ctx.cowcb_test_dir,
|
||||
)
|
||||
ctx.cowcb_sandbox.create(plan_id)
|
||||
# Activate by resolving a path
|
||||
ctx.cowcb_sandbox.get_path("existing.txt")
|
||||
assert ctx.cowcb_sandbox.status == SandboxStatus.ACTIVE
|
||||
# Register cleanup for the sandbox temp directory
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
if sandbox_path:
|
||||
parent = os.path.dirname(sandbox_path)
|
||||
ctx._cleanup_handlers.append(lambda: _cleanup_dir(parent))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('a cowcb sandbox is created on that file for plan "{plan_id}"')
|
||||
def step_cowcb_create_on_file(ctx: Context, plan_id: str) -> None:
|
||||
"""Attempt to create a sandbox with a file as original_path."""
|
||||
ctx.cowcb_sandbox = CopyOnWriteSandbox(
|
||||
resource_id="res-cowcb",
|
||||
original_path=ctx.cowcb_file_path,
|
||||
)
|
||||
try:
|
||||
ctx.cowcb_sandbox.create(plan_id)
|
||||
except SandboxCreationError as exc:
|
||||
ctx.cowcb_error = exc
|
||||
|
||||
|
||||
@when('cowcb get_path is called with "{path}"')
|
||||
def step_cowcb_get_path(ctx: Context, path: str) -> None:
|
||||
try:
|
||||
ctx.cowcb_sandbox.get_path(path)
|
||||
except SandboxStateError as exc:
|
||||
ctx.cowcb_error = exc
|
||||
|
||||
|
||||
@when("cowcb commit is called")
|
||||
def step_cowcb_commit(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.cowcb_commit_result = ctx.cowcb_sandbox.commit()
|
||||
except (SandboxStateError, SandboxCommitError) as exc:
|
||||
ctx.cowcb_error = exc
|
||||
|
||||
|
||||
@when("cowcb rollback is called")
|
||||
def step_cowcb_rollback(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.cowcb_sandbox.rollback()
|
||||
except (SandboxStateError, SandboxRollbackError) as exc:
|
||||
ctx.cowcb_error = exc
|
||||
|
||||
|
||||
@when("cowcb cleanup is called")
|
||||
def step_cowcb_cleanup(ctx: Context) -> None:
|
||||
ctx.cowcb_sandbox.cleanup()
|
||||
|
||||
|
||||
@when("the cowcb original directory is removed before commit")
|
||||
def step_cowcb_remove_original_before_commit(ctx: Context) -> None:
|
||||
"""Modify a file in the sandbox and then patch shutil.copy2 to raise OSError
|
||||
so the commit sync loop fails when trying to copy the changed file back."""
|
||||
from unittest.mock import patch as _mock_patch
|
||||
|
||||
# First, modify a file in the sandbox so _compute_diff detects a change
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
if sandbox_path:
|
||||
modified = os.path.join(sandbox_path, "existing.txt")
|
||||
if os.path.exists(modified):
|
||||
with open(modified, "w") as f:
|
||||
f.write("modified content for OSError test")
|
||||
|
||||
patcher = _mock_patch(
|
||||
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2",
|
||||
side_effect=OSError("mocked copy2 failure"),
|
||||
)
|
||||
patcher.start()
|
||||
if not hasattr(ctx, "_cleanup_handlers"):
|
||||
ctx._cleanup_handlers = []
|
||||
ctx._cleanup_handlers.append(patcher.stop)
|
||||
|
||||
|
||||
@when("the cowcb original directory is removed before rollback")
|
||||
def step_cowcb_remove_original_before_rollback(ctx: Context) -> None:
|
||||
"""Remove the original directory so that shutil.copytree in rollback triggers OSError."""
|
||||
shutil.rmtree(ctx.cowcb_test_dir)
|
||||
|
||||
|
||||
@when("the cowcb sandbox parent directory is removed")
|
||||
def step_cowcb_remove_sandbox_parent(ctx: Context) -> None:
|
||||
"""Remove the sandbox's parent temp directory before cleanup."""
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
if sandbox_path:
|
||||
parent = os.path.dirname(sandbox_path)
|
||||
if os.path.exists(parent):
|
||||
shutil.rmtree(parent, ignore_errors=True)
|
||||
|
||||
|
||||
@when('a cowcb file "{rel_path}" is created in the sandbox')
|
||||
def step_cowcb_create_file_in_sandbox(ctx: Context, rel_path: str) -> None:
|
||||
"""Create a new file at a relative path inside the sandbox copy."""
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
assert sandbox_path is not None
|
||||
file_path = os.path.join(sandbox_path, rel_path)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w") as f:
|
||||
f.write("coverage boost content")
|
||||
|
||||
|
||||
@when('the cowcb file "{filename}" is deleted from both sandbox and original')
|
||||
def step_cowcb_delete_from_both(ctx: Context, filename: str) -> None:
|
||||
"""Delete a file from both sandbox and original so commit's delete loop
|
||||
encounters a file that doesn't exist in the original."""
|
||||
sandbox_path = ctx.cowcb_sandbox._sandbox_path
|
||||
assert sandbox_path is not None
|
||||
|
||||
# Delete from sandbox
|
||||
sandbox_file = os.path.join(sandbox_path, filename)
|
||||
if os.path.exists(sandbox_file):
|
||||
os.remove(sandbox_file)
|
||||
|
||||
# Delete from original
|
||||
original_file = os.path.join(ctx.cowcb_test_dir, filename)
|
||||
if os.path.exists(original_file):
|
||||
os.remove(original_file)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("a cowcb SandboxCreationError should be raised")
|
||||
def step_cowcb_check_creation_error(ctx: Context) -> None:
|
||||
assert ctx.cowcb_error is not None, (
|
||||
"Expected SandboxCreationError but none occurred"
|
||||
)
|
||||
assert isinstance(ctx.cowcb_error, SandboxCreationError), (
|
||||
f"Expected SandboxCreationError, got {type(ctx.cowcb_error).__name__}: {ctx.cowcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('a cowcb SandboxStateError should be raised with message "{msg}"')
|
||||
def step_cowcb_check_state_error_msg(ctx: Context, msg: str) -> None:
|
||||
assert ctx.cowcb_error is not None, "Expected SandboxStateError but none occurred"
|
||||
assert isinstance(ctx.cowcb_error, SandboxStateError), (
|
||||
f"Expected SandboxStateError, got {type(ctx.cowcb_error).__name__}: {ctx.cowcb_error}"
|
||||
)
|
||||
assert msg in str(ctx.cowcb_error), (
|
||||
f"Expected '{msg}' in error message, got: {ctx.cowcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("a cowcb SandboxCommitError should be raised")
|
||||
def step_cowcb_check_commit_error(ctx: Context) -> None:
|
||||
assert ctx.cowcb_error is not None, "Expected SandboxCommitError but none occurred"
|
||||
assert isinstance(ctx.cowcb_error, SandboxCommitError), (
|
||||
f"Expected SandboxCommitError, got {type(ctx.cowcb_error).__name__}: {ctx.cowcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("a cowcb SandboxRollbackError should be raised")
|
||||
def step_cowcb_check_rollback_error(ctx: Context) -> None:
|
||||
assert ctx.cowcb_error is not None, (
|
||||
"Expected SandboxRollbackError but none occurred"
|
||||
)
|
||||
assert isinstance(ctx.cowcb_error, SandboxRollbackError), (
|
||||
f"Expected SandboxRollbackError, got {type(ctx.cowcb_error).__name__}: {ctx.cowcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cowcb sandbox status should be "{status}"')
|
||||
def step_cowcb_check_status(ctx: Context, status: str) -> None:
|
||||
expected = SandboxStatus(status)
|
||||
assert ctx.cowcb_sandbox.status == expected, (
|
||||
f"Expected status {expected}, got {ctx.cowcb_sandbox.status}"
|
||||
)
|
||||
|
||||
|
||||
@then("the cowcb commit should succeed")
|
||||
def step_cowcb_commit_success(ctx: Context) -> None:
|
||||
assert ctx.cowcb_error is None, f"Expected no error but got: {ctx.cowcb_error}"
|
||||
assert ctx.cowcb_commit_result is not None, "Expected a commit result"
|
||||
assert ctx.cowcb_commit_result.success is True, "Expected commit to succeed"
|
||||
|
||||
|
||||
@then('the cowcb file "{rel_path}" should exist in the original directory')
|
||||
def step_cowcb_file_in_original(ctx: Context, rel_path: str) -> None:
|
||||
file_path = os.path.join(ctx.cowcb_test_dir, rel_path)
|
||||
assert os.path.exists(file_path), (
|
||||
f"File {rel_path} not found in original directory at {file_path}"
|
||||
)
|
||||
@@ -0,0 +1,952 @@
|
||||
"""Step definitions for database model coverage boost tests.
|
||||
|
||||
Targets uncovered branches in LifecycleActionModel, LifecyclePlanModel,
|
||||
and NamespacedProjectModel to_domain/from_domain methods.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from behave import given, then, when
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
ActionArgumentModel,
|
||||
Base,
|
||||
LifecycleActionModel,
|
||||
LifecyclePlanModel,
|
||||
NamespacedProjectModel,
|
||||
PlanArgumentModel,
|
||||
ProjectResourceLinkModel,
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid ULIDs for tests (generated via python-ulid)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ULID_PLAN_1 = str(ULID())
|
||||
ULID_PLAN_2 = str(ULID())
|
||||
ULID_RESOURCE_1 = str(ULID())
|
||||
ULID_RESOURCE_2 = str(ULID())
|
||||
ULID_LINK_1 = str(ULID())
|
||||
ULID_LINK_2 = str(ULID())
|
||||
|
||||
NOW_ISO = "2025-06-01T12:00:00"
|
||||
LATER_ISO = "2025-06-01T13:00:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("the coverage boost database is ready")
|
||||
def step_coverage_boost_db_ready(context):
|
||||
"""Set up an in-memory database with all lifecycle tables."""
|
||||
context.cb_engine = create_engine("sqlite:///:memory:")
|
||||
context.CbSessionLocal = sessionmaker(
|
||||
bind=context.cb_engine, autoflush=False, autocommit=False
|
||||
)
|
||||
Base.metadata.create_all(context.cb_engine)
|
||||
|
||||
|
||||
@given("a coverage boost database session is open")
|
||||
def step_coverage_boost_session_open(context):
|
||||
"""Open a database session for coverage boost tests."""
|
||||
context.cb_session = context.CbSessionLocal()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_action_exists(session, name="local/cov-action"):
|
||||
"""Ensure the FK-target action row exists."""
|
||||
existing = (
|
||||
session.query(LifecycleActionModel).filter_by(namespaced_name=name).first()
|
||||
)
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
LifecycleActionModel(
|
||||
namespaced_name=name,
|
||||
namespace="local",
|
||||
name="cov-action",
|
||||
description="Coverage action",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
state="available",
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _ensure_resource_type_exists(session, type_name="builtin/git-checkout"):
|
||||
"""Ensure the FK-target resource_type row exists."""
|
||||
existing = session.query(ResourceTypeModel).filter_by(name=type_name).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
ResourceTypeModel(
|
||||
name=type_name,
|
||||
namespace="builtin",
|
||||
resource_kind="physical",
|
||||
user_addable=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _ensure_resource_exists(session, resource_id, type_name="builtin/git-checkout"):
|
||||
"""Ensure the FK-target resource row exists."""
|
||||
_ensure_resource_type_exists(session, type_name)
|
||||
existing = session.query(ResourceModel).filter_by(resource_id=resource_id).first()
|
||||
if existing:
|
||||
return
|
||||
session.add(
|
||||
ResourceModel(
|
||||
resource_id=resource_id,
|
||||
type_name=type_name,
|
||||
resource_kind="physical",
|
||||
read_only=False,
|
||||
auto_discovered=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecycleActionModel.to_domain: default_value_json branch
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("an action model exists with arguments that have default values set")
|
||||
def step_action_model_with_default_values(context):
|
||||
"""Create an action model with arguments having default_value_json set."""
|
||||
model = LifecycleActionModel(
|
||||
namespaced_name="local/default-val-action",
|
||||
namespace="local",
|
||||
name="default-val-action",
|
||||
description="Action with arg defaults",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
state="available",
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
# Argument WITH a default value
|
||||
model.arguments_rel.append(
|
||||
ActionArgumentModel(
|
||||
name="threshold",
|
||||
arg_type="integer",
|
||||
requirement="optional",
|
||||
description="Coverage threshold",
|
||||
default_value_json=json.dumps(80),
|
||||
position=0,
|
||||
)
|
||||
)
|
||||
# Argument WITHOUT a default value
|
||||
model.arguments_rel.append(
|
||||
ActionArgumentModel(
|
||||
name="target",
|
||||
arg_type="string",
|
||||
requirement="required",
|
||||
description="Target module",
|
||||
default_value_json=None,
|
||||
position=1,
|
||||
)
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_action_model = model
|
||||
|
||||
|
||||
@when("the action model is converted to a domain object")
|
||||
def step_action_model_to_domain(context):
|
||||
"""Call to_domain on the action model."""
|
||||
context.cb_action_domain = context.cb_action_model.to_domain()
|
||||
|
||||
|
||||
@then("the domain action argument should have the correct default value")
|
||||
def step_verify_arg_default_value(context):
|
||||
"""Verify the argument with default_value_json has its value deserialized."""
|
||||
args = context.cb_action_domain.arguments
|
||||
threshold_arg = next(a for a in args if a.name == "threshold")
|
||||
assert threshold_arg.default_value == 80
|
||||
|
||||
|
||||
@then("the domain action argument without a default should have None")
|
||||
def step_verify_arg_no_default(context):
|
||||
"""Verify the argument without default_value_json has None."""
|
||||
args = context.cb_action_domain.arguments
|
||||
target_arg = next(a for a in args if a.name == "target")
|
||||
assert target_arg.default_value is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecycleActionModel.to_domain: inputs_schema_json branch
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("an action model exists with inputs_schema_json populated")
|
||||
def step_action_model_with_inputs_schema(context):
|
||||
"""Create an action model with a non-null inputs_schema_json."""
|
||||
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
model = LifecycleActionModel(
|
||||
namespaced_name="local/schema-action",
|
||||
namespace="local",
|
||||
name="schema-action",
|
||||
description="Action with inputs schema",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
state="available",
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
inputs_schema_json=json.dumps(schema),
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_action_model = model
|
||||
|
||||
|
||||
@then("the domain action should have the parsed inputs_schema dict")
|
||||
def step_verify_inputs_schema_parsed(context):
|
||||
"""Verify inputs_schema is a dict parsed from JSON."""
|
||||
schema = context.cb_action_domain.inputs_schema
|
||||
assert isinstance(schema, dict)
|
||||
assert schema["type"] == "object"
|
||||
assert "properties" in schema
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecycleActionModel.from_domain: inputs_schema not None
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("an action domain object with inputs_schema set")
|
||||
def step_action_domain_with_inputs_schema(context):
|
||||
"""Prepare an Action domain object that has inputs_schema."""
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.plan import NamespacedName
|
||||
|
||||
context.cb_action_domain_input = Action(
|
||||
namespaced_name=NamespacedName(namespace="local", name="schema-from"),
|
||||
description="Action with schema",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
inputs_schema={"type": "object", "required": ["name"]},
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
@when("the action domain object is converted to a database model")
|
||||
def step_action_domain_to_model(context):
|
||||
"""Call from_domain on the action domain object."""
|
||||
context.cb_action_model_result = LifecycleActionModel.from_domain(
|
||||
context.cb_action_domain_input
|
||||
)
|
||||
|
||||
|
||||
@then("the database model should have inputs_schema_json as a JSON string")
|
||||
def step_verify_model_inputs_schema_json(context):
|
||||
"""Verify inputs_schema_json is a non-null JSON string."""
|
||||
raw = context.cb_action_model_result.inputs_schema_json
|
||||
assert raw is not None
|
||||
parsed = json.loads(raw)
|
||||
assert parsed["type"] == "object"
|
||||
assert "required" in parsed
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecycleActionModel.from_domain: default_value not None
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("an action domain object with arguments that have default values")
|
||||
def step_action_domain_with_arg_defaults(context):
|
||||
"""Prepare an Action with arguments, some having default_value."""
|
||||
from cleveragents.domain.models.core.action import (
|
||||
Action,
|
||||
ActionArgument,
|
||||
ActionState,
|
||||
ArgumentRequirement,
|
||||
ArgumentType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import NamespacedName
|
||||
|
||||
context.cb_action_domain_input = Action(
|
||||
namespaced_name=NamespacedName(namespace="local", name="defaults-from"),
|
||||
description="Action with arg defaults",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
arguments=[
|
||||
ActionArgument(
|
||||
name="threshold",
|
||||
arg_type=ArgumentType.INTEGER,
|
||||
requirement=ArgumentRequirement.OPTIONAL,
|
||||
description="Coverage threshold",
|
||||
default_value=80,
|
||||
),
|
||||
ActionArgument(
|
||||
name="target",
|
||||
arg_type=ArgumentType.STRING,
|
||||
requirement=ArgumentRequirement.REQUIRED,
|
||||
description="Target module",
|
||||
default_value=None,
|
||||
),
|
||||
],
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
@then("the database model arguments should have default_value_json set")
|
||||
def step_verify_model_arg_default_json(context):
|
||||
"""Verify argument with default_value has default_value_json serialized."""
|
||||
args = context.cb_action_model_result.arguments_rel
|
||||
threshold_arg = next(a for a in args if a.name == "threshold")
|
||||
assert threshold_arg.default_value_json is not None
|
||||
assert json.loads(threshold_arg.default_value_json) == 80
|
||||
|
||||
|
||||
@then("the argument without a default should have null default_value_json")
|
||||
def step_verify_model_arg_no_default_json(context):
|
||||
"""Verify argument without default_value has null default_value_json."""
|
||||
args = context.cb_action_model_result.arguments_rel
|
||||
target_arg = next(a for a in args if a.name == "target")
|
||||
assert target_arg.default_value_json is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecycleActionModel.from_domain: invariants loop body
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("an action domain object with invariants defined")
|
||||
def step_action_domain_with_invariants(context):
|
||||
"""Prepare an Action with non-empty invariants list."""
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.plan import NamespacedName
|
||||
|
||||
context.cb_action_domain_input = Action(
|
||||
namespaced_name=NamespacedName(namespace="local", name="inv-action"),
|
||||
description="Action with invariants",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
invariants=["No breaking changes", "Maintain backward compatibility"],
|
||||
state=ActionState.AVAILABLE,
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
@then("the database model should have invariant child records")
|
||||
def step_verify_model_has_invariants(context):
|
||||
"""Verify the model has invariant child records."""
|
||||
invs = context.cb_action_model_result.invariants_rel
|
||||
assert len(invs) == 2
|
||||
|
||||
|
||||
@then("each invariant child record should have correct text and position")
|
||||
def step_verify_invariant_child_records(context):
|
||||
"""Verify invariant child records have correct text and position."""
|
||||
invs = context.cb_action_model_result.invariants_rel
|
||||
assert invs[0].invariant_text == "No breaking changes"
|
||||
assert invs[0].position == 0
|
||||
assert invs[1].invariant_text == "Maintain backward compatibility"
|
||||
assert invs[1].position == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.to_domain: automation_profile not None
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan model exists with automation_profile JSON set")
|
||||
def step_plan_model_with_automation_profile(context):
|
||||
"""Create a plan model with automation_profile as JSON string."""
|
||||
_ensure_action_exists(context.cb_session)
|
||||
profile_json = json.dumps(
|
||||
{
|
||||
"profile_name": "trusted",
|
||||
"provenance": "action",
|
||||
}
|
||||
)
|
||||
model = LifecyclePlanModel(
|
||||
plan_id=ULID_PLAN_1,
|
||||
action_name="local/cov-action",
|
||||
namespaced_name="local/profile-plan",
|
||||
namespace="local",
|
||||
phase="strategize",
|
||||
processing_state="queued",
|
||||
attempt=1,
|
||||
automation_level="manual",
|
||||
description="Plan with automation profile",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
automation_profile=profile_json,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_plan_model = model
|
||||
|
||||
|
||||
@when("the plan model is converted to a domain object")
|
||||
def step_plan_model_to_domain(context):
|
||||
"""Call to_domain on the plan model."""
|
||||
context.cb_plan_domain = context.cb_plan_model.to_domain()
|
||||
|
||||
|
||||
@then("the domain plan should have an automation profile with the correct name")
|
||||
def step_verify_plan_automation_profile_name(context):
|
||||
"""Verify automation_profile is parsed with correct profile_name."""
|
||||
ap = context.cb_plan_domain.automation_profile
|
||||
assert ap is not None
|
||||
assert ap.profile_name == "trusted"
|
||||
|
||||
|
||||
@then("the domain plan automation profile should have the correct provenance")
|
||||
def step_verify_plan_automation_profile_provenance(context):
|
||||
"""Verify automation_profile has correct provenance."""
|
||||
from cleveragents.domain.models.core.plan import AutomationProfileProvenance
|
||||
|
||||
ap = context.cb_plan_domain.automation_profile
|
||||
assert ap.provenance == AutomationProfileProvenance.ACTION
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.to_domain: sandbox_refs_json
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan model exists with sandbox_refs_json populated")
|
||||
def step_plan_model_with_sandbox_refs(context):
|
||||
"""Create a plan model with sandbox_refs_json as a JSON list."""
|
||||
_ensure_action_exists(context.cb_session)
|
||||
model = LifecyclePlanModel(
|
||||
plan_id=ULID_PLAN_2,
|
||||
action_name="local/cov-action",
|
||||
namespaced_name="local/sandbox-plan",
|
||||
namespace="local",
|
||||
phase="execute",
|
||||
processing_state="processing",
|
||||
attempt=1,
|
||||
automation_level="manual",
|
||||
description="Plan with sandbox refs",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
sandbox_refs_json=json.dumps(["sandbox-ref-1", "sandbox-ref-2"]),
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_plan_model = model
|
||||
|
||||
|
||||
@then("the domain plan should have the parsed sandbox refs list")
|
||||
def step_verify_plan_sandbox_refs(context):
|
||||
"""Verify sandbox_refs is a list parsed from JSON."""
|
||||
refs = context.cb_plan_domain.sandbox_refs
|
||||
assert refs == ["sandbox-ref-1", "sandbox-ref-2"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.to_domain: validation_summary_json
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan model exists with validation_summary_json populated")
|
||||
def step_plan_model_with_validation_summary(context):
|
||||
"""Create a plan model with validation_summary_json set."""
|
||||
_ensure_action_exists(context.cb_session)
|
||||
summary = {"tests_passed": 42, "tests_failed": 0, "coverage": 95.5}
|
||||
# Use a fresh ULID to avoid PK conflict
|
||||
plan_id = str(ULID())
|
||||
model = LifecyclePlanModel(
|
||||
plan_id=plan_id,
|
||||
action_name="local/cov-action",
|
||||
namespaced_name="local/validation-plan",
|
||||
namespace="local",
|
||||
phase="apply",
|
||||
processing_state="queued",
|
||||
attempt=1,
|
||||
automation_level="manual",
|
||||
description="Plan with validation summary",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
validation_summary_json=json.dumps(summary),
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_plan_model = model
|
||||
|
||||
|
||||
@then("the domain plan should have the parsed validation summary dict")
|
||||
def step_verify_plan_validation_summary(context):
|
||||
"""Verify validation_summary is a dict parsed from JSON."""
|
||||
vs = context.cb_plan_domain.validation_summary
|
||||
assert isinstance(vs, dict)
|
||||
assert vs["tests_passed"] == 42
|
||||
assert vs["coverage"] == 95.5
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.to_domain: error_details_json
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan model exists with error_details_json populated")
|
||||
def step_plan_model_with_error_details(context):
|
||||
"""Create a plan model with error_details_json set."""
|
||||
_ensure_action_exists(context.cb_session)
|
||||
error_details = {"error_type": "ValidationError", "trace": "line 42"}
|
||||
plan_id = str(ULID())
|
||||
model = LifecyclePlanModel(
|
||||
plan_id=plan_id,
|
||||
action_name="local/cov-action",
|
||||
namespaced_name="local/error-plan",
|
||||
namespace="local",
|
||||
phase="apply",
|
||||
processing_state="errored",
|
||||
attempt=1,
|
||||
automation_level="manual",
|
||||
description="Plan with error details",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
error_message="Something went wrong",
|
||||
error_details_json=json.dumps(error_details),
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_plan_model = model
|
||||
|
||||
|
||||
@then("the domain plan should have the parsed error details dict")
|
||||
def step_verify_plan_error_details(context):
|
||||
"""Verify error_details is a dict parsed from JSON."""
|
||||
ed = context.cb_plan_domain.error_details
|
||||
assert isinstance(ed, dict)
|
||||
assert ed["error_type"] == "ValidationError"
|
||||
assert ed["trace"] == "line 42"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.to_domain: arguments from child table
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan model exists with argument child records")
|
||||
def step_plan_model_with_arguments(context):
|
||||
"""Create a plan model with PlanArgumentModel child records."""
|
||||
_ensure_action_exists(context.cb_session)
|
||||
plan_id = str(ULID())
|
||||
model = LifecyclePlanModel(
|
||||
plan_id=plan_id,
|
||||
action_name="local/cov-action",
|
||||
namespaced_name="local/args-plan",
|
||||
namespace="local",
|
||||
phase="strategize",
|
||||
processing_state="queued",
|
||||
attempt=1,
|
||||
automation_level="manual",
|
||||
description="Plan with arguments",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
model.arguments_rel.append(
|
||||
PlanArgumentModel(
|
||||
name="coverage",
|
||||
value_json=json.dumps(80),
|
||||
value_type="integer",
|
||||
position=0,
|
||||
)
|
||||
)
|
||||
model.arguments_rel.append(
|
||||
PlanArgumentModel(
|
||||
name="framework",
|
||||
value_json=json.dumps("pytest"),
|
||||
value_type="string",
|
||||
position=1,
|
||||
)
|
||||
)
|
||||
model.arguments_rel.append(
|
||||
PlanArgumentModel(
|
||||
name="verbose",
|
||||
value_json=None,
|
||||
value_type="boolean",
|
||||
position=2,
|
||||
)
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_plan_model = model
|
||||
|
||||
|
||||
@then("the domain plan should have the arguments dict populated")
|
||||
def step_verify_plan_arguments_dict(context):
|
||||
"""Verify the arguments dict has entries from child records."""
|
||||
args = context.cb_plan_domain.arguments
|
||||
assert args["coverage"] == 80
|
||||
assert args["framework"] == "pytest"
|
||||
assert args["verbose"] is None
|
||||
|
||||
|
||||
@then("the domain plan should have arguments_order populated")
|
||||
def step_verify_plan_arguments_order(context):
|
||||
"""Verify arguments_order contains the argument names in order."""
|
||||
order = context.cb_plan_domain.arguments_order
|
||||
assert order == ["coverage", "framework", "verbose"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.from_domain: project_links loop
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan domain object with project links defined")
|
||||
def step_plan_domain_with_project_links(context):
|
||||
"""Prepare a Plan domain object with project_links populated."""
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationLevel,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
context.cb_plan_domain_input = Plan(
|
||||
identity=PlanIdentity(plan_id=str(ULID())),
|
||||
namespaced_name=NamespacedName(namespace="local", name="pl-plan"),
|
||||
action_name="local/cov-action",
|
||||
description="Plan with project links",
|
||||
definition_of_done="Tests pass",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
automation_level=AutomationLevel.MANUAL,
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
project_links=[
|
||||
ProjectLink(project_name="local/api-service", alias="api", read_only=False),
|
||||
ProjectLink(project_name="local/web-app", alias="web", read_only=True),
|
||||
],
|
||||
timestamps=PlanTimestamps(
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
),
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
@when("the plan domain object is converted to a database model via from_domain")
|
||||
def step_plan_domain_to_model(context):
|
||||
"""Call from_domain on the plan domain object."""
|
||||
context.cb_plan_model_result = LifecyclePlanModel.from_domain(
|
||||
context.cb_plan_domain_input
|
||||
)
|
||||
|
||||
|
||||
@then("the database plan model should have project link child records")
|
||||
def step_verify_plan_model_project_links(context):
|
||||
"""Verify the model has project link child records."""
|
||||
links = context.cb_plan_model_result.project_links_rel
|
||||
assert len(links) == 2
|
||||
|
||||
|
||||
@then("each project link child record should have the correct project name")
|
||||
def step_verify_project_link_names(context):
|
||||
"""Verify each project link child record has the correct project name."""
|
||||
links = context.cb_plan_model_result.project_links_rel
|
||||
names = [pl.project_name for pl in links]
|
||||
assert "local/api-service" in names
|
||||
assert "local/web-app" in names
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.from_domain: invariants loop
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan domain object with plan invariants defined")
|
||||
def step_plan_domain_with_invariants(context):
|
||||
"""Prepare a Plan domain object with invariants list."""
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationLevel,
|
||||
InvariantSource,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
|
||||
context.cb_plan_domain_input = Plan(
|
||||
identity=PlanIdentity(plan_id=str(ULID())),
|
||||
namespaced_name=NamespacedName(namespace="local", name="inv-plan"),
|
||||
action_name="local/cov-action",
|
||||
description="Plan with invariants",
|
||||
definition_of_done="Tests pass",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
automation_level=AutomationLevel.MANUAL,
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
invariants=[
|
||||
PlanInvariant(text="No breaking changes", source=InvariantSource.ACTION),
|
||||
PlanInvariant(text="Keep API stable", source=InvariantSource.PROJECT),
|
||||
],
|
||||
timestamps=PlanTimestamps(
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
),
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
@then("the database plan model should have invariant child records with source")
|
||||
def step_verify_plan_model_invariants(context):
|
||||
"""Verify the model has invariant child records."""
|
||||
invs = context.cb_plan_model_result.invariants_rel
|
||||
assert len(invs) == 2
|
||||
|
||||
|
||||
@then("each plan invariant child record should have correct text and source")
|
||||
def step_verify_plan_invariant_details(context):
|
||||
"""Verify invariant child records have correct text and source_scope."""
|
||||
invs = context.cb_plan_model_result.invariants_rel
|
||||
assert invs[0].invariant_text == "No breaking changes"
|
||||
assert invs[0].source_scope == "action"
|
||||
assert invs[0].position == 0
|
||||
assert invs[1].invariant_text == "Keep API stable"
|
||||
assert invs[1].source_scope == "project"
|
||||
assert invs[1].position == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# LifecyclePlanModel.from_domain: arguments loop
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a plan domain object with arguments and arguments_order defined")
|
||||
def step_plan_domain_with_arguments(context):
|
||||
"""Prepare a Plan domain object with arguments dict and arguments_order."""
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationLevel,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
|
||||
context.cb_plan_domain_input = Plan(
|
||||
identity=PlanIdentity(plan_id=str(ULID())),
|
||||
namespaced_name=NamespacedName(namespace="local", name="args-plan"),
|
||||
action_name="local/cov-action",
|
||||
description="Plan with arguments",
|
||||
definition_of_done="Tests pass",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
automation_level=AutomationLevel.MANUAL,
|
||||
strategy_actor="local/strategy",
|
||||
execution_actor="local/executor",
|
||||
arguments={"coverage": 80, "framework": "pytest"},
|
||||
arguments_order=["coverage", "framework"],
|
||||
timestamps=PlanTimestamps(
|
||||
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
||||
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
||||
),
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
@then("the database plan model should have argument child records")
|
||||
def step_verify_plan_model_arguments(context):
|
||||
"""Verify the model has argument child records."""
|
||||
args = context.cb_plan_model_result.arguments_rel
|
||||
assert len(args) == 2
|
||||
|
||||
|
||||
@then("each plan argument child record should have correct name and value")
|
||||
def step_verify_plan_argument_details(context):
|
||||
"""Verify argument child records have correct name and value_json."""
|
||||
args = context.cb_plan_model_result.arguments_rel
|
||||
coverage_arg = next(a for a in args if a.name == "coverage")
|
||||
framework_arg = next(a for a in args if a.name == "framework")
|
||||
assert json.loads(coverage_arg.value_json) == 80
|
||||
assert json.loads(framework_arg.value_json) == "pytest"
|
||||
assert coverage_arg.position == 0
|
||||
assert framework_arg.position == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# NamespacedProjectModel.to_domain: resource_links loop
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a project model exists with resource link child records")
|
||||
def step_project_model_with_resource_links(context):
|
||||
"""Create a project model with ProjectResourceLinkModel children."""
|
||||
_ensure_resource_exists(context.cb_session, ULID_RESOURCE_1)
|
||||
_ensure_resource_exists(context.cb_session, ULID_RESOURCE_2)
|
||||
|
||||
model = NamespacedProjectModel(
|
||||
namespaced_name="local/linked-project",
|
||||
namespace="local",
|
||||
description="Project with resource links",
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.flush()
|
||||
|
||||
link1 = ProjectResourceLinkModel(
|
||||
link_id=ULID_LINK_1,
|
||||
project_name="local/linked-project",
|
||||
resource_id=ULID_RESOURCE_1,
|
||||
alias="main-repo",
|
||||
read_only=False,
|
||||
created_at=NOW_ISO,
|
||||
)
|
||||
link2 = ProjectResourceLinkModel(
|
||||
link_id=ULID_LINK_2,
|
||||
project_name="local/linked-project",
|
||||
resource_id=ULID_RESOURCE_2,
|
||||
alias="docs-repo",
|
||||
read_only=True,
|
||||
created_at=NOW_ISO,
|
||||
)
|
||||
context.cb_session.add_all([link1, link2])
|
||||
context.cb_session.commit()
|
||||
|
||||
# Re-fetch to ensure relationships are loaded
|
||||
context.cb_project_model = (
|
||||
context.cb_session.query(NamespacedProjectModel)
|
||||
.filter_by(namespaced_name="local/linked-project")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@when("the project model is converted to a domain object")
|
||||
def step_project_model_to_domain(context):
|
||||
"""Call to_domain on the project model."""
|
||||
context.cb_project_domain = context.cb_project_model.to_domain()
|
||||
|
||||
|
||||
@then("the domain project should have linked resources populated")
|
||||
def step_verify_project_linked_resources(context):
|
||||
"""Verify the project has linked_resources populated."""
|
||||
lr = context.cb_project_domain.linked_resources
|
||||
assert len(lr) == 2
|
||||
|
||||
|
||||
@then("each linked resource should have the correct resource id and alias")
|
||||
def step_verify_linked_resource_details(context):
|
||||
"""Verify each linked resource has correct resource_id and alias."""
|
||||
lr = context.cb_project_domain.linked_resources
|
||||
ids = {r.resource_id for r in lr}
|
||||
assert ULID_RESOURCE_1 in ids
|
||||
assert ULID_RESOURCE_2 in ids
|
||||
aliases = {r.alias for r in lr}
|
||||
assert "main-repo" in aliases
|
||||
assert "docs-repo" in aliases
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# NamespacedProjectModel.to_domain: context_policy_json parsing
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@given("a project model exists with context_policy_json populated")
|
||||
def step_project_model_with_context_policy(context):
|
||||
"""Create a project model with non-null context_policy_json."""
|
||||
policy = {
|
||||
"max_file_size": 500000,
|
||||
"indexing_strategy": "semantic",
|
||||
"summarize": False,
|
||||
}
|
||||
model = NamespacedProjectModel(
|
||||
namespaced_name="local/ctx-project",
|
||||
namespace="local",
|
||||
description="Project with context policy",
|
||||
context_policy_json=json.dumps(policy),
|
||||
created_at=NOW_ISO,
|
||||
updated_at=LATER_ISO,
|
||||
tags_json="[]",
|
||||
)
|
||||
context.cb_session.add(model)
|
||||
context.cb_session.commit()
|
||||
context.cb_project_model = model
|
||||
|
||||
|
||||
@then("the domain project should have a context config with custom values")
|
||||
def step_verify_project_context_config(context):
|
||||
"""Verify context_config is parsed from JSON with custom values."""
|
||||
cc = context.cb_project_domain.context_config
|
||||
assert cc.max_file_size == 500000
|
||||
assert cc.indexing_strategy == "semantic"
|
||||
assert cc.summarize is False
|
||||
@@ -0,0 +1,577 @@
|
||||
"""Step definitions for git worktree sandbox coverage boost.
|
||||
|
||||
All steps use the ``gwtcb`` prefix to avoid collisions with existing steps.
|
||||
These scenarios target uncovered lines and branches in git_worktree.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import (
|
||||
GitWorktreeSandbox,
|
||||
_sanitise_branch_name,
|
||||
)
|
||||
from cleveragents.infrastructure.sandbox.protocol import (
|
||||
SandboxCommitError,
|
||||
SandboxCreationError,
|
||||
SandboxRollbackError,
|
||||
SandboxStateError,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
_MODULE = "cleveragents.infrastructure.sandbox.git_worktree"
|
||||
|
||||
|
||||
def _make_sandbox(original_path: str) -> GitWorktreeSandbox:
|
||||
"""Helper: create a GitWorktreeSandbox with sensible defaults."""
|
||||
return GitWorktreeSandbox(
|
||||
resource_id="res-cov-001",
|
||||
original_path=original_path,
|
||||
git_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _init_test_repo() -> str:
|
||||
"""Create a temporary git repo with an initial commit."""
|
||||
repo_dir = tempfile.mkdtemp(prefix="gwtcb-test-repo-")
|
||||
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@test.com"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "commit.gpgSign", "false"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
readme = os.path.join(repo_dir, "README.md")
|
||||
with open(readme, "w") as f:
|
||||
f.write("# Test Repo\n")
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return repo_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitise_branch_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('gwtcb _sanitise_branch_name is called with "{raw}"')
|
||||
def step_gwtcb_sanitise(ctx: Context, raw: str) -> None:
|
||||
ctx.gwtcb_result = _sanitise_branch_name(raw)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
|
||||
@then('gwtcb the result should be "{expected}"')
|
||||
def step_gwtcb_sanitise_result(ctx: Context, expected: str) -> None:
|
||||
assert ctx.gwtcb_result == expected, (
|
||||
f"Expected '{expected}', got '{ctx.gwtcb_result}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create: path is not repo root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox pointed at a non-root subdirectory")
|
||||
def step_gwtcb_non_root_subdir(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
# Create a subdirectory inside the repo
|
||||
subdir = os.path.join(repo_dir, "subdir")
|
||||
os.makedirs(subdir, exist_ok=True)
|
||||
ctx.gwtcb_sandbox = _make_sandbox(subdir)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
|
||||
@when('gwtcb create is called for plan "{plan_id}"')
|
||||
def step_gwtcb_create(ctx: Context, plan_id: str) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.create(plan_id)
|
||||
except (SandboxCreationError, SandboxStateError, ValueError) as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create: timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox with mocked _run_git that times out")
|
||||
def step_gwtcb_create_timeout_setup(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
# Patch _run_git to raise TimeoutExpired on first call
|
||||
patcher = patch(
|
||||
f"{_MODULE}._run_git",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
|
||||
)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb create is called expecting a timeout error")
|
||||
def step_gwtcb_create_timeout(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.create("plan-timeout")
|
||||
except SandboxCreationError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_path: worktree_path is None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox in CREATED state with None worktree_path")
|
||||
def step_gwtcb_created_none_path(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
# Force internal state: CREATED with worktree_path = None
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.CREATED
|
||||
ctx.gwtcb_sandbox._worktree_path = None
|
||||
|
||||
|
||||
@when('gwtcb get_path is called with "{path}"')
|
||||
def step_gwtcb_get_path(ctx: Context, path: str) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.get_path(path)
|
||||
except SandboxStateError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit: worktree not initialised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox in ACTIVE state with no worktree or branch")
|
||||
def step_gwtcb_active_no_worktree(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
# Force ACTIVE with None worktree and branch to hit line 344-345
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = None
|
||||
ctx.gwtcb_sandbox._branch_name = None
|
||||
|
||||
|
||||
@when('gwtcb commit is called with message "{message}"')
|
||||
def step_gwtcb_commit(ctx: Context, message: str) -> None:
|
||||
try:
|
||||
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit(message)
|
||||
except (SandboxStateError, SandboxCommitError) as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit: modified files in diff output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox ready to commit with modified-file diff output")
|
||||
def step_gwtcb_commit_modified_diff(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
ctx.gwtcb_commit_result = None
|
||||
|
||||
# Set sandbox to ACTIVE with valid worktree/branch
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = repo_dir
|
||||
ctx.gwtcb_sandbox._branch_name = "sandbox/test"
|
||||
ctx.gwtcb_sandbox._base_commit = "abc123"
|
||||
ctx.gwtcb_sandbox._original_branch = "main"
|
||||
|
||||
# Prepare the diff output including M (modified), A (added), D (deleted)
|
||||
diff_output = "M\tsrc/modified.py\nA\tsrc/new.py\nD\tsrc/old.py\n"
|
||||
|
||||
# Mock _run_git for add, commit, rev-parse, merge
|
||||
def mock_run_git(args, cwd, timeout=10):
|
||||
result = MagicMock()
|
||||
result.stdout = "abc456\n"
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
ctx.gwtcb_run_git_patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
|
||||
ctx.gwtcb_run_git_patcher.start()
|
||||
|
||||
# Mock subprocess.run for diff --cached --name-status
|
||||
real_subprocess_run = subprocess.run
|
||||
|
||||
def mock_subprocess_run(cmd, **kwargs):
|
||||
if isinstance(cmd, list) and "diff" in cmd and "--name-status" in cmd:
|
||||
result = MagicMock()
|
||||
result.stdout = diff_output
|
||||
result.stderr = ""
|
||||
result.returncode = 0
|
||||
return result
|
||||
return real_subprocess_run(cmd, **kwargs)
|
||||
|
||||
ctx.gwtcb_subprocess_patcher = patch(
|
||||
"subprocess.run", side_effect=mock_subprocess_run
|
||||
)
|
||||
ctx.gwtcb_subprocess_patcher.start()
|
||||
|
||||
|
||||
@then('the gwtcb commit result should include changed file "{filename}"')
|
||||
def step_gwtcb_commit_changed_file(ctx: Context, filename: str) -> None:
|
||||
if hasattr(ctx, "gwtcb_run_git_patcher"):
|
||||
ctx.gwtcb_run_git_patcher.stop()
|
||||
if hasattr(ctx, "gwtcb_subprocess_patcher"):
|
||||
ctx.gwtcb_subprocess_patcher.stop()
|
||||
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
|
||||
assert filename in ctx.gwtcb_commit_result.changed_files, (
|
||||
f"Expected '{filename}' in changed_files, got {ctx.gwtcb_commit_result.changed_files}"
|
||||
)
|
||||
|
||||
|
||||
@then('the gwtcb commit result should include added file "{filename}"')
|
||||
def step_gwtcb_commit_added_file(ctx: Context, filename: str) -> None:
|
||||
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
|
||||
assert filename in ctx.gwtcb_commit_result.added_files, (
|
||||
f"Expected '{filename}' in added_files, got {ctx.gwtcb_commit_result.added_files}"
|
||||
)
|
||||
|
||||
|
||||
@then('the gwtcb commit result should include deleted file "{filename}"')
|
||||
def step_gwtcb_commit_deleted_file(ctx: Context, filename: str) -> None:
|
||||
assert ctx.gwtcb_commit_result is not None, "Expected a commit result"
|
||||
assert filename in ctx.gwtcb_commit_result.deleted_files, (
|
||||
f"Expected '{filename}' in deleted_files, got {ctx.gwtcb_commit_result.deleted_files}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit: timeout and CalledProcessError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox in ACTIVE state ready to commit")
|
||||
def step_gwtcb_active_ready_commit(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
ctx.gwtcb_commit_result = None
|
||||
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = repo_dir
|
||||
ctx.gwtcb_sandbox._branch_name = "sandbox/test"
|
||||
ctx.gwtcb_sandbox._base_commit = "abc123"
|
||||
ctx.gwtcb_sandbox._original_branch = "main"
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to raise TimeoutExpired on commit")
|
||||
def step_gwtcb_mock_commit_timeout(ctx: Context) -> None:
|
||||
patcher = patch(
|
||||
f"{_MODULE}._run_git",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
|
||||
)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb commit is called expecting a timeout error")
|
||||
def step_gwtcb_commit_timeout(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit("timeout commit")
|
||||
except SandboxCommitError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to raise CalledProcessError on commit")
|
||||
def step_gwtcb_mock_commit_process_error(ctx: Context) -> None:
|
||||
patcher = patch(
|
||||
f"{_MODULE}._run_git",
|
||||
side_effect=subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="git commit",
|
||||
stderr="commit failed",
|
||||
),
|
||||
)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb commit is called expecting a process error")
|
||||
def step_gwtcb_commit_process_error(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_commit_result = ctx.gwtcb_sandbox.commit("error commit")
|
||||
except SandboxCommitError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rollback: worktree not initialised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox in ACTIVE state with no worktree or base commit")
|
||||
def step_gwtcb_active_no_worktree_rollback(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
# Force ACTIVE with None worktree and base_commit to hit line 472-473
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = None
|
||||
ctx.gwtcb_sandbox._base_commit = None
|
||||
|
||||
|
||||
@when("gwtcb rollback is called expecting a state error")
|
||||
def step_gwtcb_rollback_state_error(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.rollback()
|
||||
except SandboxStateError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rollback: timeout and CalledProcessError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox in ACTIVE state ready to rollback")
|
||||
def step_gwtcb_active_ready_rollback(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = repo_dir
|
||||
ctx.gwtcb_sandbox._base_commit = "abc123"
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to raise TimeoutExpired on rollback")
|
||||
def step_gwtcb_mock_rollback_timeout(ctx: Context) -> None:
|
||||
patcher = patch(
|
||||
f"{_MODULE}._run_git",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
|
||||
)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb rollback is called expecting a timeout error")
|
||||
def step_gwtcb_rollback_timeout(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.rollback()
|
||||
except SandboxRollbackError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to raise CalledProcessError on rollback")
|
||||
def step_gwtcb_mock_rollback_process_error(ctx: Context) -> None:
|
||||
patcher = patch(
|
||||
f"{_MODULE}._run_git",
|
||||
side_effect=subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="git reset",
|
||||
stderr="rollback failed",
|
||||
),
|
||||
)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb rollback is called expecting a process error")
|
||||
def step_gwtcb_rollback_process_error(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.rollback()
|
||||
except SandboxRollbackError as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cleanup: worktree remove failure (fallback to shutil)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox with a worktree directory that exists")
|
||||
def step_gwtcb_cleanup_worktree_exists(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
# Create a real temp dir to act as the worktree path
|
||||
worktree_dir = tempfile.mkdtemp(prefix="gwtcb-worktree-")
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = worktree_dir
|
||||
ctx.gwtcb_sandbox._branch_name = "sandbox/cleanup-test"
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to fail on worktree remove")
|
||||
def step_gwtcb_mock_worktree_remove_fail(ctx: Context) -> None:
|
||||
call_count = {"n": 0}
|
||||
|
||||
def mock_run_git(args, cwd, timeout=10):
|
||||
call_count["n"] += 1
|
||||
if args and args[0] == "worktree" and "remove" in args:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="git worktree remove",
|
||||
stderr="worktree remove failed",
|
||||
)
|
||||
# Let branch -D and worktree prune succeed
|
||||
result = MagicMock()
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
@when("gwtcb cleanup is called")
|
||||
def step_gwtcb_cleanup(ctx: Context) -> None:
|
||||
try:
|
||||
ctx.gwtcb_sandbox.cleanup()
|
||||
except Exception as exc:
|
||||
ctx.gwtcb_error = exc
|
||||
finally:
|
||||
if hasattr(ctx, "gwtcb_patcher"):
|
||||
ctx.gwtcb_patcher.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cleanup: branch delete failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a gwtcb sandbox with a branch name set but no worktree directory")
|
||||
def step_gwtcb_cleanup_no_worktree_dir(ctx: Context) -> None:
|
||||
repo_dir = _init_test_repo()
|
||||
ctx.gwtcb_sandbox = _make_sandbox(repo_dir)
|
||||
ctx.gwtcb_error = None
|
||||
|
||||
# Set branch name but worktree_path to a non-existent path
|
||||
# so the worktree removal block is skipped
|
||||
ctx.gwtcb_sandbox._status = SandboxStatus.ACTIVE
|
||||
ctx.gwtcb_sandbox._worktree_path = "/tmp/nonexistent-gwtcb-worktree"
|
||||
ctx.gwtcb_sandbox._branch_name = "sandbox/branch-delete-fail"
|
||||
|
||||
|
||||
@given("gwtcb _run_git is mocked to fail on branch delete")
|
||||
def step_gwtcb_mock_branch_delete_fail(ctx: Context) -> None:
|
||||
|
||||
def mock_run_git(args, cwd, timeout=10):
|
||||
if args and args[0] == "branch" and "-D" in args:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="git branch -D",
|
||||
stderr="branch delete failed",
|
||||
)
|
||||
# Let worktree prune succeed
|
||||
result = MagicMock()
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
patcher = patch(f"{_MODULE}._run_git", side_effect=mock_run_git)
|
||||
ctx.gwtcb_patcher = patcher
|
||||
patcher.start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then - common assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the gwtcb sandbox should be in the "{status}" state')
|
||||
def step_gwtcb_check_status(ctx: Context, status: str) -> None:
|
||||
expected = SandboxStatus(status)
|
||||
assert ctx.gwtcb_sandbox.status == expected, (
|
||||
f"Expected status {expected}, got {ctx.gwtcb_sandbox.status}"
|
||||
)
|
||||
|
||||
|
||||
@then("a gwtcb SandboxCreationError should be raised")
|
||||
def step_gwtcb_creation_error(ctx: Context) -> None:
|
||||
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
|
||||
assert isinstance(ctx.gwtcb_error, SandboxCreationError), (
|
||||
f"Expected SandboxCreationError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('a gwtcb SandboxCreationError should be raised with message "{msg}"')
|
||||
def step_gwtcb_creation_error_msg(ctx: Context, msg: str) -> None:
|
||||
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
|
||||
assert isinstance(ctx.gwtcb_error, SandboxCreationError), (
|
||||
f"Expected SandboxCreationError, got {type(ctx.gwtcb_error).__name__}"
|
||||
)
|
||||
assert msg in str(ctx.gwtcb_error), (
|
||||
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('a gwtcb SandboxStateError should be raised with message "{msg}"')
|
||||
def step_gwtcb_state_error_msg(ctx: Context, msg: str) -> None:
|
||||
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
|
||||
assert isinstance(ctx.gwtcb_error, SandboxStateError), (
|
||||
f"Expected SandboxStateError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
|
||||
)
|
||||
assert msg in str(ctx.gwtcb_error), (
|
||||
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('a gwtcb SandboxCommitError should be raised with message "{msg}"')
|
||||
def step_gwtcb_commit_error_msg(ctx: Context, msg: str) -> None:
|
||||
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
|
||||
assert isinstance(ctx.gwtcb_error, SandboxCommitError), (
|
||||
f"Expected SandboxCommitError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
|
||||
)
|
||||
assert msg in str(ctx.gwtcb_error), (
|
||||
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('a gwtcb SandboxRollbackError should be raised with message "{msg}"')
|
||||
def step_gwtcb_rollback_error_msg(ctx: Context, msg: str) -> None:
|
||||
assert ctx.gwtcb_error is not None, "Expected an error but none occurred"
|
||||
assert isinstance(ctx.gwtcb_error, SandboxRollbackError), (
|
||||
f"Expected SandboxRollbackError, got {type(ctx.gwtcb_error).__name__}: {ctx.gwtcb_error}"
|
||||
)
|
||||
assert msg in str(ctx.gwtcb_error), (
|
||||
f"Expected '{msg}' in error message, got: {ctx.gwtcb_error}"
|
||||
)
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Step definitions for plan_cli_coverage_boost.feature.
|
||||
|
||||
Covers uncovered branches in cleveragents/cli/commands/plan.py:
|
||||
- _plan_spec_dict: error_message truthy branch, legacy fallback
|
||||
- _print_lifecycle_plan: all optional timestamps, estimation_actor,
|
||||
invariant_actor, non-Plan fallback
|
||||
- list_plans: non-rich format path
|
||||
- execute_plan: non-rich format path
|
||||
- lifecycle_apply_plan: non-rich format path
|
||||
- lifecycle_list_plans: regex, state, processing_state filtering
|
||||
- cancel_plan: rich and non-rich paths, with and without reason
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands import plan as plan_module
|
||||
from cleveragents.cli.commands.plan import (
|
||||
_plan_spec_dict,
|
||||
_print_lifecycle_plan,
|
||||
)
|
||||
from cleveragents.cli.commands.plan import (
|
||||
app as plan_app,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationLevel,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
# Valid ULIDs for test plans
|
||||
_ULIDS = [
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAX",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAY",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAZ",
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FB0",
|
||||
]
|
||||
|
||||
|
||||
def _make_plan(
|
||||
*,
|
||||
plan_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
name: str = "local/test-plan",
|
||||
description: str = "Test plan for coverage",
|
||||
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
||||
processing_state: ProcessingState = ProcessingState.QUEUED,
|
||||
error_message: str | None = None,
|
||||
estimation_actor: str | None = None,
|
||||
invariant_actor: str | None = None,
|
||||
timestamps: PlanTimestamps | None = None,
|
||||
project_links: list[ProjectLink] | None = None,
|
||||
action_name: str = "local/test-action",
|
||||
) -> Plan:
|
||||
"""Build a real Plan object for testing."""
|
||||
if timestamps is None:
|
||||
timestamps = PlanTimestamps(
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=plan_id),
|
||||
namespaced_name=NamespacedName.parse(name),
|
||||
action_name=action_name,
|
||||
description=description,
|
||||
phase=phase,
|
||||
processing_state=processing_state,
|
||||
project_links=project_links or [],
|
||||
error_message=error_message,
|
||||
estimation_actor=estimation_actor,
|
||||
invariant_actor=invariant_actor,
|
||||
timestamps=timestamps,
|
||||
automation_level=AutomationLevel.MANUAL,
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _plan_spec_dict helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a v3 Plan with error_message set to "{error_msg}"')
|
||||
def step_plan_with_error_message(context, error_msg: str) -> None:
|
||||
context.test_plan = _make_plan(error_message=error_msg)
|
||||
|
||||
|
||||
@given("a v3 Plan with error_message set to None")
|
||||
def step_plan_with_no_error_message(context) -> None:
|
||||
context.test_plan = _make_plan(error_message=None)
|
||||
|
||||
|
||||
@given('a non-Plan object with string value "{value}"')
|
||||
def step_non_plan_object(context, value: str) -> None:
|
||||
context.test_plan = value
|
||||
|
||||
|
||||
@when("I call _plan_spec_dict on the plan")
|
||||
def step_call_plan_spec_dict_on_plan(context) -> None:
|
||||
context.spec_dict = _plan_spec_dict(context.test_plan)
|
||||
|
||||
|
||||
@when("I call _plan_spec_dict on the object")
|
||||
def step_call_plan_spec_dict_on_object(context) -> None:
|
||||
context.spec_dict = _plan_spec_dict(context.test_plan)
|
||||
|
||||
|
||||
@then('the spec dict should contain key "{key}" with value "{value}"')
|
||||
def step_spec_dict_contains_key_value(context, key: str, value: str) -> None:
|
||||
assert key in context.spec_dict, (
|
||||
f"Key '{key}' not in spec dict: {context.spec_dict}"
|
||||
)
|
||||
assert str(context.spec_dict[key]) == value, (
|
||||
f"Expected '{value}', got '{context.spec_dict[key]}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the spec dict should equal {expected}")
|
||||
def step_spec_dict_equals(context, expected: str) -> None:
|
||||
import json
|
||||
|
||||
expected_dict = json.loads(expected.replace("'", '"'))
|
||||
assert context.spec_dict == expected_dict, (
|
||||
f"Expected {expected_dict}, got {context.spec_dict}"
|
||||
)
|
||||
|
||||
|
||||
@then('the spec dict should not contain key "{key}"')
|
||||
def step_spec_dict_not_contains_key(context, key: str) -> None:
|
||||
assert key not in context.spec_dict, (
|
||||
f"Key '{key}' should not be in spec dict: {context.spec_dict}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _print_lifecycle_plan helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a v3 Plan with all timestamps populated")
|
||||
def step_plan_with_all_timestamps(context) -> None:
|
||||
now = datetime.now()
|
||||
ts = PlanTimestamps(
|
||||
created_at=now - timedelta(hours=2),
|
||||
updated_at=now,
|
||||
strategize_started_at=now - timedelta(hours=1, minutes=50),
|
||||
strategize_completed_at=now - timedelta(hours=1, minutes=40),
|
||||
execute_started_at=now - timedelta(hours=1, minutes=30),
|
||||
execute_completed_at=now - timedelta(hours=1),
|
||||
applied_at=now - timedelta(minutes=30),
|
||||
)
|
||||
context.test_plan = _make_plan(
|
||||
timestamps=ts,
|
||||
phase=PlanPhase.APPLY,
|
||||
processing_state=ProcessingState.APPLIED,
|
||||
)
|
||||
|
||||
|
||||
@given('a v3 Plan with estimation_actor set to "{actor}"')
|
||||
def step_plan_with_estimation_actor(context, actor: str) -> None:
|
||||
context.test_plan = _make_plan(estimation_actor=actor)
|
||||
|
||||
|
||||
@given('a v3 Plan with invariant_actor set to "{actor}"')
|
||||
def step_plan_with_invariant_actor(context, actor: str) -> None:
|
||||
context.test_plan = _make_plan(invariant_actor=actor)
|
||||
|
||||
|
||||
@when("I call _print_lifecycle_plan on the plan")
|
||||
def step_call_print_lifecycle_plan_on_plan(context) -> None:
|
||||
buf = StringIO()
|
||||
from rich.console import Console
|
||||
|
||||
test_console = Console(file=buf, width=200, no_color=True)
|
||||
with patch.object(plan_module, "console", test_console):
|
||||
_print_lifecycle_plan(context.test_plan, title="Test Plan")
|
||||
context.printed_output = buf.getvalue()
|
||||
|
||||
|
||||
@when("I call _print_lifecycle_plan on the object")
|
||||
def step_call_print_lifecycle_plan_on_object(context) -> None:
|
||||
buf = StringIO()
|
||||
from rich.console import Console
|
||||
|
||||
test_console = Console(file=buf, width=200, no_color=True)
|
||||
with patch.object(plan_module, "console", test_console):
|
||||
_print_lifecycle_plan(context.test_plan, title="Legacy Plan")
|
||||
context.printed_output = buf.getvalue()
|
||||
|
||||
|
||||
@then('the printed output should contain "{text}"')
|
||||
def step_printed_output_contains(context, text: str) -> None:
|
||||
assert text in context.printed_output, (
|
||||
f"Expected '{text}' in output:\n{context.printed_output}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI runner steps for list_plans, execute, lifecycle-apply, lifecycle-list,
|
||||
# cancel in non-rich and rich formats
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _output(context) -> str:
|
||||
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
|
||||
|
||||
|
||||
@given("a plan lifecycle CLI runner for coverage")
|
||||
def step_cli_runner_for_coverage(context) -> None:
|
||||
context.runner = CliRunner()
|
||||
|
||||
|
||||
@given("a mocked lifecycle service for plan coverage commands")
|
||||
def step_mocked_lifecycle_service_for_coverage(context) -> None:
|
||||
context.mock_lifecycle_service = MagicMock()
|
||||
patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_lifecycle_service,
|
||||
)
|
||||
patcher.start()
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(patcher.stop)
|
||||
|
||||
# Widen the Rich console so table columns do not wrap during tests
|
||||
console_patcher = patch.object(plan_module.console, "width", 200)
|
||||
console_patcher.start()
|
||||
context._cleanup_handlers.append(console_patcher.stop)
|
||||
|
||||
|
||||
@given("the mocked plan service returns legacy plans")
|
||||
def step_mocked_plan_service_returns_legacy_plans(context) -> None:
|
||||
"""Set up the container mock so list_plans returns legacy Plan objects."""
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.name = "test-plan"
|
||||
mock_plan.status = "active"
|
||||
mock_plan.created_at = datetime.now()
|
||||
mock_plan.current = False
|
||||
mock_plan.id = "plan-001"
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.name = "test-project"
|
||||
|
||||
mock_plan_service = MagicMock()
|
||||
mock_plan_service.list_plans.return_value = [mock_plan]
|
||||
|
||||
mock_project_service = MagicMock()
|
||||
mock_project_service.get_current_project.return_value = mock_project
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.plan_service.return_value = mock_plan_service
|
||||
mock_container.project_service.return_value = mock_project_service
|
||||
|
||||
patcher = patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=mock_container,
|
||||
)
|
||||
patcher.start()
|
||||
context._cleanup_handlers.append(patcher.stop)
|
||||
|
||||
|
||||
@given("the service has a complete strategize plan for execute")
|
||||
def step_service_has_strategize_plan(context) -> None:
|
||||
plan = _make_plan(
|
||||
plan_id=_ULIDS[0],
|
||||
name="local/exec-plan",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.COMPLETE,
|
||||
)
|
||||
context.mock_lifecycle_service.execute_plan.return_value = plan
|
||||
context._execute_plan_id = _ULIDS[0]
|
||||
|
||||
|
||||
@given("the service has a complete execute plan for apply")
|
||||
def step_service_has_execute_plan(context) -> None:
|
||||
plan = _make_plan(
|
||||
plan_id=_ULIDS[1],
|
||||
name="local/apply-plan",
|
||||
phase=PlanPhase.EXECUTE,
|
||||
processing_state=ProcessingState.COMPLETE,
|
||||
)
|
||||
context.mock_lifecycle_service.apply_plan.return_value = plan
|
||||
context._apply_plan_id = _ULIDS[1]
|
||||
|
||||
|
||||
@given("the service has multiple plans for lifecycle list")
|
||||
def step_service_has_multiple_plans(context) -> None:
|
||||
plans = [
|
||||
_make_plan(
|
||||
plan_id=_ULIDS[0],
|
||||
name="local/alpha-plan",
|
||||
action_name="local/test-action",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
),
|
||||
_make_plan(
|
||||
plan_id=_ULIDS[1],
|
||||
name="local/beta-plan",
|
||||
action_name="local/other-action",
|
||||
phase=PlanPhase.EXECUTE,
|
||||
processing_state=ProcessingState.PROCESSING,
|
||||
),
|
||||
]
|
||||
context.mock_lifecycle_service.list_plans.return_value = plans
|
||||
|
||||
|
||||
@given("the service has plans in different processing states")
|
||||
def step_service_has_plans_different_states(context) -> None:
|
||||
plans = [
|
||||
_make_plan(
|
||||
plan_id=_ULIDS[0],
|
||||
name="local/plan-processing",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.PROCESSING,
|
||||
),
|
||||
_make_plan(
|
||||
plan_id=_ULIDS[1],
|
||||
name="local/plan-complete",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.COMPLETE,
|
||||
),
|
||||
]
|
||||
context.mock_lifecycle_service.list_plans.return_value = plans
|
||||
|
||||
|
||||
@given("the service can cancel a plan")
|
||||
def step_service_can_cancel(context) -> None:
|
||||
plan = _make_plan(
|
||||
plan_id=_ULIDS[2],
|
||||
name="local/cancel-me",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.CANCELLED,
|
||||
)
|
||||
context.mock_lifecycle_service.cancel_plan.return_value = plan
|
||||
context._cancel_plan_id = _ULIDS[2]
|
||||
|
||||
|
||||
# ---- When steps for CLI invocations ----
|
||||
|
||||
|
||||
@when('I invoke list_plans with "--format" "{fmt}"')
|
||||
def step_invoke_list_plans_format(context, fmt: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["list", "--format", fmt],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke execute with "--format" "json" and plan id')
|
||||
def step_invoke_execute_json(context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["execute", context._execute_plan_id, "--format", "json"],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-apply with "--format" "json" and plan id')
|
||||
def step_invoke_lifecycle_apply_json(context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-apply", context._apply_plan_id, "--format", "json"],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-list with regex "{pattern}"')
|
||||
def step_invoke_lifecycle_list_regex(context, pattern: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-list", pattern],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-list with "--state" "{state}"')
|
||||
def step_invoke_lifecycle_list_state(context, state: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-list", "--state", state],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-list with "--processing-state" "{state}"')
|
||||
def step_invoke_lifecycle_list_processing_state(context, state: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-list", "--processing-state", state],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-list with "--action" "{action}"')
|
||||
def step_invoke_lifecycle_list_action(context, action: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-list", "--action", action],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke lifecycle-list with "--format" "{fmt}"')
|
||||
def step_invoke_lifecycle_list_format(context, fmt: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["lifecycle-list", "--format", fmt],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke cancel with "--format" "json" and no reason')
|
||||
def step_invoke_cancel_json_no_reason(context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["cancel", context._cancel_plan_id, "--format", "json"],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke cancel with "--format" "json" and reason "{reason}"')
|
||||
def step_invoke_cancel_json_with_reason(context, reason: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
[
|
||||
"cancel",
|
||||
context._cancel_plan_id,
|
||||
"--format",
|
||||
"json",
|
||||
"--reason",
|
||||
reason,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when('I invoke cancel in rich format with reason "{reason}"')
|
||||
def step_invoke_cancel_rich_with_reason(context, reason: str) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["cancel", context._cancel_plan_id, "--reason", reason],
|
||||
)
|
||||
|
||||
|
||||
@when("I invoke cancel in rich format without reason")
|
||||
def step_invoke_cancel_rich_no_reason(context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
plan_app,
|
||||
["cancel", context._cancel_plan_id],
|
||||
)
|
||||
|
||||
|
||||
# ---- Then steps for CLI assertions ----
|
||||
|
||||
|
||||
@then("the plan coverage command should succeed")
|
||||
def step_plan_coverage_command_succeeds(context) -> None:
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected success but got exit code {context.result.exit_code}.\n"
|
||||
f"Output: {_output(context)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan coverage command should abort")
|
||||
def step_plan_coverage_command_aborts(context) -> None:
|
||||
assert context.result.exit_code != 0, (
|
||||
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan coverage output should contain "{text}"')
|
||||
def step_plan_coverage_output_contains(context, text: str) -> None:
|
||||
output = _output(context)
|
||||
assert text in output, f"Expected '{text}' in output:\n{output}"
|
||||
|
||||
|
||||
@then('the plan coverage output should not contain "{text}"')
|
||||
def step_plan_coverage_output_not_contains(context, text: str) -> None:
|
||||
output = _output(context)
|
||||
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Step definitions for plan_lifecycle_service_coverage_boost.feature.
|
||||
|
||||
Targets uncovered lines and branches in PlanLifecycleService:
|
||||
- Line 284: get_action_by_name linear scan fallback
|
||||
- Line 420: use_action PlanInvariant append from action.invariants
|
||||
- Line 643: execute_plan InvalidPhaseTransitionError
|
||||
- Lines 829-846: constrain_apply method (happy + wrong-phase)
|
||||
- Line 959: auto_progress final return (no condition matched)
|
||||
"""
|
||||
|
||||
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.plan import (
|
||||
AutomationLevel,
|
||||
InvariantSource,
|
||||
PlanPhase,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Background
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have a fresh plan lifecycle service for coverage boost")
|
||||
def step_create_fresh_service(context: Context) -> None:
|
||||
"""Create a clean PlanLifecycleService instance."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
context.service = PlanLifecycleService(settings=settings)
|
||||
context.error = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helpers (not steps)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_action(context: Context, name: str, **kwargs):
|
||||
"""Helper to create a basic action."""
|
||||
defaults = {
|
||||
"name": name,
|
||||
"description": f"Action {name}",
|
||||
"definition_of_done": "Tests pass",
|
||||
"strategy_actor": "openai/gpt-4",
|
||||
"execution_actor": "openai/gpt-4",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return context.service.create_action(**defaults)
|
||||
|
||||
|
||||
def _create_plan_in_phase(context: Context, target_phase: PlanPhase):
|
||||
"""Create a plan and advance it to the given phase.
|
||||
|
||||
Returns the plan. Stores it on context.plan.
|
||||
"""
|
||||
action = _create_action(context, f"local/cov-{id(context)}")
|
||||
plan = context.service.use_action(
|
||||
action_name=str(action.namespaced_name),
|
||||
project_links=[ProjectLink(project_name="proj-1")],
|
||||
)
|
||||
pid = plan.identity.plan_id
|
||||
|
||||
if target_phase in (PlanPhase.EXECUTE, PlanPhase.APPLY):
|
||||
context.service.start_strategize(pid)
|
||||
context.service.complete_strategize(pid)
|
||||
context.service.execute_plan(pid)
|
||||
|
||||
if target_phase == PlanPhase.APPLY:
|
||||
context.service.start_execute(pid)
|
||||
context.service.complete_execute(pid)
|
||||
context.service.apply_plan(pid)
|
||||
|
||||
context.plan = context.service.get_plan(pid)
|
||||
return context.plan
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: get_action_by_name linear scan fallback (line 284)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("an action stored under a mismatched dict key")
|
||||
def step_store_action_under_wrong_key(context: Context) -> None:
|
||||
"""Create an action normally, then re-key it so the dict lookup misses.
|
||||
|
||||
The action's ``namespaced_name`` stays correct, but the dict key
|
||||
in ``_actions`` is changed to something different. This forces
|
||||
``get_action_by_name`` to fall through to the linear scan.
|
||||
"""
|
||||
action = _create_action(context, "local/scan-target")
|
||||
correct_key = str(action.namespaced_name) # "local/scan-target"
|
||||
|
||||
# Remove from its correct key and store under a bogus key
|
||||
del context.service._actions[correct_key]
|
||||
context.service._actions["__mismatched_key__"] = action
|
||||
|
||||
context.expected_action = action
|
||||
|
||||
|
||||
@when("I look up the action by its namespaced name via get_action_by_name")
|
||||
def step_lookup_via_get_action_by_name(context: Context) -> None:
|
||||
"""Call get_action_by_name with the real namespaced name."""
|
||||
context.found_action = context.service.get_action_by_name("local/scan-target")
|
||||
|
||||
|
||||
@then("the action should be found via linear scan fallback")
|
||||
def step_verify_linear_scan_found(context: Context) -> None:
|
||||
"""Verify the action returned is the one we stored."""
|
||||
assert context.found_action is not None
|
||||
assert context.found_action is context.expected_action
|
||||
assert str(context.found_action.namespaced_name) == "local/scan-target"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: use_action copies action.invariants (line 420)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given('an action with invariants "{inv1}" and "{inv2}"')
|
||||
def step_create_action_with_invariants(context: Context, inv1: str, inv2: str) -> None:
|
||||
"""Create an action whose ``invariants`` list is non-empty."""
|
||||
context.action = _create_action(
|
||||
context,
|
||||
"local/inv-action",
|
||||
invariants=[inv1, inv2],
|
||||
)
|
||||
|
||||
|
||||
@when("I use that action to create a plan")
|
||||
def step_use_action_create_plan(context: Context) -> None:
|
||||
"""Use the action to instantiate a plan."""
|
||||
context.plan = context.service.use_action(
|
||||
action_name=str(context.action.namespaced_name),
|
||||
project_links=[ProjectLink(project_name="proj-inv")],
|
||||
)
|
||||
|
||||
|
||||
@then("the plan should contain {count:d} action-sourced invariants")
|
||||
def step_check_action_sourced_invariant_count(context: Context, count: int) -> None:
|
||||
"""Verify the plan has the expected number of ACTION-sourced invariants."""
|
||||
action_invariants = [
|
||||
inv for inv in context.plan.invariants if inv.source == InvariantSource.ACTION
|
||||
]
|
||||
assert len(action_invariants) == count, (
|
||||
f"Expected {count} ACTION-sourced invariants, got {len(action_invariants)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the invariant texts should include "{text}"')
|
||||
def step_check_invariant_text_present(context: Context, text: str) -> None:
|
||||
"""Verify a specific invariant text is present."""
|
||||
texts = [inv.text for inv in context.plan.invariants]
|
||||
assert text in texts, f"Expected '{text}' in invariant texts: {texts}"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: execute_plan InvalidPhaseTransitionError (line 643)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("a plan that is already in execute phase")
|
||||
def step_plan_already_in_execute(context: Context) -> None:
|
||||
"""Create a plan already in Execute/QUEUED phase."""
|
||||
_create_plan_in_phase(context, PlanPhase.EXECUTE)
|
||||
|
||||
|
||||
@given("a plan that is already in apply phase")
|
||||
def step_plan_already_in_apply(context: Context) -> None:
|
||||
"""Create a plan already in Apply/QUEUED phase."""
|
||||
_create_plan_in_phase(context, PlanPhase.APPLY)
|
||||
|
||||
|
||||
@when("I try to execute the plan from execute phase")
|
||||
def step_try_execute_from_execute(context: Context) -> None:
|
||||
"""Attempt execute_plan when plan is already in Execute phase."""
|
||||
context.error = None
|
||||
try:
|
||||
context.service.execute_plan(context.plan.identity.plan_id)
|
||||
except InvalidPhaseTransitionError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to execute the plan from apply phase")
|
||||
def step_try_execute_from_apply(context: Context) -> None:
|
||||
"""Attempt execute_plan when plan is in Apply phase."""
|
||||
context.error = None
|
||||
try:
|
||||
context.service.execute_plan(context.plan.identity.plan_id)
|
||||
except InvalidPhaseTransitionError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("an InvalidPhaseTransitionError should be raised for the coverage boost")
|
||||
def step_verify_invalid_transition_error(context: Context) -> None:
|
||||
"""Verify that an InvalidPhaseTransitionError was raised."""
|
||||
assert context.error is not None, (
|
||||
"Expected InvalidPhaseTransitionError but none raised"
|
||||
)
|
||||
assert isinstance(context.error, InvalidPhaseTransitionError), (
|
||||
f"Expected InvalidPhaseTransitionError, got {type(context.error).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: constrain_apply happy path (lines 829-846)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("a plan in apply phase with processing state for coverage boost")
|
||||
def step_plan_in_apply_processing(context: Context) -> None:
|
||||
"""Create a plan in Apply/PROCESSING state."""
|
||||
_create_plan_in_phase(context, PlanPhase.APPLY)
|
||||
pid = context.plan.identity.plan_id
|
||||
context.service.start_apply(pid)
|
||||
context.plan = context.service.get_plan(pid)
|
||||
|
||||
|
||||
@when('I constrain the apply with reason "{reason}"')
|
||||
def step_constrain_apply(context: Context, reason: str) -> None:
|
||||
"""Call constrain_apply on the plan."""
|
||||
context.plan = context.service.constrain_apply(
|
||||
context.plan.identity.plan_id, reason
|
||||
)
|
||||
|
||||
|
||||
@then('the plan processing state should be "{expected}" for coverage boost')
|
||||
def step_check_processing_state_cb(context: Context, expected: str) -> None:
|
||||
"""Verify the plan processing state."""
|
||||
actual = context.plan.processing_state.value
|
||||
assert actual == expected, f"Expected state '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the plan error message should be "{expected}" for coverage boost')
|
||||
def step_check_error_message_cb(context: Context, expected: str) -> None:
|
||||
"""Verify the plan error_message field."""
|
||||
assert context.plan.error_message == expected, (
|
||||
f"Expected error message '{expected}', got '{context.plan.error_message}'"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: constrain_apply wrong phase (line 831-832)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("a plan in strategize phase for coverage boost")
|
||||
def step_plan_in_strategize_for_cb(context: Context) -> None:
|
||||
"""Create a plan that is still in Strategize phase."""
|
||||
action = _create_action(context, "local/strat-only")
|
||||
context.plan = context.service.use_action(
|
||||
action_name=str(action.namespaced_name),
|
||||
project_links=[ProjectLink(project_name="proj-strat")],
|
||||
)
|
||||
|
||||
|
||||
@when('I try to constrain the apply with reason "{reason}"')
|
||||
def step_try_constrain_apply(context: Context, reason: str) -> None:
|
||||
"""Attempt constrain_apply expecting it to fail."""
|
||||
context.error = None
|
||||
try:
|
||||
context.service.constrain_apply(context.plan.identity.plan_id, reason)
|
||||
except PlanError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a PlanError should be raised for coverage boost")
|
||||
def step_verify_plan_error_cb(context: Context) -> None:
|
||||
"""Verify a PlanError was raised."""
|
||||
assert context.error is not None, "Expected PlanError but none raised"
|
||||
assert isinstance(context.error, PlanError), (
|
||||
f"Expected PlanError, got {type(context.error).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Scenario: auto_progress final return (line 959)
|
||||
# =================================================================
|
||||
|
||||
|
||||
@given("a plan in strategize queued state with full automation for coverage boost")
|
||||
def step_plan_strategize_queued_full_auto(context: Context) -> None:
|
||||
"""Create a plan in Strategize/QUEUED with FULL_AUTOMATION.
|
||||
|
||||
should_auto_progress will return False because the plan is
|
||||
QUEUED (not COMPLETE), so auto_progress hits the early return
|
||||
at line 935. To hit line 959 instead, we need
|
||||
should_auto_progress to return True but neither if-block to match.
|
||||
|
||||
However, looking at the logic: should_auto_progress returns True
|
||||
only when phase==STRATEGIZE+COMPLETE or phase==EXECUTE+COMPLETE.
|
||||
If should_auto_progress is True, one of the two if-blocks in
|
||||
auto_progress *will* match — unless the plan state changed between
|
||||
the check and the if-blocks.
|
||||
|
||||
The simplest way to reach line 959 is for should_auto_progress
|
||||
to return False, which makes auto_progress return at line 935.
|
||||
But line 935 is already covered.
|
||||
|
||||
To truly hit line 959, we need should_auto_progress to return True
|
||||
but the plan to NOT match either if-condition by the time we reach
|
||||
them. We can do this by monkey-patching should_auto_progress.
|
||||
"""
|
||||
action = _create_action(context, "local/auto-prog-test")
|
||||
context.plan = context.service.use_action(
|
||||
action_name=str(action.namespaced_name),
|
||||
project_links=[ProjectLink(project_name="proj-auto")],
|
||||
automation_level=AutomationLevel.FULL_AUTOMATION,
|
||||
)
|
||||
# Plan is now Strategize/QUEUED — should_auto_progress returns False,
|
||||
# so we monkey-patch it to return True to force execution past line 935
|
||||
# into the if-blocks, which won't match (QUEUED != COMPLETE).
|
||||
context.service.should_auto_progress = lambda plan: True
|
||||
|
||||
|
||||
@when("I call auto_progress on the plan")
|
||||
def step_call_auto_progress(context: Context) -> None:
|
||||
"""Call auto_progress."""
|
||||
context.plan = context.service.auto_progress(context.plan.identity.plan_id)
|
||||
|
||||
|
||||
@then("the plan should be returned unchanged in strategize queued state")
|
||||
def step_verify_plan_unchanged(context: Context) -> None:
|
||||
"""Verify the plan was returned without phase/state changes."""
|
||||
assert context.plan.phase == PlanPhase.STRATEGIZE, (
|
||||
f"Expected STRATEGIZE, got {context.plan.phase}"
|
||||
)
|
||||
assert context.plan.processing_state == ProcessingState.QUEUED, (
|
||||
f"Expected QUEUED, got {context.plan.processing_state}"
|
||||
)
|
||||
@@ -0,0 +1,711 @@
|
||||
"""Step definitions for repository coverage boost.
|
||||
|
||||
Targets uncovered lines in ``repositories.py``:
|
||||
- ActionRepository.update: row-is-None branch, arguments loop with default_value,
|
||||
invariants loop
|
||||
- LifecyclePlanRepository: get_by_name, update PlanNotFoundError,
|
||||
update with project_links/arguments/invariants, list_plans by phase
|
||||
- ResourceTypeRepository: create, get, duplicate handling
|
||||
- ResourceRepository: create, get, get_by_name, list_resources by type
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.core.exceptions import DatabaseError
|
||||
from cleveragents.domain.models.core.action import (
|
||||
Action,
|
||||
ActionArgument,
|
||||
ActionState,
|
||||
ArgumentRequirement,
|
||||
ArgumentType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationLevel,
|
||||
InvariantSource,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanInvariant,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
ProjectLink,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import Base
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ActionRepository,
|
||||
LifecyclePlanRepository,
|
||||
PlanNotFoundError,
|
||||
)
|
||||
|
||||
# Crockford base32 alphabet for ULID generation
|
||||
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
_ULID_COUNTER = 100 # start high to avoid collisions with other step files
|
||||
|
||||
|
||||
def _next_ulid() -> str:
|
||||
"""Return a unique, valid ULID string for each call."""
|
||||
from ulid import ULID
|
||||
|
||||
return str(ULID())
|
||||
|
||||
|
||||
def _make_action(
|
||||
name: str = "local/test-action",
|
||||
state: str = "available",
|
||||
arguments: list[ActionArgument] | None = None,
|
||||
invariants: list[str] | None = None,
|
||||
) -> Action:
|
||||
"""Create a minimal valid Action domain object."""
|
||||
parts = name.split("/", 1)
|
||||
namespace = parts[0] if len(parts) == 2 else "local"
|
||||
short_name = parts[1] if len(parts) == 2 else parts[0]
|
||||
|
||||
return Action(
|
||||
namespaced_name=NamespacedName(
|
||||
namespace=namespace,
|
||||
name=short_name,
|
||||
),
|
||||
description=f"Test action {short_name}",
|
||||
long_description=None,
|
||||
definition_of_done=f"Verify {short_name} completes",
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
estimation_actor=None,
|
||||
review_actor=None,
|
||||
arguments=arguments or [],
|
||||
invariants=invariants or [],
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
state=ActionState(state),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
created_by=None,
|
||||
tags=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_plan(
|
||||
action_name: str,
|
||||
plan_id: str | None = None,
|
||||
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
||||
ns_name: str = "local/test-plan",
|
||||
) -> Plan:
|
||||
"""Create a minimal valid Plan domain object."""
|
||||
pid = plan_id or _next_ulid()
|
||||
parts = ns_name.split("/", 1)
|
||||
namespace = parts[0] if len(parts) == 2 else "local"
|
||||
short_name = parts[1] if len(parts) == 2 else parts[0]
|
||||
|
||||
return Plan(
|
||||
identity=PlanIdentity(
|
||||
plan_id=pid,
|
||||
parent_plan_id=None,
|
||||
root_plan_id=None,
|
||||
attempt=1,
|
||||
),
|
||||
namespaced_name=NamespacedName(
|
||||
namespace=namespace,
|
||||
name=short_name,
|
||||
),
|
||||
action_name=action_name,
|
||||
description=f"Test plan for {action_name}",
|
||||
definition_of_done="Tests pass",
|
||||
phase=phase,
|
||||
processing_state=ProcessingState.QUEUED,
|
||||
automation_level=AutomationLevel.MANUAL,
|
||||
strategy_actor="local/strategist",
|
||||
execution_actor="local/executor",
|
||||
timestamps=PlanTimestamps(
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
),
|
||||
created_by=None,
|
||||
tags=[],
|
||||
reusable=True,
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Background
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given("a fresh in-memory database with full lifecycle schema")
|
||||
def step_fresh_db(context: Context) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
context.db_engine = engine
|
||||
session = sessionmaker(bind=engine)()
|
||||
context.db_session = session
|
||||
context.db_session_factory = lambda: session
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("an action repository using the session factory")
|
||||
def step_action_repo(context: Context) -> None:
|
||||
context.action_repo = ActionRepository(
|
||||
session_factory=context.db_session_factory,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("a lifecycle plan repository using the session factory")
|
||||
def step_plan_repo(context: Context) -> None:
|
||||
context.plan_repo = LifecyclePlanRepository(
|
||||
session_factory=context.db_session_factory,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# ActionRepository.update - non-existent action (line 921)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given('a valid action object named "{name}"')
|
||||
def step_make_action_obj(context: Context, name: str) -> None:
|
||||
context.action = _make_action(name=name)
|
||||
|
||||
|
||||
@when("the action is updated without being persisted first")
|
||||
def step_update_non_existent_action(context: Context) -> None:
|
||||
try:
|
||||
context.action_repo.update(context.action)
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then('a DatabaseError mentioning "{text}" should be raised')
|
||||
def step_verify_database_error_text(context: Context, text: str) -> None:
|
||||
assert context.error is not None, "Expected DatabaseError, got no error"
|
||||
assert isinstance(context.error, DatabaseError), (
|
||||
f"Expected DatabaseError, got {type(context.error).__name__}: {context.error}"
|
||||
)
|
||||
assert text in str(context.error), (
|
||||
f"Expected '{text}' in error message: {context.error}"
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# ActionRepository.update - with arguments and invariants (lines 940-965)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given("the action has been persisted in the database")
|
||||
def step_persist_action(context: Context) -> None:
|
||||
context.action_repo.create(context.action)
|
||||
context.db_session.commit()
|
||||
|
||||
|
||||
@when("the action arguments are replaced with new arguments including defaults")
|
||||
def step_replace_arguments(context: Context) -> None:
|
||||
new_args = [
|
||||
ActionArgument(
|
||||
name="target_coverage",
|
||||
arg_type=ArgumentType.INTEGER,
|
||||
requirement=ArgumentRequirement.REQUIRED,
|
||||
description="Target coverage percentage",
|
||||
default_value=80,
|
||||
min_value=0.0,
|
||||
max_value=100.0,
|
||||
),
|
||||
ActionArgument(
|
||||
name="framework",
|
||||
arg_type=ArgumentType.STRING,
|
||||
requirement=ArgumentRequirement.OPTIONAL,
|
||||
description="Test framework to use",
|
||||
default_value="pytest",
|
||||
),
|
||||
ActionArgument(
|
||||
name="verbose",
|
||||
arg_type=ArgumentType.BOOLEAN,
|
||||
requirement=ArgumentRequirement.OPTIONAL,
|
||||
description="Enable verbose output",
|
||||
default_value=None,
|
||||
),
|
||||
]
|
||||
context.action = context.action.model_copy(update={"arguments": new_args})
|
||||
|
||||
|
||||
@when("the action invariants are set to new invariant texts")
|
||||
def step_set_invariants(context: Context) -> None:
|
||||
context.action = context.action.model_copy(
|
||||
update={
|
||||
"invariants": [
|
||||
"All tests must pass before deployment",
|
||||
"Code coverage must not decrease",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@when("the action is updated via the repository")
|
||||
def step_update_action(context: Context) -> None:
|
||||
try:
|
||||
context.action = context.action.model_copy(
|
||||
update={"updated_at": datetime.now()}
|
||||
)
|
||||
context.result_action = context.action_repo.update(context.action)
|
||||
context.db_session.commit()
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then("the update should succeed without error")
|
||||
def step_verify_no_update_error(context: Context) -> None:
|
||||
assert context.error is None, f"Unexpected error: {context.error}"
|
||||
|
||||
|
||||
@then("retrieving the action should show the new arguments")
|
||||
def step_verify_action_arguments(context: Context) -> None:
|
||||
fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
|
||||
assert fetched is not None, "Action not found after update"
|
||||
assert len(fetched.arguments) == 3, (
|
||||
f"Expected 3 arguments, got {len(fetched.arguments)}"
|
||||
)
|
||||
arg_names = [a.name for a in fetched.arguments]
|
||||
assert "target_coverage" in arg_names
|
||||
assert "framework" in arg_names
|
||||
assert "verbose" in arg_names
|
||||
|
||||
# Verify default_value round-trip for the argument that has one
|
||||
framework_arg = next(a for a in fetched.arguments if a.name == "framework")
|
||||
assert framework_arg.default_value == "pytest", (
|
||||
f"Expected default_value 'pytest', got {framework_arg.default_value!r}"
|
||||
)
|
||||
|
||||
coverage_arg = next(a for a in fetched.arguments if a.name == "target_coverage")
|
||||
assert coverage_arg.default_value == 80, (
|
||||
f"Expected default_value 80, got {coverage_arg.default_value!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("retrieving the action should show the new invariants")
|
||||
def step_verify_action_invariants(context: Context) -> None:
|
||||
fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
|
||||
assert fetched is not None, "Action not found after update"
|
||||
assert len(fetched.invariants) == 2, (
|
||||
f"Expected 2 invariants, got {len(fetched.invariants)}"
|
||||
)
|
||||
assert "All tests must pass before deployment" in fetched.invariants
|
||||
assert "Code coverage must not decrease" in fetched.invariants
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# LifecyclePlanRepository.get_by_name (lines 1162-1163)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given('a lifecycle plan domain object linked to "{action_name}"')
|
||||
def step_make_plan(context: Context, action_name: str) -> None:
|
||||
context.plan = _make_plan(
|
||||
action_name=action_name,
|
||||
ns_name=f"local/plan-{action_name.split('/')[-1]}",
|
||||
)
|
||||
|
||||
|
||||
@given("the lifecycle plan has been persisted in the database")
|
||||
def step_persist_plan(context: Context) -> None:
|
||||
context.plan_repo.create(context.plan)
|
||||
context.db_session.commit()
|
||||
|
||||
|
||||
@when("the lifecycle plan is looked up by namespaced name")
|
||||
def step_get_plan_by_name(context: Context) -> None:
|
||||
ns_name = str(context.plan.namespaced_name)
|
||||
context.result_plan = context.plan_repo.get_by_name(ns_name)
|
||||
|
||||
|
||||
@then("the returned plan should match the original plan identity")
|
||||
def step_verify_plan_identity(context: Context) -> None:
|
||||
assert context.result_plan is not None, "Expected a plan, got None"
|
||||
assert context.result_plan.identity.plan_id == context.plan.identity.plan_id, (
|
||||
f"Expected plan_id {context.plan.identity.plan_id}, "
|
||||
f"got {context.result_plan.identity.plan_id}"
|
||||
)
|
||||
|
||||
|
||||
@when('a lifecycle plan is looked up by name "{name}"')
|
||||
def step_get_plan_by_name_direct(context: Context, name: str) -> None:
|
||||
context.result_plan = context.plan_repo.get_by_name(name)
|
||||
|
||||
|
||||
@then("no lifecycle plan should be returned")
|
||||
def step_verify_no_plan(context: Context) -> None:
|
||||
assert context.result_plan is None, f"Expected None, got {context.result_plan}"
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# LifecyclePlanRepository.update - PlanNotFoundError (lines 1190-1191)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@when("the lifecycle plan is updated without being persisted first")
|
||||
def step_update_non_existent_plan(context: Context) -> None:
|
||||
try:
|
||||
context.plan_repo.update(context.plan)
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then("a PlanNotFoundError should be raised")
|
||||
def step_verify_plan_not_found(context: Context) -> None:
|
||||
assert context.error is not None, "Expected PlanNotFoundError, got no error"
|
||||
assert isinstance(context.error, PlanNotFoundError), (
|
||||
f"Expected PlanNotFoundError, got {type(context.error).__name__}: {context.error}"
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# LifecyclePlanRepository.update - with project_links, arguments, invariants
|
||||
# (lines 1225-1314)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@when("the plan is updated with project links, arguments, and invariants")
|
||||
def step_update_plan_with_children(context: Context) -> None:
|
||||
try:
|
||||
updated = context.plan.model_copy(
|
||||
update={
|
||||
"project_links": [
|
||||
ProjectLink(
|
||||
project_name="local/api-service",
|
||||
alias="api",
|
||||
read_only=False,
|
||||
),
|
||||
ProjectLink(
|
||||
project_name="local/web-frontend",
|
||||
alias="web",
|
||||
read_only=True,
|
||||
),
|
||||
],
|
||||
"arguments": {
|
||||
"env": "production",
|
||||
"replicas": 3,
|
||||
},
|
||||
"arguments_order": ["env", "replicas"],
|
||||
"invariants": [
|
||||
PlanInvariant(
|
||||
text="Must not break backward compatibility",
|
||||
source=InvariantSource.PLAN,
|
||||
),
|
||||
PlanInvariant(
|
||||
text="All integration tests must pass",
|
||||
source=InvariantSource.ACTION,
|
||||
),
|
||||
],
|
||||
"timestamps": PlanTimestamps(
|
||||
created_at=context.plan.timestamps.created_at,
|
||||
updated_at=datetime.now(),
|
||||
),
|
||||
}
|
||||
)
|
||||
context.plan = updated
|
||||
context.result_plan = context.plan_repo.update(updated)
|
||||
context.db_session.commit()
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then("the plan update should succeed without error")
|
||||
def step_verify_plan_update_ok(context: Context) -> None:
|
||||
assert context.error is None, f"Unexpected error: {context.error}"
|
||||
|
||||
|
||||
@then("retrieving the plan should show the new project links")
|
||||
def step_verify_plan_project_links(context: Context) -> None:
|
||||
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
||||
assert fetched is not None, "Plan not found after update"
|
||||
assert len(fetched.project_links) == 2, (
|
||||
f"Expected 2 project links, got {len(fetched.project_links)}"
|
||||
)
|
||||
names = [pl.project_name for pl in fetched.project_links]
|
||||
assert "local/api-service" in names
|
||||
assert "local/web-frontend" in names
|
||||
|
||||
|
||||
@then("retrieving the plan should show the new plan arguments")
|
||||
def step_verify_plan_arguments(context: Context) -> None:
|
||||
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
||||
assert fetched is not None, "Plan not found after update"
|
||||
assert "env" in fetched.arguments, (
|
||||
f"Expected 'env' in arguments, got {fetched.arguments}"
|
||||
)
|
||||
assert fetched.arguments["env"] == "production"
|
||||
assert fetched.arguments["replicas"] == 3
|
||||
|
||||
|
||||
@then("retrieving the plan should show the new plan invariants")
|
||||
def step_verify_plan_invariants(context: Context) -> None:
|
||||
fetched = context.plan_repo.get(context.plan.identity.plan_id)
|
||||
assert fetched is not None, "Plan not found after update"
|
||||
assert len(fetched.invariants) == 2, (
|
||||
f"Expected 2 invariants, got {len(fetched.invariants)}"
|
||||
)
|
||||
texts = [inv.text for inv in fetched.invariants]
|
||||
assert "Must not break backward compatibility" in texts
|
||||
assert "All integration tests must pass" in texts
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# LifecyclePlanRepository.list_plans filtered by phase (lines 1269-1271)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given('lifecycle plans in different phases linked to "{action_name}"')
|
||||
def step_create_plans_in_phases(context: Context, action_name: str) -> None:
|
||||
context.strategize_plan_ids = []
|
||||
context.execute_plan_ids = []
|
||||
|
||||
for i in range(2):
|
||||
plan = _make_plan(
|
||||
action_name=action_name,
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
ns_name=f"local/strat-plan-{i}",
|
||||
)
|
||||
context.plan_repo.create(plan)
|
||||
context.strategize_plan_ids.append(plan.identity.plan_id)
|
||||
|
||||
for i in range(1):
|
||||
plan = _make_plan(
|
||||
action_name=action_name,
|
||||
phase=PlanPhase.EXECUTE,
|
||||
ns_name=f"local/exec-plan-{i}",
|
||||
)
|
||||
context.plan_repo.create(plan)
|
||||
context.execute_plan_ids.append(plan.identity.plan_id)
|
||||
|
||||
context.db_session.commit()
|
||||
|
||||
|
||||
@when('plans are listed filtered by phase "{phase}"')
|
||||
def step_list_by_phase(context: Context, phase: str) -> None:
|
||||
context.result_plans = context.plan_repo.list_plans(phase=phase)
|
||||
|
||||
|
||||
@then("only the strategize-phase plans should be returned")
|
||||
def step_verify_phase_filter(context: Context) -> None:
|
||||
assert len(context.result_plans) == 2, (
|
||||
f"Expected 2 strategize plans, got {len(context.result_plans)}"
|
||||
)
|
||||
for plan in context.result_plans:
|
||||
assert plan.phase == PlanPhase.STRATEGIZE, (
|
||||
f"Expected STRATEGIZE phase, got {plan.phase}"
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# ResourceTypeRepository (lines 1126-1135)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given("a resource type repository using the session factory")
|
||||
def step_resource_type_repo(context: Context) -> None:
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
context.resource_type_repo = ResourceTypeRepository(
|
||||
session_factory=context.db_session_factory,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given('a valid resource type domain object named "{name}"')
|
||||
def step_make_resource_type(context: Context, name: str) -> None:
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
context.resource_type = ResourceTypeSpec(
|
||||
name=name,
|
||||
description="Test resource type",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy.COPY_ON_WRITE,
|
||||
user_addable=True,
|
||||
cli_args=[],
|
||||
parent_types=[],
|
||||
child_types=[],
|
||||
auto_discovery=None,
|
||||
equivalence=None,
|
||||
handler=None,
|
||||
capabilities={
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
built_in=False,
|
||||
)
|
||||
|
||||
|
||||
@given("the resource type has been persisted in the database")
|
||||
def step_persist_resource_type(context: Context) -> None:
|
||||
context.resource_type_repo.create(context.resource_type)
|
||||
context.db_session.commit()
|
||||
|
||||
|
||||
@when("the resource type is created through the repository")
|
||||
def step_create_resource_type(context: Context) -> None:
|
||||
try:
|
||||
context.resource_type_repo.create(context.resource_type)
|
||||
context.db_session.commit()
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then('the resource type should be retrievable by name "{name}"')
|
||||
def step_verify_resource_type_by_name(context: Context, name: str) -> None:
|
||||
fetched = context.resource_type_repo.get(name)
|
||||
assert fetched is not None, f"Resource type '{name}' not found"
|
||||
assert fetched.name == name
|
||||
|
||||
|
||||
@when("the same resource type is created again")
|
||||
def step_create_duplicate_resource_type(context: Context) -> None:
|
||||
try:
|
||||
context.resource_type_repo.create(context.resource_type)
|
||||
context.db_session.commit()
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then("a DuplicateResourceTypeError should be raised")
|
||||
def step_verify_dup_resource_type(context: Context) -> None:
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
DuplicateResourceTypeError,
|
||||
)
|
||||
|
||||
assert context.error is not None, "Expected DuplicateResourceTypeError"
|
||||
assert isinstance(context.error, DuplicateResourceTypeError), (
|
||||
f"Expected DuplicateResourceTypeError, "
|
||||
f"got {type(context.error).__name__}: {context.error}"
|
||||
)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# ResourceRepository (lines 1346-1422)
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@given("a resource repository using the session factory")
|
||||
def step_resource_repo(context: Context) -> None:
|
||||
from cleveragents.infrastructure.database.repositories import ResourceRepository
|
||||
|
||||
context.resource_repo = ResourceRepository(
|
||||
session_factory=context.db_session_factory,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given('a valid resource domain object of type "{type_name}"')
|
||||
def step_make_resource(context: Context, type_name: str) -> None:
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
|
||||
resource_id = _next_ulid()
|
||||
context.resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=f"local/test-resource-{resource_id[-6:].lower()}",
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="A test resource",
|
||||
properties={"key": "value"},
|
||||
location="/tmp/test-location",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True,
|
||||
writable=True,
|
||||
sandboxable=True,
|
||||
checkpointable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@when("the resource is created through the repository")
|
||||
def step_create_resource(context: Context) -> None:
|
||||
try:
|
||||
context.resource_repo.create(context.resource)
|
||||
context.db_session.commit()
|
||||
except Exception as exc:
|
||||
context.error = exc
|
||||
|
||||
|
||||
@then("the resource should be retrievable by its ID")
|
||||
def step_verify_resource_by_id(context: Context) -> None:
|
||||
assert context.error is None, f"Unexpected error: {context.error}"
|
||||
fetched = context.resource_repo.get(context.resource.resource_id)
|
||||
assert fetched is not None, "Resource not found by ID"
|
||||
assert fetched.resource_id == context.resource.resource_id
|
||||
|
||||
|
||||
@then("the resource should be retrievable by its namespaced name")
|
||||
def step_verify_resource_by_name(context: Context) -> None:
|
||||
fetched = context.resource_repo.get_by_name(context.resource.name)
|
||||
assert fetched is not None, "Resource not found by namespaced name"
|
||||
assert fetched.name == context.resource.name
|
||||
|
||||
|
||||
@given('multiple resources of type "{type_name}" have been created')
|
||||
def step_create_multiple_resources(context: Context, type_name: str) -> None:
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
|
||||
context.created_resource_ids = []
|
||||
for i in range(3):
|
||||
resource_id = _next_ulid()
|
||||
resource = Resource(
|
||||
resource_id=resource_id,
|
||||
name=f"local/multi-res-{resource_id[-6:].lower()}",
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description=f"Test resource {i}",
|
||||
properties={},
|
||||
location=f"/tmp/test-{i}",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True,
|
||||
writable=True,
|
||||
sandboxable=True,
|
||||
checkpointable=False,
|
||||
),
|
||||
)
|
||||
context.resource_repo.create(resource)
|
||||
context.created_resource_ids.append(resource_id)
|
||||
context.db_session.commit()
|
||||
|
||||
|
||||
@when('resources are listed by type "{type_name}"')
|
||||
def step_list_resources_by_type(context: Context, type_name: str) -> None:
|
||||
context.result_resources = context.resource_repo.list_resources(
|
||||
type_name=type_name,
|
||||
)
|
||||
|
||||
|
||||
@then("all resources of that type should be returned")
|
||||
def step_verify_resources_listed(context: Context) -> None:
|
||||
assert len(context.result_resources) == 3, (
|
||||
f"Expected 3 resources, got {len(context.result_resources)}"
|
||||
)
|
||||
returned_ids = {r.resource_id for r in context.result_resources}
|
||||
for rid in context.created_resource_ids:
|
||||
assert rid in returned_ids, f"Resource {rid} not in result list"
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Step definitions for resource_repository.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeArgument,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
Base,
|
||||
ResourceEdgeModel,
|
||||
)
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
DuplicateResourceError,
|
||||
DuplicateResourceTypeError,
|
||||
ResourceHasEdgesError,
|
||||
ResourceNotFoundRepoError,
|
||||
ResourceRepository,
|
||||
ResourceTypeHasResourcesError,
|
||||
ResourceTypeNotFoundError,
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
|
||||
# Crockford base32 alphabet for generating ULIDs
|
||||
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
_ULID_CTR = 0
|
||||
|
||||
|
||||
def _next_ulid() -> str:
|
||||
"""Return a unique, valid ULID string for each call."""
|
||||
global _ULID_CTR
|
||||
_ULID_CTR += 1
|
||||
n = _ULID_CTR
|
||||
suffix = ""
|
||||
for _ in range(8):
|
||||
suffix = _CB32[n % 32] + suffix
|
||||
n //= 32
|
||||
return f"01HGZ6FE0AQDYTR4BX{suffix}"
|
||||
|
||||
|
||||
def _make_resource_type_spec(
|
||||
name: str = "myorg/test-type",
|
||||
user_addable: bool = True,
|
||||
) -> ResourceTypeSpec:
|
||||
"""Create a minimal valid ResourceTypeSpec."""
|
||||
return ResourceTypeSpec(
|
||||
name=name,
|
||||
description="Test resource type",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy.GIT_WORKTREE,
|
||||
user_addable=user_addable,
|
||||
cli_args=[],
|
||||
parent_types=[],
|
||||
child_types=[],
|
||||
auto_discovery=None,
|
||||
equivalence=None,
|
||||
handler=None,
|
||||
capabilities={
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
built_in=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_resource(
|
||||
name: str | None = "myorg/test-resource",
|
||||
type_name: str = "myorg/test-type",
|
||||
resource_id: str | None = None,
|
||||
) -> Resource:
|
||||
"""Create a minimal valid Resource domain object."""
|
||||
return Resource(
|
||||
resource_id=resource_id or _next_ulid(),
|
||||
name=name,
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description="Test resource",
|
||||
properties={},
|
||||
location="/tmp/test",
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True,
|
||||
writable=True,
|
||||
sandboxable=True,
|
||||
checkpointable=False,
|
||||
),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
updated_at=datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
# ── Background ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a clean resource repository database")
|
||||
def step_clean_resource_repo_db(context: Context) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_fk_pragma(dbapi_conn: object, _rec: object) -> None:
|
||||
cursor = dbapi_conn.cursor() # type: ignore[union-attr]
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
context.repo_session_factory = factory
|
||||
context.repo_engine = engine
|
||||
context.repo_error = None
|
||||
|
||||
|
||||
@given("a resource type repository backed by the database")
|
||||
def step_resource_type_repo(context: Context) -> None:
|
||||
context.rt_repo = ResourceTypeRepository(context.repo_session_factory)
|
||||
|
||||
|
||||
@given("a resource repository backed by the database")
|
||||
def step_resource_repo(context: Context) -> None:
|
||||
context.res_repo = ResourceRepository(context.repo_session_factory)
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: Create ────────────────────────────
|
||||
|
||||
|
||||
@given('a valid resource type spec named "{name}"')
|
||||
def step_valid_rt_spec(context: Context, name: str) -> None:
|
||||
context.rt_spec = _make_resource_type_spec(name=name)
|
||||
|
||||
|
||||
@given("the resource type has already been saved once")
|
||||
def step_rt_saved_once(context: Context) -> None:
|
||||
context.rt_repo.create(context.rt_spec)
|
||||
|
||||
|
||||
@when("the resource type is saved through the repository")
|
||||
def step_save_rt(context: Context) -> None:
|
||||
try:
|
||||
context.rt_repo.create(context.rt_spec)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@when('a second resource type with the same name "{name}" is saved')
|
||||
def step_save_dup_rt(context: Context, name: str) -> None:
|
||||
try:
|
||||
dup = _make_resource_type_spec(name=name)
|
||||
context.rt_repo.create(dup)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@then("the resource type repository should not raise an error")
|
||||
def step_rt_no_error(context: Context) -> None:
|
||||
assert context.repo_error is None, f"Unexpected error: {context.repo_error}"
|
||||
|
||||
|
||||
@then('the persisted resource type should have name "{name}"')
|
||||
def step_rt_persisted_name(context: Context, name: str) -> None:
|
||||
result = context.rt_repo.get(name)
|
||||
assert result is not None, f"Resource type '{name}' not found"
|
||||
assert result.name == name
|
||||
|
||||
|
||||
@then('a DuplicateResourceTypeError should be raised mentioning "{name}"')
|
||||
def step_dup_rt_error(context: Context, name: str) -> None:
|
||||
assert isinstance(context.repo_error, DuplicateResourceTypeError), (
|
||||
f"Expected DuplicateResourceTypeError, got {type(context.repo_error)}"
|
||||
)
|
||||
assert name in str(context.repo_error)
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: Read ──────────────────────────────
|
||||
|
||||
|
||||
@given("the resource type has been saved through the repository")
|
||||
def step_rt_saved(context: Context) -> None:
|
||||
context.rt_repo.create(context.rt_spec)
|
||||
|
||||
|
||||
@when('the resource type is looked up by name "{name}"')
|
||||
def step_lookup_rt_by_name(context: Context, name: str) -> None:
|
||||
context.rt_result = context.rt_repo.get(name)
|
||||
|
||||
|
||||
@then('the returned resource type should have name "{name}"')
|
||||
def step_rt_has_name(context: Context, name: str) -> None:
|
||||
assert context.rt_result is not None, "Expected a resource type, got None"
|
||||
assert context.rt_result.name == name
|
||||
|
||||
|
||||
@then("no resource type should be returned")
|
||||
def step_no_rt_returned(context: Context) -> None:
|
||||
assert context.rt_result is None, f"Expected None, got {context.rt_result}"
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: List ──────────────────────────────
|
||||
|
||||
|
||||
@given('resource types "{name1}" and "{name2}" have been saved')
|
||||
def step_two_rts_saved(context: Context, name1: str, name2: str) -> None:
|
||||
context.rt_repo.create(_make_resource_type_spec(name=name1))
|
||||
context.rt_repo.create(_make_resource_type_spec(name=name2))
|
||||
|
||||
|
||||
@when("all resource types are listed")
|
||||
def step_list_all_rts(context: Context) -> None:
|
||||
context.rt_list = context.rt_repo.list_types()
|
||||
|
||||
|
||||
@when('resource types are listed with namespace "{namespace}"')
|
||||
def step_list_rts_by_namespace(context: Context, namespace: str) -> None:
|
||||
context.rt_list = context.rt_repo.list_types(namespace=namespace)
|
||||
|
||||
|
||||
@given('a resource type "{name}" with user_addable {flag} is saved')
|
||||
def step_rt_with_addable_flag(context: Context, name: str, flag: str) -> None:
|
||||
spec = _make_resource_type_spec(name=name, user_addable=flag.lower() == "true")
|
||||
context.rt_repo.create(spec)
|
||||
|
||||
|
||||
@when("resource types are listed with user_addable {flag}")
|
||||
def step_list_rts_by_addable(context: Context, flag: str) -> None:
|
||||
context.rt_list = context.rt_repo.list_types(user_addable=flag.lower() == "true")
|
||||
|
||||
|
||||
@then("the resource type list should have {count:d} entries")
|
||||
def step_rt_list_count(context: Context, count: int) -> None:
|
||||
assert len(context.rt_list) == count, (
|
||||
f"Expected {count} entries, got {len(context.rt_list)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the first resource type in the list should be "{name}"')
|
||||
def step_rt_list_first(context: Context, name: str) -> None:
|
||||
assert context.rt_list[0].name == name, (
|
||||
f"Expected first entry '{name}', got '{context.rt_list[0].name}'"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: Update ────────────────────────────
|
||||
|
||||
|
||||
@when('the resource type description is updated to "{desc}"')
|
||||
def step_update_rt_desc(context: Context, desc: str) -> None:
|
||||
updated = ResourceTypeSpec(
|
||||
name=context.rt_spec.name,
|
||||
description=desc,
|
||||
resource_kind=context.rt_spec.resource_kind,
|
||||
sandbox_strategy=context.rt_spec.sandbox_strategy,
|
||||
user_addable=context.rt_spec.user_addable,
|
||||
cli_args=context.rt_spec.cli_args,
|
||||
parent_types=context.rt_spec.parent_types,
|
||||
child_types=context.rt_spec.child_types,
|
||||
auto_discovery=context.rt_spec.auto_discovery,
|
||||
equivalence=context.rt_spec.equivalence,
|
||||
handler=context.rt_spec.handler,
|
||||
capabilities=context.rt_spec.capabilities,
|
||||
built_in=context.rt_spec.built_in,
|
||||
)
|
||||
context.rt_repo.update(updated)
|
||||
|
||||
|
||||
@then('the resource type should be retrievable with description "{desc}"')
|
||||
def step_rt_has_desc(context: Context, desc: str) -> None:
|
||||
result = context.rt_repo.get(context.rt_spec.name)
|
||||
assert result is not None
|
||||
assert result.description == desc, (
|
||||
f"Expected description '{desc}', got '{result.description}'"
|
||||
)
|
||||
|
||||
|
||||
@when('a non-existent resource type "{name}" is updated')
|
||||
def step_update_nonexistent_rt(context: Context, name: str) -> None:
|
||||
try:
|
||||
fake = _make_resource_type_spec(name=name)
|
||||
context.rt_repo.update(fake)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@then("a ResourceTypeNotFoundError should be raised")
|
||||
def step_rt_not_found_error(context: Context) -> None:
|
||||
assert isinstance(context.repo_error, ResourceTypeNotFoundError), (
|
||||
f"Expected ResourceTypeNotFoundError, got {type(context.repo_error)}"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: Delete ────────────────────────────
|
||||
|
||||
|
||||
@when('the resource type "{name}" is deleted')
|
||||
def step_delete_rt(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.delete_result = context.rt_repo.delete(name)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
context.delete_result = None
|
||||
|
||||
|
||||
@then("the resource type deletion should return true")
|
||||
def step_rt_delete_true(context: Context) -> None:
|
||||
assert context.delete_result is True, f"Expected True, got {context.delete_result}"
|
||||
|
||||
|
||||
@then("the resource type deletion should return false")
|
||||
def step_rt_delete_false(context: Context) -> None:
|
||||
assert context.delete_result is False, (
|
||||
f"Expected False, got {context.delete_result}"
|
||||
)
|
||||
|
||||
|
||||
@then('the resource type "{name}" should no longer exist')
|
||||
def step_rt_no_longer_exists(context: Context, name: str) -> None:
|
||||
result = context.rt_repo.get(name)
|
||||
assert result is None, f"Resource type '{name}' still exists"
|
||||
|
||||
|
||||
@given('a resource of type "{type_name}" exists in the database')
|
||||
def step_resource_of_type_exists(context: Context, type_name: str) -> None:
|
||||
res = _make_resource(name=None, type_name=type_name)
|
||||
context.res_repo.create(res)
|
||||
|
||||
|
||||
@then("a ResourceTypeHasResourcesError should be raised")
|
||||
def step_rt_has_resources_error(context: Context) -> None:
|
||||
assert isinstance(context.repo_error, ResourceTypeHasResourcesError), (
|
||||
f"Expected ResourceTypeHasResourcesError, got {type(context.repo_error)}"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceRepository: Create ────────────────────────────────
|
||||
|
||||
|
||||
@given('a resource type "{name}" exists for resource tests')
|
||||
def step_rt_exists_for_res(context: Context, name: str) -> None:
|
||||
with contextlib.suppress(DuplicateResourceTypeError):
|
||||
context.rt_repo.create(_make_resource_type_spec(name=name))
|
||||
|
||||
|
||||
@given('a valid resource domain object with name "{name}"')
|
||||
def step_valid_res_obj(context: Context, name: str) -> None:
|
||||
type_name = "myorg/res-type"
|
||||
if hasattr(context, "res_type_name"):
|
||||
type_name = context.res_type_name
|
||||
context.res_obj = _make_resource(name=name, type_name=type_name)
|
||||
context.res_type_name = type_name
|
||||
|
||||
|
||||
@given('a valid resource domain object with type "{type_name}"')
|
||||
def step_valid_res_obj_with_type(context: Context, type_name: str) -> None:
|
||||
context.res_obj = _make_resource(name="myorg/orphan-res", type_name=type_name)
|
||||
|
||||
|
||||
@when("the resource is saved through the repository")
|
||||
def step_save_res(context: Context) -> None:
|
||||
try:
|
||||
context.res_repo.create(context.res_obj)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@then("the resource repository should not raise an error")
|
||||
def step_res_no_error(context: Context) -> None:
|
||||
assert context.repo_error is None, f"Unexpected error: {context.repo_error}"
|
||||
|
||||
|
||||
@then('the persisted resource should have name "{name}"')
|
||||
def step_res_persisted_name(context: Context, name: str) -> None:
|
||||
result = context.res_repo.get_by_name(name)
|
||||
assert result is not None, f"Resource '{name}' not found"
|
||||
assert result.name == name
|
||||
|
||||
|
||||
@then('a ResourceTypeNotFoundError should be raised for type "{type_name}"')
|
||||
def step_rt_not_found_for_type(context: Context, type_name: str) -> None:
|
||||
assert isinstance(context.repo_error, ResourceTypeNotFoundError), (
|
||||
f"Expected ResourceTypeNotFoundError, got {type(context.repo_error)}"
|
||||
)
|
||||
assert type_name in str(context.repo_error)
|
||||
|
||||
|
||||
@given('a resource with name "{name}" has been saved')
|
||||
def step_res_named_saved(context: Context, name: str) -> None:
|
||||
# Determine the type name from the most recent resource type created
|
||||
type_name = "myorg/res-type"
|
||||
if hasattr(context, "res_type_name"):
|
||||
type_name = context.res_type_name
|
||||
else:
|
||||
# Try to guess from existing resource types
|
||||
for attr_name in ("rt_spec",):
|
||||
if hasattr(context, attr_name):
|
||||
type_name = getattr(context, attr_name).name
|
||||
break
|
||||
|
||||
# Use type name from context if set up by previous step
|
||||
rt_list = context.rt_repo.list_types()
|
||||
if rt_list:
|
||||
type_name = rt_list[-1].name
|
||||
|
||||
res = _make_resource(name=name, type_name=type_name)
|
||||
context.res_repo.create(res)
|
||||
context.last_saved_resource = res
|
||||
context.res_type_name = type_name
|
||||
|
||||
|
||||
@when('a second resource with name "{name}" is saved')
|
||||
def step_save_dup_res(context: Context, name: str) -> None:
|
||||
try:
|
||||
dup = _make_resource(name=name, type_name=context.res_type_name)
|
||||
context.res_repo.create(dup)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@then('a DuplicateResourceError should be raised mentioning "{name}"')
|
||||
def step_dup_res_error(context: Context, name: str) -> None:
|
||||
assert isinstance(context.repo_error, DuplicateResourceError), (
|
||||
f"Expected DuplicateResourceError, got {type(context.repo_error)}"
|
||||
)
|
||||
assert name in str(context.repo_error)
|
||||
|
||||
|
||||
# ── ResourceRepository: Read ──────────────────────────────────
|
||||
|
||||
|
||||
@given("a resource with known ULID has been saved")
|
||||
def step_res_known_ulid_saved(context: Context) -> None:
|
||||
rt_list = context.rt_repo.list_types()
|
||||
type_name = rt_list[-1].name if rt_list else "myorg/get-type"
|
||||
ulid = _next_ulid()
|
||||
res = _make_resource(
|
||||
name=f"myorg/ulid-res-{ulid[-6:]}",
|
||||
type_name=type_name,
|
||||
resource_id=ulid,
|
||||
)
|
||||
context.res_repo.create(res)
|
||||
context.known_ulid = ulid
|
||||
context.last_saved_resource = res
|
||||
|
||||
|
||||
@when("the resource is looked up by its ULID")
|
||||
def step_lookup_res_by_ulid(context: Context) -> None:
|
||||
context.res_result = context.res_repo.get(context.known_ulid)
|
||||
|
||||
|
||||
@then("the returned resource should have the correct ULID")
|
||||
def step_res_has_ulid(context: Context) -> None:
|
||||
assert context.res_result is not None, "Expected a resource, got None"
|
||||
assert context.res_result.resource_id == context.known_ulid
|
||||
|
||||
|
||||
@when('the resource is looked up by name "{name}"')
|
||||
def step_lookup_res_by_name(context: Context, name: str) -> None:
|
||||
context.res_result = context.res_repo.get_by_name(name)
|
||||
|
||||
|
||||
@then('the returned resource should have name "{name}"')
|
||||
def step_res_has_name(context: Context, name: str) -> None:
|
||||
assert context.res_result is not None, "Expected a resource, got None"
|
||||
assert context.res_result.name == name
|
||||
|
||||
|
||||
@when('the resource is looked up by ULID "{ulid}"')
|
||||
def step_lookup_res_by_specific_ulid(context: Context, ulid: str) -> None:
|
||||
context.res_result = context.res_repo.get(ulid)
|
||||
|
||||
|
||||
@then("no resource should be returned")
|
||||
def step_no_res_returned(context: Context) -> None:
|
||||
assert context.res_result is None, f"Expected None, got {context.res_result}"
|
||||
|
||||
|
||||
# ── ResourceRepository: List ──────────────────────────────────
|
||||
|
||||
|
||||
@given('{count:d} resources of type "{type_name}" have been saved')
|
||||
def step_n_resources_saved(context: Context, count: int, type_name: str) -> None:
|
||||
for i in range(count):
|
||||
res = _make_resource(
|
||||
name=f"myorg/batch-{type_name.split('/')[-1]}-{i}",
|
||||
type_name=type_name,
|
||||
)
|
||||
context.res_repo.create(res)
|
||||
|
||||
|
||||
@when("resources are listed with limit {limit:d} and offset {offset:d}")
|
||||
def step_list_res_paginated(context: Context, limit: int, offset: int) -> None:
|
||||
context.res_list = context.res_repo.list_resources(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@when('resources are listed with type_name "{type_name}"')
|
||||
def step_list_res_by_type(context: Context, type_name: str) -> None:
|
||||
context.res_list = context.res_repo.list_resources(type_name=type_name)
|
||||
|
||||
|
||||
@given('resource types "{name1}" and "{name2}" exist for resource tests')
|
||||
def step_two_rts_for_res(context: Context, name1: str, name2: str) -> None:
|
||||
for n in (name1, name2):
|
||||
with contextlib.suppress(DuplicateResourceTypeError):
|
||||
context.rt_repo.create(_make_resource_type_spec(name=n))
|
||||
|
||||
|
||||
@then("the resource list should have {count:d} entries")
|
||||
def step_res_list_count(context: Context, count: int) -> None:
|
||||
assert len(context.res_list) == count, (
|
||||
f"Expected {count} entries, got {len(context.res_list)}"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceRepository: Update ────────────────────────────────
|
||||
|
||||
|
||||
@when('the resource description is updated to "{desc}"')
|
||||
def step_update_res_desc(context: Context, desc: str) -> None:
|
||||
original = context.last_saved_resource
|
||||
updated = Resource(
|
||||
resource_id=original.resource_id,
|
||||
name=original.name,
|
||||
resource_type_name=original.resource_type_name,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
description=desc,
|
||||
properties=original.properties,
|
||||
location=original.location,
|
||||
content_hash=original.content_hash,
|
||||
sandbox_strategy=original.sandbox_strategy,
|
||||
capabilities=original.capabilities,
|
||||
created_at=original.created_at,
|
||||
updated_at=datetime.now(tz=UTC),
|
||||
)
|
||||
context.res_repo.update(updated)
|
||||
|
||||
|
||||
@then('the resource should be retrievable with description "{desc}"')
|
||||
def step_res_has_desc(context: Context, desc: str) -> None:
|
||||
result = context.res_repo.get(context.last_saved_resource.resource_id)
|
||||
assert result is not None
|
||||
assert result.description == desc, (
|
||||
f"Expected description '{desc}', got '{result.description}'"
|
||||
)
|
||||
|
||||
|
||||
@when('a non-existent resource with ULID "{ulid}" is updated')
|
||||
def step_update_nonexistent_res(context: Context, ulid: str) -> None:
|
||||
try:
|
||||
fake = _make_resource(
|
||||
name="myorg/phantom-res",
|
||||
type_name="myorg/phantom-type",
|
||||
resource_id=ulid,
|
||||
)
|
||||
context.res_repo.update(fake)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
|
||||
|
||||
@then("a ResourceNotFoundRepoError should be raised")
|
||||
def step_res_not_found_error(context: Context) -> None:
|
||||
assert isinstance(context.repo_error, ResourceNotFoundRepoError), (
|
||||
f"Expected ResourceNotFoundRepoError, got {type(context.repo_error)}"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceRepository: Delete ────────────────────────────────
|
||||
|
||||
|
||||
@when("the resource is deleted by its ULID")
|
||||
def step_delete_res_by_ulid(context: Context) -> None:
|
||||
try:
|
||||
ulid = context.last_saved_resource.resource_id
|
||||
context.delete_result = context.res_repo.delete(ulid)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
context.delete_result = None
|
||||
|
||||
|
||||
@then("the resource deletion should return true")
|
||||
def step_res_delete_true(context: Context) -> None:
|
||||
assert context.delete_result is True, f"Expected True, got {context.delete_result}"
|
||||
|
||||
|
||||
@then("the resource deletion should return false")
|
||||
def step_res_delete_false(context: Context) -> None:
|
||||
assert context.delete_result is False, (
|
||||
f"Expected False, got {context.delete_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource should no longer exist")
|
||||
def step_res_no_longer_exists(context: Context) -> None:
|
||||
result = context.res_repo.get(context.last_saved_resource.resource_id)
|
||||
assert result is None, "Resource still exists"
|
||||
|
||||
|
||||
@when('the resource with ULID "{ulid}" is deleted')
|
||||
def step_delete_res_specific_ulid(context: Context, ulid: str) -> None:
|
||||
try:
|
||||
context.delete_result = context.res_repo.delete(ulid)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
context.delete_result = None
|
||||
|
||||
|
||||
@given('a parent and child resource of type "{type_name}" are linked')
|
||||
def step_parent_child_linked(context: Context, type_name: str) -> None:
|
||||
parent = _make_resource(
|
||||
name=f"myorg/parent-{_next_ulid()[-6:]}",
|
||||
type_name=type_name,
|
||||
)
|
||||
child = _make_resource(
|
||||
name=f"myorg/child-{_next_ulid()[-6:]}",
|
||||
type_name=type_name,
|
||||
)
|
||||
context.res_repo.create(parent)
|
||||
context.res_repo.create(child)
|
||||
context.parent_resource = parent
|
||||
context.child_resource = child
|
||||
|
||||
# Insert edge directly via session
|
||||
session: Session = context.repo_session_factory()
|
||||
edge = ResourceEdgeModel(
|
||||
parent_id=parent.resource_id,
|
||||
child_id=child.resource_id,
|
||||
link_type="contains",
|
||||
auto_discovered=False,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
)
|
||||
session.add(edge)
|
||||
session.flush()
|
||||
|
||||
|
||||
@when("the parent resource is deleted")
|
||||
def step_delete_parent_res(context: Context) -> None:
|
||||
try:
|
||||
context.delete_result = context.res_repo.delete(
|
||||
context.parent_resource.resource_id
|
||||
)
|
||||
context.repo_error = None
|
||||
except Exception as exc:
|
||||
context.repo_error = exc
|
||||
context.delete_result = None
|
||||
|
||||
|
||||
@then("a ResourceHasEdgesError should be raised")
|
||||
def step_res_has_edges_error(context: Context) -> None:
|
||||
assert isinstance(context.repo_error, ResourceHasEdgesError), (
|
||||
f"Expected ResourceHasEdgesError, got {type(context.repo_error)}"
|
||||
)
|
||||
|
||||
|
||||
# ── ResourceRepository: resolve_namespaced_name ───────────────
|
||||
|
||||
|
||||
@when('the resource is resolved by name_or_id "{name_or_id}"')
|
||||
def step_resolve_res(context: Context, name_or_id: str) -> None:
|
||||
context.resolved_result = context.res_repo.resolve_namespaced_name(name_or_id)
|
||||
|
||||
|
||||
@then('the resolved resource should have name "{name}"')
|
||||
def step_resolved_has_name(context: Context, name: str) -> None:
|
||||
assert context.resolved_result is not None, "Expected a resource, got None"
|
||||
assert context.resolved_result.name == name
|
||||
|
||||
|
||||
@then("no resolved resource should be returned")
|
||||
def step_no_resolved_res(context: Context) -> None:
|
||||
assert context.resolved_result is None, (
|
||||
f"Expected None, got {context.resolved_result}"
|
||||
)
|
||||
|
||||
|
||||
@given("a resource with known ULID has been saved for resolve test")
|
||||
def step_res_ulid_for_resolve(context: Context) -> None:
|
||||
rt_list = context.rt_repo.list_types()
|
||||
type_name = rt_list[-1].name if rt_list else "myorg/resolve-ulid-type"
|
||||
ulid = _next_ulid()
|
||||
res = _make_resource(
|
||||
name=None,
|
||||
type_name=type_name,
|
||||
resource_id=ulid,
|
||||
)
|
||||
context.res_repo.create(res)
|
||||
context.known_ulid = ulid
|
||||
context.last_saved_resource = res
|
||||
|
||||
|
||||
@when("the resource is resolved by its ULID")
|
||||
def step_resolve_by_ulid(context: Context) -> None:
|
||||
context.resolved_result = context.res_repo.resolve_namespaced_name(
|
||||
context.known_ulid
|
||||
)
|
||||
|
||||
|
||||
@then("the resolved resource should have the correct ULID")
|
||||
def step_resolved_has_ulid(context: Context) -> None:
|
||||
assert context.resolved_result is not None, "Expected a resource, got None"
|
||||
assert context.resolved_result.resource_id == context.known_ulid
|
||||
|
||||
|
||||
# ── ResourceTypeRepository: domain round-trip ─────────────────
|
||||
|
||||
|
||||
@given("a full resource type spec with cli_args and capabilities")
|
||||
def step_full_rt_spec(context: Context) -> None:
|
||||
context.full_rt_spec = ResourceTypeSpec(
|
||||
name="myorg/full-type",
|
||||
description="Full test type with all fields",
|
||||
resource_kind=ResourceKind.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy.COPY_ON_WRITE,
|
||||
user_addable=True,
|
||||
cli_args=[
|
||||
ResourceTypeArgument(
|
||||
name="path",
|
||||
type="path",
|
||||
required=True,
|
||||
description="Path to resource",
|
||||
),
|
||||
ResourceTypeArgument(
|
||||
name="branch",
|
||||
type="string",
|
||||
required=False,
|
||||
description="Branch name",
|
||||
default="main",
|
||||
),
|
||||
],
|
||||
parent_types=["myorg/parent-type"],
|
||||
child_types=["myorg/child-type"],
|
||||
auto_discovery={"strategy": "glob", "pattern": "**/*.py"},
|
||||
equivalence=None,
|
||||
handler="myorg.handlers:FullHandler",
|
||||
capabilities={
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": True,
|
||||
},
|
||||
built_in=False,
|
||||
)
|
||||
|
||||
|
||||
@when("the full resource type is saved and retrieved")
|
||||
def step_save_and_retrieve_full_rt(context: Context) -> None:
|
||||
context.rt_repo.create(context.full_rt_spec)
|
||||
context.restored_rt = context.rt_repo.get(context.full_rt_spec.name)
|
||||
|
||||
|
||||
@then("the restored resource type should match the original fields")
|
||||
def step_rt_fields_match(context: Context) -> None:
|
||||
original = context.full_rt_spec
|
||||
restored = context.restored_rt
|
||||
assert restored is not None, "Restored resource type is None"
|
||||
assert restored.name == original.name
|
||||
assert restored.description == original.description
|
||||
assert restored.resource_kind == original.resource_kind
|
||||
assert restored.sandbox_strategy == original.sandbox_strategy
|
||||
assert restored.user_addable == original.user_addable
|
||||
assert len(restored.cli_args) == len(original.cli_args)
|
||||
assert restored.cli_args[0].name == "path"
|
||||
assert restored.cli_args[0].type == "path"
|
||||
assert restored.cli_args[0].required is True
|
||||
assert restored.cli_args[1].name == "branch"
|
||||
assert restored.cli_args[1].default == "main"
|
||||
assert restored.parent_types == original.parent_types
|
||||
assert restored.child_types == original.child_types
|
||||
assert restored.auto_discovery == original.auto_discovery
|
||||
assert restored.handler == original.handler
|
||||
assert restored.capabilities["checkpoint"] is True
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Step definitions for tool lifecycle coverage boost feature tests.
|
||||
|
||||
Targets uncovered lines and branches in src/cleveragents/tool/lifecycle.py:
|
||||
- Line 253: ToolLifecycleCache.remove returns None when tool not in cache
|
||||
- Line 394: ToolRuntime.activate re-raises ToolCancelledError
|
||||
- Line 473: ToolRuntime.execute raises ToolNotActivatedError for None instance
|
||||
- Lines 496-502: ToolRuntime.execute handles ToolCancelledError with trace
|
||||
- Line 550: ToolRuntime.deactivate early return when instance not in cache
|
||||
- Branch 585:exit: deactivate_plan with empty instances dict
|
||||
- Branch 235:exit: get_plan_tools iterating over empty cache
|
||||
- Branch 248:253: remove when tool not found / plan not found
|
||||
- Branch 250:252: remove pops entry successfully
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.tool import (
|
||||
Tool,
|
||||
ToolCapability,
|
||||
ToolSource,
|
||||
ToolType,
|
||||
)
|
||||
from cleveragents.tool.context import (
|
||||
CancellationToken,
|
||||
ToolCancelledError,
|
||||
ToolExecutionContext,
|
||||
)
|
||||
from cleveragents.tool.lifecycle import (
|
||||
ToolDescriptor,
|
||||
ToolExecutionError,
|
||||
ToolLifecycleCache,
|
||||
ToolNotActivatedError,
|
||||
ToolResult,
|
||||
ToolRuntime,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock tool instances for coverage-boost scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _CBMockToolInstance:
|
||||
"""Basic mock ToolInstance for coverage-boost tests."""
|
||||
|
||||
def __init__(self, name: str = "mock/tool", read_only: bool = True) -> None:
|
||||
self.name = name
|
||||
self.read_only = read_only
|
||||
self.activated = False
|
||||
self.deactivated = False
|
||||
|
||||
def discover(self) -> ToolDescriptor:
|
||||
return ToolDescriptor(
|
||||
name=self.name,
|
||||
description=f"Coverage-boost mock {self.name}",
|
||||
capability=ToolCapability(read_only=self.read_only),
|
||||
)
|
||||
|
||||
def activate(self, ctx: ToolExecutionContext) -> None:
|
||||
self.activated = True
|
||||
|
||||
def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
|
||||
return ToolResult(success=True, data={"result": "ok"})
|
||||
|
||||
def deactivate(self, ctx: ToolExecutionContext) -> None:
|
||||
self.deactivated = True
|
||||
|
||||
|
||||
class _CancellingActivateInstance(_CBMockToolInstance):
|
||||
"""Mock that raises ToolCancelledError on activate()."""
|
||||
|
||||
def activate(self, ctx: ToolExecutionContext) -> None:
|
||||
raise ToolCancelledError("Cancelled during activation")
|
||||
|
||||
|
||||
class _CancellingExecuteInstance(_CBMockToolInstance):
|
||||
"""Mock that raises ToolCancelledError on execute()."""
|
||||
|
||||
def activate(self, ctx: ToolExecutionContext) -> None:
|
||||
self.activated = True
|
||||
|
||||
def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
|
||||
raise ToolCancelledError("Cancelled during execution")
|
||||
|
||||
|
||||
class _NullCacheInstance(_CBMockToolInstance):
|
||||
"""Mock whose activate succeeds but we manipulate the cache afterwards."""
|
||||
|
||||
def activate(self, ctx: ToolExecutionContext) -> None:
|
||||
self.activated = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper to create Tool domain models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cb_make_tool(
|
||||
name: str,
|
||||
*,
|
||||
read_only: bool = True,
|
||||
writes: bool = False,
|
||||
) -> Tool:
|
||||
"""Create a minimal Tool domain model for coverage-boost tests."""
|
||||
return Tool(
|
||||
name=name,
|
||||
description=f"Coverage-boost test tool {name}",
|
||||
source=ToolSource.BUILTIN,
|
||||
tool_type=ToolType.TOOL,
|
||||
capability=ToolCapability(
|
||||
read_only=read_only,
|
||||
writes=writes,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolLifecycleCache steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I create a coverage-boost lifecycle cache")
|
||||
def step_create_cb_cache(context: Context) -> None:
|
||||
context.cb_cache = ToolLifecycleCache()
|
||||
context.cb_remove_result = None
|
||||
|
||||
|
||||
@given('I have a coverage-boost mock tool instance named "{name}"')
|
||||
def step_create_cb_mock(context: Context, name: str) -> None:
|
||||
context.cb_mock_instance = _CBMockToolInstance(name=name)
|
||||
|
||||
|
||||
@given('I have a second coverage-boost mock tool instance named "{name}"')
|
||||
def step_create_cb_mock_second(context: Context, name: str) -> None:
|
||||
context.cb_mock_instance_2 = _CBMockToolInstance(name=name)
|
||||
|
||||
|
||||
@when(
|
||||
'I put the coverage-boost instance in cache for plan "{plan_id}" and tool "{tool_name}"'
|
||||
)
|
||||
def step_cb_cache_put(context: Context, plan_id: str, tool_name: str) -> None:
|
||||
context.cb_cache.put(plan_id, tool_name, context.cb_mock_instance)
|
||||
|
||||
|
||||
@when(
|
||||
'I put the second coverage-boost instance in cache for plan "{plan_id}" and tool "{tool_name}"'
|
||||
)
|
||||
def step_cb_cache_put_second(context: Context, plan_id: str, tool_name: str) -> None:
|
||||
context.cb_cache.put(plan_id, tool_name, context.cb_mock_instance_2)
|
||||
|
||||
|
||||
@when('I remove tool "{tool_name}" from coverage-boost cache plan "{plan_id}"')
|
||||
def step_cb_cache_remove(context: Context, tool_name: str, plan_id: str) -> None:
|
||||
context.cb_remove_result = context.cb_cache.remove(plan_id, tool_name)
|
||||
|
||||
|
||||
@then("the coverage-boost cache should have {count:d} plans")
|
||||
def step_cb_cache_plan_count(context: Context, count: int) -> None:
|
||||
assert context.cb_cache.plan_count == count, (
|
||||
f"Expected {count} plans, got {context.cb_cache.plan_count}"
|
||||
)
|
||||
|
||||
|
||||
@then('the coverage-boost cache internal dict should not contain plan "{plan_id}"')
|
||||
def step_cb_cache_no_plan_key(context: Context, plan_id: str) -> None:
|
||||
# Access internal _cache dict to verify the plan key was fully deleted
|
||||
assert plan_id not in context.cb_cache._cache, (
|
||||
f"Plan '{plan_id}' should have been removed from internal _cache dict"
|
||||
)
|
||||
|
||||
|
||||
@then("the coverage-boost remove result should be None")
|
||||
def step_cb_remove_result_none(context: Context) -> None:
|
||||
assert context.cb_remove_result is None, (
|
||||
f"Expected None, got {context.cb_remove_result}"
|
||||
)
|
||||
|
||||
|
||||
@then('the coverage-boost plan tools for plan "{plan_id}" should be an empty list')
|
||||
def step_cb_plan_tools_empty(context: Context, plan_id: str) -> None:
|
||||
tools = context.cb_cache.get_plan_tools(plan_id)
|
||||
assert tools == [], f"Expected empty list, got {tools}"
|
||||
|
||||
|
||||
@then(
|
||||
'the coverage-boost cache get for plan "{plan_id}" and tool "{tool_name}" should not be None'
|
||||
)
|
||||
def step_cb_cache_get_not_none(context: Context, plan_id: str, tool_name: str) -> None:
|
||||
result = context.cb_cache.get(plan_id, tool_name)
|
||||
assert result is not None, f"Expected non-None for plan={plan_id} tool={tool_name}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime steps — coverage-boost context and runtime setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I create a coverage-boost tool runtime")
|
||||
def step_create_cb_runtime(context: Context) -> None:
|
||||
context.cb_runtime = ToolRuntime()
|
||||
context.cb_mocks: dict[str, _CBMockToolInstance] = {}
|
||||
context.cb_error = None
|
||||
|
||||
|
||||
@given('I create a coverage-boost execution context with plan_id "{plan_id}"')
|
||||
def step_create_cb_ctx(context: Context, plan_id: str) -> None:
|
||||
token = CancellationToken()
|
||||
context.cb_ctx = ToolExecutionContext(
|
||||
plan_id=plan_id,
|
||||
cancellation_token=token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime.activate — ToolCancelledError re-raise (line 394)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('I register a coverage-boost cancelling-activate tool "{name}"')
|
||||
def step_register_cb_cancel_activate(context: Context, name: str) -> None:
|
||||
tool = _cb_make_tool(name, read_only=True)
|
||||
mock = _CancellingActivateInstance(name=name)
|
||||
context.cb_runtime.register_tool(tool, mock)
|
||||
context.cb_mocks[name] = mock
|
||||
|
||||
|
||||
@when('I try to activate the cancelling tool "{name}" in coverage-boost runtime')
|
||||
def step_try_cb_activate_cancel(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.cb_runtime.activate(name, context.cb_ctx)
|
||||
context.cb_error = None
|
||||
except ToolCancelledError as exc:
|
||||
context.cb_error = exc
|
||||
except Exception as exc:
|
||||
context.cb_error = exc
|
||||
|
||||
|
||||
@then("a ToolCancelledError should have been raised in coverage-boost activation")
|
||||
def step_check_cb_activation_cancelled(context: Context) -> None:
|
||||
assert isinstance(context.cb_error, ToolCancelledError), (
|
||||
f"Expected ToolCancelledError, got {type(context.cb_error).__name__}: {context.cb_error}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime.execute — None cached instance (line 473)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('I register a coverage-boost tool "{name}" with a mock that nullifies cache')
|
||||
def step_register_cb_null_cache(context: Context, name: str) -> None:
|
||||
tool = _cb_make_tool(name, read_only=True)
|
||||
mock = _NullCacheInstance(name=name)
|
||||
context.cb_runtime.register_tool(tool, mock)
|
||||
context.cb_mocks[name] = mock
|
||||
|
||||
|
||||
@when('I try to execute the null-cache tool "{name}" in coverage-boost runtime')
|
||||
def step_try_cb_execute_null_cache(context: Context, name: str) -> None:
|
||||
# We need activate to succeed (so it passes the auto-activate gate)
|
||||
# but cache.get to return None right after.
|
||||
# Strategy: patch the cache's get method to return None after activate
|
||||
# has put the instance into the cache.
|
||||
original_get = context.cb_runtime._cache.get
|
||||
|
||||
def _patched_get(plan_id: str, tool_name: str) -> Any:
|
||||
# First call is in activate() to check if already cached — return None
|
||||
# so activate proceeds. After activate puts it, the second call is in
|
||||
# execute() at line 471 — we remove it from cache first so it returns None.
|
||||
result = original_get(plan_id, tool_name)
|
||||
if result is not None:
|
||||
# Remove it so the next get (line 471) returns None
|
||||
context.cb_runtime._cache.remove(plan_id, tool_name)
|
||||
return None
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(context.cb_runtime._cache, "get", side_effect=_patched_get):
|
||||
context.cb_runtime.execute(name, {}, context.cb_ctx)
|
||||
context.cb_error = None
|
||||
except ToolNotActivatedError as exc:
|
||||
context.cb_error = exc
|
||||
except Exception as exc:
|
||||
context.cb_error = exc
|
||||
|
||||
|
||||
@then("a ToolNotActivatedError should have been raised in coverage-boost execution")
|
||||
def step_check_cb_not_activated(context: Context) -> None:
|
||||
# ToolNotActivatedError is raised at line 473 but caught by the generic
|
||||
# except Exception handler at line 504 and wrapped in ToolExecutionError.
|
||||
is_direct = isinstance(context.cb_error, ToolNotActivatedError)
|
||||
is_wrapped = (
|
||||
isinstance(context.cb_error, ToolExecutionError)
|
||||
and "not activated" in str(context.cb_error).lower()
|
||||
)
|
||||
assert is_direct or is_wrapped, (
|
||||
f"Expected ToolNotActivatedError (or ToolExecutionError wrapping it), "
|
||||
f"got {type(context.cb_error).__name__}: {context.cb_error}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime.execute — ToolCancelledError during execution (lines 496-502)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('I register a coverage-boost cancelling-execute tool "{name}"')
|
||||
def step_register_cb_cancel_execute(context: Context, name: str) -> None:
|
||||
tool = _cb_make_tool(name, read_only=True)
|
||||
mock = _CancellingExecuteInstance(name=name)
|
||||
context.cb_runtime.register_tool(tool, mock)
|
||||
context.cb_mocks[name] = mock
|
||||
|
||||
|
||||
@when('I try to execute the cancelling-execute tool "{name}" in coverage-boost runtime')
|
||||
def step_try_cb_execute_cancel(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.cb_runtime.execute(name, {}, context.cb_ctx)
|
||||
context.cb_error = None
|
||||
except ToolCancelledError as exc:
|
||||
context.cb_error = exc
|
||||
except Exception as exc:
|
||||
context.cb_error = exc
|
||||
|
||||
|
||||
@then("a ToolCancelledError should have been raised in coverage-boost execution")
|
||||
def step_check_cb_execution_cancelled(context: Context) -> None:
|
||||
assert isinstance(context.cb_error, ToolCancelledError), (
|
||||
f"Expected ToolCancelledError, got {type(context.cb_error).__name__}: {context.cb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the coverage-boost context should have {count:d} traces")
|
||||
def step_check_cb_ctx_traces(context: Context, count: int) -> None:
|
||||
actual = len(context.cb_ctx.traces)
|
||||
assert actual == count, f"Expected {count} traces, got {actual}"
|
||||
|
||||
|
||||
@then("the coverage-boost last trace should show success {expected}")
|
||||
def step_check_cb_last_trace_success(context: Context, expected: str) -> None:
|
||||
traces = context.cb_ctx.traces
|
||||
assert len(traces) > 0, "No traces recorded"
|
||||
last = traces[-1]
|
||||
expected_bool = expected == "True"
|
||||
assert last.success == expected_bool, (
|
||||
f"Expected trace success={expected_bool}, got {last.success}"
|
||||
)
|
||||
|
||||
|
||||
@then('the coverage-boost last trace error should be "{expected}"')
|
||||
def step_check_cb_last_trace_error(context: Context, expected: str) -> None:
|
||||
traces = context.cb_ctx.traces
|
||||
assert len(traces) > 0, "No traces recorded"
|
||||
last = traces[-1]
|
||||
assert last.error == expected, (
|
||||
f"Expected trace error='{expected}', got '{last.error}'"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime.deactivate — tool not in cache (line 550)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('I register a coverage-boost read-only tool "{name}" with a mock')
|
||||
def step_register_cb_readonly(context: Context, name: str) -> None:
|
||||
tool = _cb_make_tool(name, read_only=True)
|
||||
mock = _CBMockToolInstance(name=name, read_only=True)
|
||||
context.cb_runtime.register_tool(tool, mock)
|
||||
context.cb_mocks[name] = mock
|
||||
|
||||
|
||||
@when('I deactivate tool "{name}" in the coverage-boost runtime')
|
||||
def step_cb_deactivate_tool(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.cb_runtime.deactivate(name, context.cb_ctx)
|
||||
context.cb_error = None
|
||||
except Exception as exc:
|
||||
context.cb_error = exc
|
||||
|
||||
|
||||
@then("no coverage-boost exception should have been raised")
|
||||
def step_check_cb_no_exception(context: Context) -> None:
|
||||
assert context.cb_error is None, (
|
||||
f"Expected no exception, got {type(context.cb_error).__name__}: {context.cb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('the coverage-boost mock "{name}" should not have been deactivated')
|
||||
def step_check_cb_mock_not_deactivated(context: Context, name: str) -> None:
|
||||
mock = context.cb_mocks[name]
|
||||
assert not mock.deactivated, f"Mock '{name}' should NOT have been deactivated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolRuntime.deactivate_plan — empty plan (branch 585:exit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I deactivate plan in the coverage-boost runtime")
|
||||
def step_cb_deactivate_plan(context: Context) -> None:
|
||||
try:
|
||||
context.cb_runtime.deactivate_plan(context.cb_ctx)
|
||||
context.cb_error = None
|
||||
except Exception as exc:
|
||||
context.cb_error = exc
|
||||
@@ -0,0 +1,88 @@
|
||||
Feature: Tool lifecycle coverage boost
|
||||
As the tool execution engine
|
||||
I need to cover edge-case branches in ToolLifecycleCache and ToolRuntime
|
||||
Including empty-cache iteration, last-tool removal, cancellation re-raise,
|
||||
None-instance execution, and no-op deactivation paths
|
||||
|
||||
# ── ToolLifecycleCache.remove deletes plan entry when last tool removed ──
|
||||
|
||||
Scenario: Removing the last tool from a plan deletes the plan entry
|
||||
Given I create a coverage-boost lifecycle cache
|
||||
And I have a coverage-boost mock tool instance named "builtin/only-tool"
|
||||
When I put the coverage-boost instance in cache for plan "plan-rm-1" and tool "builtin/only-tool"
|
||||
Then the coverage-boost cache should have 1 plans
|
||||
When I remove tool "builtin/only-tool" from coverage-boost cache plan "plan-rm-1"
|
||||
Then the coverage-boost cache should have 0 plans
|
||||
And the coverage-boost cache internal dict should not contain plan "plan-rm-1"
|
||||
|
||||
Scenario: Removing a tool that does not exist in cache returns None
|
||||
Given I create a coverage-boost lifecycle cache
|
||||
When I remove tool "builtin/ghost" from coverage-boost cache plan "plan-rm-2"
|
||||
Then the coverage-boost remove result should be None
|
||||
|
||||
# ── ToolLifecycleCache.get_plan_tools with empty cache ──────────────────
|
||||
|
||||
Scenario: Getting plan tools for a plan with no activated tools returns empty list
|
||||
Given I create a coverage-boost lifecycle cache
|
||||
Then the coverage-boost plan tools for plan "plan-empty" should be an empty list
|
||||
|
||||
# ── ToolRuntime.activate with ToolCancelledError ────────────────────────
|
||||
|
||||
Scenario: Activating a tool that raises ToolCancelledError propagates the error
|
||||
Given I create a coverage-boost tool runtime
|
||||
And I register a coverage-boost cancelling-activate tool "builtin/cancel-act"
|
||||
And I create a coverage-boost execution context with plan_id "plan-cancel-act"
|
||||
When I try to activate the cancelling tool "builtin/cancel-act" in coverage-boost runtime
|
||||
Then a ToolCancelledError should have been raised in coverage-boost activation
|
||||
|
||||
# ── ToolRuntime.execute with None cached instance ──────────────────────
|
||||
|
||||
Scenario: Executing a tool when cached instance is None raises ToolNotActivatedError
|
||||
Given I create a coverage-boost tool runtime
|
||||
And I register a coverage-boost tool "builtin/null-cache" with a mock that nullifies cache
|
||||
And I create a coverage-boost execution context with plan_id "plan-null-cache"
|
||||
When I try to execute the null-cache tool "builtin/null-cache" in coverage-boost runtime
|
||||
Then a ToolNotActivatedError should have been raised in coverage-boost execution
|
||||
|
||||
# ── ToolRuntime.execute with ToolCancelledError during execution ────────
|
||||
|
||||
Scenario: Executing a tool that raises ToolCancelledError records trace and re-raises
|
||||
Given I create a coverage-boost tool runtime
|
||||
And I register a coverage-boost cancelling-execute tool "builtin/cancel-exec"
|
||||
And I create a coverage-boost execution context with plan_id "plan-cancel-exec"
|
||||
When I try to execute the cancelling-execute tool "builtin/cancel-exec" in coverage-boost runtime
|
||||
Then a ToolCancelledError should have been raised in coverage-boost execution
|
||||
And the coverage-boost context should have 1 traces
|
||||
And the coverage-boost last trace should show success False
|
||||
And the coverage-boost last trace error should be "Cancelled"
|
||||
|
||||
# ── ToolRuntime.deactivate with tool not in cache ──────────────────────
|
||||
|
||||
Scenario: Deactivating a tool that was never activated is a silent no-op
|
||||
Given I create a coverage-boost tool runtime
|
||||
And I register a coverage-boost read-only tool "builtin/never-active" with a mock
|
||||
And I create a coverage-boost execution context with plan_id "plan-deact-none"
|
||||
When I deactivate tool "builtin/never-active" in the coverage-boost runtime
|
||||
Then no coverage-boost exception should have been raised
|
||||
And the coverage-boost mock "builtin/never-active" should not have been deactivated
|
||||
|
||||
# ── ToolRuntime.deactivate_plan with empty plan ────────────────────────
|
||||
|
||||
Scenario: Deactivating a plan with no activated tools is a silent no-op
|
||||
Given I create a coverage-boost tool runtime
|
||||
And I create a coverage-boost execution context with plan_id "plan-deact-empty"
|
||||
When I deactivate plan in the coverage-boost runtime
|
||||
Then no coverage-boost exception should have been raised
|
||||
|
||||
# ── ToolLifecycleCache.remove with multiple tools leaves remaining ─────
|
||||
|
||||
Scenario: Removing one of two tools keeps the plan entry with the remaining tool
|
||||
Given I create a coverage-boost lifecycle cache
|
||||
And I have a coverage-boost mock tool instance named "builtin/tool-a"
|
||||
And I have a second coverage-boost mock tool instance named "builtin/tool-b"
|
||||
When I put the coverage-boost instance in cache for plan "plan-rm-multi" and tool "builtin/tool-a"
|
||||
And I put the second coverage-boost instance in cache for plan "plan-rm-multi" and tool "builtin/tool-b"
|
||||
Then the coverage-boost cache should have 1 plans
|
||||
When I remove tool "builtin/tool-a" from coverage-boost cache plan "plan-rm-multi"
|
||||
Then the coverage-boost cache should have 1 plans
|
||||
And the coverage-boost cache get for plan "plan-rm-multi" and tool "builtin/tool-b" should not be None
|
||||
+19
-19
@@ -2179,25 +2179,25 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
- [ ] Git [Brent]: `git branch -d feature/m1-resource-db-robot-tests`
|
||||
- [ ] Quality [Brent]: 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%.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: B0.repo.resources | Branch: feature/m1-resource-repos | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(repo): add resource repositories"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m1-resource-repos`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Implement `ResourceTypeRepository` CRUD with list filters (namespace, user_addable) and ordered output.
|
||||
- [ ] Code [Jeff]: Implement `ResourceRepository` CRUD with name/ULID resolution and type validation on create.
|
||||
- [ ] Code [Jeff]: Add repository helpers for `resolve_namespaced_name` and `resolve_ulid` with clear NotFound errors.
|
||||
- [ ] Docs [Jeff]: Document repository interfaces in `docs/reference/repositories.md` (resource types + resources).
|
||||
- [ ] Tests (Behave) [Jeff]: Add repository scenarios for create/get/list and invalid type rejection.
|
||||
- [ ] Tests (Robot) [Jeff]: Add Robot test that creates a resource and fetches it by name and ULID.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/resource_repository_bench.py` for list/query performance.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [ ] Git [Jeff]: `git add .`
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(repo): add resource repositories"`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-resource-repos` to `master` with description "Add resource repositories with name/ULID resolution, docs, and tests.".
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git branch -d feature/m1-resource-repos`
|
||||
- [ ] 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] **COMMIT (Owner: Jeff | Group: B0.repo.resources | Branch: feature/m1-resource-repos | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(repo): add resource repositories"** Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git pull origin master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m1-resource-repos` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Implement `ResourceTypeRepository` CRUD with list filters (namespace, user_addable) and ordered output. Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Implement `ResourceRepository` CRUD with name/ULID resolution and type validation on create. Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Add repository helpers for `resolve_namespaced_name` and `resolve_ulid` with clear NotFound errors. Done: Day 7, February 15, 2026
|
||||
- [X] Docs [Jeff]: Document repository interfaces in `docs/reference/repositories.md` (resource types + resources). Done: Day 7, February 15, 2026
|
||||
- [X] Tests (Behave) [Jeff]: Add repository scenarios for create/get/list and invalid type rejection. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (Robot) [Jeff]: Add Robot test that creates a resource and fetches it by name and ULID. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/resource_repository_bench.py` for list/query performance. Done: Day 7, February 15, 2026
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes. Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git add .` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git commit -m "feat(repo): add resource repositories"` Done: Day 7, February 15, 2026
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-resource-repos` to `master` with description "Add resource repositories with name/ULID resolution, docs, and tests.". Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git branch -d feature/m1-resource-repos` Done: Day 7, February 15, 2026
|
||||
- [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%. Done: Day 7, February 15, 2026
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: B0.repo.projects | Branch: feature/m1-project-repos | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(repo): add project repositories"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
*** Settings ***
|
||||
Documentation Resource Repository CRUD Integration Test
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
|
||||
*** Variables ***
|
||||
${PYTHON} python
|
||||
|
||||
*** Test Cases ***
|
||||
ResourceTypeRepository Create And Get
|
||||
[Documentation] Create a resource type and retrieve it by name
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from sqlalchemy import create_engine, event
|
||||
... from sqlalchemy.orm import sessionmaker
|
||||
... from cleveragents.infrastructure.database.models import Base, ResourceTypeModel
|
||||
... from cleveragents.infrastructure.database.repositories import ResourceTypeRepository
|
||||
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind, SandboxStrategy
|
||||
... engine = create_engine("sqlite:///:memory:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine)
|
||||
... repo = ResourceTypeRepository(factory)
|
||||
... spec = ResourceTypeSpec(name="robot/test-type", description="Robot test", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.GIT_WORKTREE, user_addable=True, cli_args=[], parent_types=[], child_types=[], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False)
|
||||
... repo.create(spec)
|
||||
... result = repo.get("robot/test-type")
|
||||
... assert result is not None, "Resource type not found"
|
||||
... assert result.name == "robot/test-type", f"Name mismatch: {result.name}"
|
||||
... assert result.description == "Robot test"
|
||||
... print("ResourceTypeRepository create+get passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 ResourceType repo test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} ResourceTypeRepository create+get passed
|
||||
|
||||
ResourceRepository Create And Resolve
|
||||
[Documentation] Create a resource and resolve it by name and ULID
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... from datetime import datetime, UTC
|
||||
... from sqlalchemy import create_engine, event
|
||||
... from sqlalchemy.orm import sessionmaker
|
||||
... from cleveragents.infrastructure.database.models import Base
|
||||
... from cleveragents.infrastructure.database.repositories import ResourceTypeRepository, ResourceRepository
|
||||
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind, SandboxStrategy
|
||||
... from cleveragents.domain.models.core.resource import Resource, PhysVirt, ResourceCapabilities
|
||||
... engine = create_engine("sqlite:///:memory:")
|
||||
... @event.listens_for(engine, "connect")
|
||||
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
|
||||
... Base.metadata.create_all(engine)
|
||||
... factory = sessionmaker(bind=engine)
|
||||
... rt_repo = ResourceTypeRepository(factory)
|
||||
... res_repo = ResourceRepository(factory)
|
||||
... spec = ResourceTypeSpec(name="robot/res-type", description="Type", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=[], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False)
|
||||
... rt_repo.create(spec)
|
||||
... res = Resource(resource_id="01HGZ6FE0AQDYTR4BX00000001", name="robot/test-res", resource_type_name="robot/res-type", classification=PhysVirt.PHYSICAL, description="Test", properties={}, location="/tmp/test", content_hash=None, sandbox_strategy=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC))
|
||||
... res_repo.create(res)
|
||||
... by_name = res_repo.resolve_namespaced_name("robot/test-res")
|
||||
... assert by_name is not None, "Resolve by name failed"
|
||||
... by_ulid = res_repo.resolve_namespaced_name("01HGZ6FE0AQDYTR4BX00000001")
|
||||
... assert by_ulid is not None, "Resolve by ULID failed"
|
||||
... assert by_name.resource_id == by_ulid.resource_id
|
||||
... print("ResourceRepository create+resolve passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Resource repo test failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} ResourceRepository create+resolve passed
|
||||
|
||||
*** Keywords ***
|
||||
@@ -31,10 +31,18 @@ from .repositories import (
|
||||
ContextRepository,
|
||||
DuplicateActionError,
|
||||
DuplicatePlanError,
|
||||
DuplicateResourceError,
|
||||
DuplicateResourceTypeError,
|
||||
LifecyclePlanRepository,
|
||||
PlanNotFoundError,
|
||||
PlanRepository,
|
||||
ProjectRepository,
|
||||
ResourceHasEdgesError,
|
||||
ResourceNotFoundRepoError,
|
||||
ResourceRepository,
|
||||
ResourceTypeHasResourcesError,
|
||||
ResourceTypeNotFoundError,
|
||||
ResourceTypeRepository,
|
||||
)
|
||||
from .unit_of_work import UnitOfWork, UnitOfWorkContext
|
||||
|
||||
@@ -50,6 +58,8 @@ __all__ = [
|
||||
"ContextRepository",
|
||||
"DuplicateActionError",
|
||||
"DuplicatePlanError",
|
||||
"DuplicateResourceError",
|
||||
"DuplicateResourceTypeError",
|
||||
"LifecycleActionModel",
|
||||
"LifecyclePlanModel",
|
||||
"LifecyclePlanRepository",
|
||||
@@ -64,8 +74,14 @@ __all__ = [
|
||||
"ProjectRepository",
|
||||
"ProjectResourceLinkModel",
|
||||
"ResourceEdgeModel",
|
||||
"ResourceHasEdgesError",
|
||||
"ResourceModel",
|
||||
"ResourceNotFoundRepoError",
|
||||
"ResourceRepository",
|
||||
"ResourceTypeHasResourcesError",
|
||||
"ResourceTypeModel",
|
||||
"ResourceTypeNotFoundError",
|
||||
"ResourceTypeRepository",
|
||||
"UnitOfWork",
|
||||
"UnitOfWorkContext",
|
||||
"get_session",
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -42,6 +42,9 @@ from cleveragents.infrastructure.database.models import (
|
||||
PlanModel,
|
||||
PlanProjectModel,
|
||||
ProjectModel,
|
||||
ResourceEdgeModel,
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
|
||||
|
||||
@@ -1420,3 +1423,760 @@ class LifecyclePlanRepository:
|
||||
return cast(int, query.count())
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to count plans: {exc}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource Registry Repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResourceTypeNotFoundError(DatabaseError):
|
||||
"""Raised when a resource type is not found."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
super().__init__(f"Resource type '{name}' not found")
|
||||
self.type_name = name
|
||||
|
||||
|
||||
class ResourceNotFoundRepoError(DatabaseError):
|
||||
"""Raised when a resource is not found by ID or name."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__(f"Resource '{identifier}' not found")
|
||||
self.identifier = identifier
|
||||
|
||||
|
||||
class ResourceHasEdgesError(BusinessRuleViolation):
|
||||
"""Raised when deleting a resource that still has DAG edges."""
|
||||
|
||||
def __init__(self, resource_id: str, edge_count: int):
|
||||
super().__init__(
|
||||
f"Cannot delete resource {resource_id}: "
|
||||
f"still has {edge_count} edge(s) in the resource DAG"
|
||||
)
|
||||
self.resource_id = resource_id
|
||||
self.edge_count = edge_count
|
||||
|
||||
|
||||
class ResourceTypeHasResourcesError(BusinessRuleViolation):
|
||||
"""Raised when deleting a resource type that still has resources."""
|
||||
|
||||
def __init__(self, type_name: str, resource_count: int):
|
||||
super().__init__(
|
||||
f"Cannot delete resource type '{type_name}': "
|
||||
f"still referenced by {resource_count} resource(s)"
|
||||
)
|
||||
self.type_name = type_name
|
||||
self.resource_count = resource_count
|
||||
|
||||
|
||||
class DuplicateResourceTypeError(DatabaseError):
|
||||
"""Raised when creating a resource type with a name that already exists."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
super().__init__(f"Resource type with name '{name}' already exists")
|
||||
self.type_name = name
|
||||
|
||||
|
||||
class DuplicateResourceError(DatabaseError):
|
||||
"""Raised when creating a resource with a name that already exists."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
super().__init__(f"Resource with name '{name}' already exists")
|
||||
self.resource_name = name
|
||||
|
||||
|
||||
class ResourceTypeRepository:
|
||||
"""Repository for resource type persistence.
|
||||
|
||||
Uses a session-factory pattern: each public method obtains its own
|
||||
session from the factory, ensuring proper session lifecycle management.
|
||||
|
||||
All mutating methods flush (but do NOT commit); the caller or a
|
||||
``UnitOfWork`` wrapper is responsible for committing the transaction.
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session."""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""Convenience helper to obtain a session."""
|
||||
return self._session_factory()
|
||||
|
||||
@database_retry
|
||||
def create(self, resource_type: Any) -> Any:
|
||||
"""Persist a new resource type from a domain object.
|
||||
|
||||
Args:
|
||||
resource_type: A ``ResourceTypeSpec`` domain model instance.
|
||||
|
||||
Returns:
|
||||
The same resource type after persistence.
|
||||
|
||||
Raises:
|
||||
DuplicateResourceTypeError: If a type with the same name exists.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
# Check for existing type to prevent retry-induced duplicates
|
||||
existing = (
|
||||
session.query(ResourceTypeModel)
|
||||
.filter_by(name=resource_type.name)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
raise DuplicateResourceTypeError(resource_type.name)
|
||||
|
||||
now_iso = datetime.now(tz=UTC).isoformat()
|
||||
db_model = ResourceTypeModel(
|
||||
name=resource_type.name,
|
||||
namespace=resource_type.name.split("/")[0]
|
||||
if "/" in resource_type.name
|
||||
else "builtin",
|
||||
description=resource_type.description or None,
|
||||
resource_kind=resource_type.resource_kind.value
|
||||
if hasattr(resource_type.resource_kind, "value")
|
||||
else resource_type.resource_kind,
|
||||
sandbox_strategy=resource_type.sandbox_strategy.value
|
||||
if hasattr(resource_type.sandbox_strategy, "value")
|
||||
else resource_type.sandbox_strategy,
|
||||
user_addable=resource_type.user_addable,
|
||||
handler_ref=resource_type.handler,
|
||||
args_schema_json=json.dumps(
|
||||
[
|
||||
{
|
||||
"name": arg.name,
|
||||
"type": arg.type,
|
||||
"required": arg.required,
|
||||
"description": arg.description,
|
||||
"default": arg.default,
|
||||
"validation_pattern": arg.validation_pattern,
|
||||
}
|
||||
for arg in resource_type.cli_args
|
||||
]
|
||||
)
|
||||
if resource_type.cli_args
|
||||
else None,
|
||||
allowed_parent_types_json=json.dumps(resource_type.parent_types)
|
||||
if resource_type.parent_types
|
||||
else None,
|
||||
allowed_child_types_json=json.dumps(resource_type.child_types)
|
||||
if resource_type.child_types
|
||||
else None,
|
||||
auto_discover_json=json.dumps(resource_type.auto_discovery)
|
||||
if resource_type.auto_discovery
|
||||
else None,
|
||||
capabilities_json=json.dumps(resource_type.capabilities)
|
||||
if resource_type.capabilities
|
||||
else None,
|
||||
equivalence_json=json.dumps(resource_type.equivalence)
|
||||
if resource_type.equivalence
|
||||
else None,
|
||||
source=getattr(resource_type, "source", None),
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
)
|
||||
session.add(db_model)
|
||||
session.flush()
|
||||
return resource_type
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
|
||||
raise DuplicateResourceTypeError(resource_type.name) from exc
|
||||
raise DatabaseError(f"Failed to create resource type: {exc}") from exc
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(f"Failed to create resource type: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def get(self, name: str) -> Any | None:
|
||||
"""Get a resource type by its name (PK).
|
||||
|
||||
Returns:
|
||||
A ``ResourceTypeSpec`` domain object, or ``None`` if not found.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = session.query(ResourceTypeModel).filter_by(name=name).first()
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_domain(row)
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to get resource type '{name}': {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def list_types(
|
||||
self,
|
||||
namespace: str | None = None,
|
||||
user_addable: bool | None = None,
|
||||
) -> list[Any]:
|
||||
"""List resource types with optional filters.
|
||||
|
||||
Args:
|
||||
namespace: Optional namespace to filter by.
|
||||
user_addable: Optional boolean to filter user-addable types.
|
||||
|
||||
Returns:
|
||||
List of ``ResourceTypeSpec`` domain objects, ordered by name.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
query = session.query(ResourceTypeModel)
|
||||
if namespace is not None:
|
||||
query = query.filter_by(namespace=namespace)
|
||||
if user_addable is not None:
|
||||
query = query.filter_by(user_addable=user_addable)
|
||||
rows = query.order_by(ResourceTypeModel.name).all()
|
||||
return [self._to_domain(row) for row in rows]
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to list resource types: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def update(self, resource_type: Any) -> Any:
|
||||
"""Update mutable fields of an existing resource type.
|
||||
|
||||
Args:
|
||||
resource_type: The ``ResourceTypeSpec`` domain model with updated fields.
|
||||
|
||||
Returns:
|
||||
The same resource type after persistence.
|
||||
|
||||
Raises:
|
||||
ResourceTypeNotFoundError: If the resource type is not found.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(ResourceTypeModel)
|
||||
.filter_by(name=resource_type.name)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise ResourceTypeNotFoundError(resource_type.name)
|
||||
|
||||
row.description = resource_type.description or None # type: ignore[assignment]
|
||||
row.resource_kind = ( # type: ignore[assignment]
|
||||
resource_type.resource_kind.value
|
||||
if hasattr(resource_type.resource_kind, "value")
|
||||
else resource_type.resource_kind
|
||||
)
|
||||
row.sandbox_strategy = ( # type: ignore[assignment]
|
||||
resource_type.sandbox_strategy.value
|
||||
if hasattr(resource_type.sandbox_strategy, "value")
|
||||
else resource_type.sandbox_strategy
|
||||
)
|
||||
row.user_addable = resource_type.user_addable # type: ignore[assignment]
|
||||
row.handler_ref = resource_type.handler # type: ignore[assignment]
|
||||
row.args_schema_json = ( # type: ignore[assignment]
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": arg.name,
|
||||
"type": arg.type,
|
||||
"required": arg.required,
|
||||
"description": arg.description,
|
||||
"default": arg.default,
|
||||
"validation_pattern": arg.validation_pattern,
|
||||
}
|
||||
for arg in resource_type.cli_args
|
||||
]
|
||||
)
|
||||
if resource_type.cli_args
|
||||
else None
|
||||
)
|
||||
row.allowed_parent_types_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource_type.parent_types)
|
||||
if resource_type.parent_types
|
||||
else None
|
||||
)
|
||||
row.allowed_child_types_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource_type.child_types)
|
||||
if resource_type.child_types
|
||||
else None
|
||||
)
|
||||
row.auto_discover_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource_type.auto_discovery)
|
||||
if resource_type.auto_discovery
|
||||
else None
|
||||
)
|
||||
row.capabilities_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource_type.capabilities)
|
||||
if resource_type.capabilities
|
||||
else None
|
||||
)
|
||||
row.equivalence_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource_type.equivalence)
|
||||
if resource_type.equivalence
|
||||
else None
|
||||
)
|
||||
row.updated_at = datetime.now(tz=UTC).isoformat() # type: ignore[assignment]
|
||||
session.flush()
|
||||
return resource_type
|
||||
except ResourceTypeNotFoundError:
|
||||
raise
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to update resource type '{resource_type.name}': {exc}"
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def delete(self, name: str) -> bool:
|
||||
"""Delete a resource type by name.
|
||||
|
||||
Before deletion, verifies no resources reference this type.
|
||||
|
||||
Args:
|
||||
name: The resource type name to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if the resource type was deleted, ``False`` if not found.
|
||||
|
||||
Raises:
|
||||
ResourceTypeHasResourcesError: If resources still reference this type.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
resource_count: int = (
|
||||
session.query(ResourceModel)
|
||||
.filter(ResourceModel.type_name == name)
|
||||
.count()
|
||||
)
|
||||
if resource_count > 0:
|
||||
raise ResourceTypeHasResourcesError(name, resource_count)
|
||||
|
||||
row = session.query(ResourceTypeModel).filter_by(name=name).first()
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return True
|
||||
except ResourceTypeHasResourcesError:
|
||||
raise
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to delete resource type '{name}': {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(row: ResourceTypeModel) -> Any:
|
||||
"""Convert a ``ResourceTypeModel`` row to a ``ResourceTypeSpec``."""
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeArgument,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
cli_args: list[ResourceTypeArgument] = []
|
||||
raw_args = cast("str | None", row.args_schema_json)
|
||||
if raw_args:
|
||||
for arg_data in json.loads(raw_args):
|
||||
cli_args.append(
|
||||
ResourceTypeArgument(
|
||||
name=arg_data["name"],
|
||||
type=arg_data.get("type", "string"),
|
||||
required=arg_data.get("required", True),
|
||||
description=arg_data.get("description", ""),
|
||||
default=arg_data.get("default"),
|
||||
validation_pattern=arg_data.get("validation_pattern"),
|
||||
)
|
||||
)
|
||||
|
||||
parent_types: list[str] = json.loads(
|
||||
cast(str, row.allowed_parent_types_json or "[]")
|
||||
)
|
||||
child_types: list[str] = json.loads(
|
||||
cast(str, row.allowed_child_types_json or "[]")
|
||||
)
|
||||
auto_discovery: dict[str, Any] | None = None
|
||||
if row.auto_discover_json is not None:
|
||||
auto_discovery = json.loads(cast(str, row.auto_discover_json))
|
||||
equivalence: dict[str, Any] | None = None
|
||||
if row.equivalence_json is not None:
|
||||
equivalence = json.loads(cast(str, row.equivalence_json))
|
||||
capabilities: dict[str, bool] = json.loads(
|
||||
cast(
|
||||
str,
|
||||
row.capabilities_json
|
||||
or '{"read": true, "write": true, "sandbox": true,'
|
||||
' "checkpoint": false}',
|
||||
)
|
||||
)
|
||||
|
||||
name_str = cast(str, row.name)
|
||||
is_builtin = "/" not in name_str
|
||||
|
||||
return ResourceTypeSpec(
|
||||
name=name_str,
|
||||
description=cast(str, row.description or ""),
|
||||
resource_kind=ResourceKind(cast(str, row.resource_kind)),
|
||||
sandbox_strategy=SandboxStrategy(cast(str, row.sandbox_strategy)),
|
||||
user_addable=cast(bool, row.user_addable),
|
||||
cli_args=cli_args,
|
||||
parent_types=parent_types,
|
||||
child_types=child_types,
|
||||
auto_discovery=auto_discovery,
|
||||
equivalence=equivalence,
|
||||
handler=cast("str | None", row.handler_ref),
|
||||
capabilities=capabilities,
|
||||
built_in=is_builtin,
|
||||
)
|
||||
|
||||
|
||||
class ResourceRepository:
|
||||
"""Repository for resource persistence.
|
||||
|
||||
Uses a session-factory pattern: each public method obtains its own
|
||||
session from the factory, ensuring proper session lifecycle management.
|
||||
|
||||
All mutating methods flush (but do NOT commit); the caller or a
|
||||
``UnitOfWork`` wrapper is responsible for committing the transaction.
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""Initialise with a callable that returns a new SQLAlchemy Session."""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""Convenience helper to obtain a session."""
|
||||
return self._session_factory()
|
||||
|
||||
@database_retry
|
||||
def create(self, resource: Any) -> Any:
|
||||
"""Persist a new resource.
|
||||
|
||||
Args:
|
||||
resource: A ``Resource`` domain model instance.
|
||||
|
||||
Returns:
|
||||
The same resource after persistence.
|
||||
|
||||
Raises:
|
||||
DuplicateResourceError: If a resource with the same name exists.
|
||||
ResourceTypeNotFoundError: If the referenced type doesn't exist.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
# Validate that the referenced type exists
|
||||
type_row = (
|
||||
session.query(ResourceTypeModel)
|
||||
.filter_by(name=resource.resource_type_name)
|
||||
.first()
|
||||
)
|
||||
if type_row is None:
|
||||
raise ResourceTypeNotFoundError(resource.resource_type_name)
|
||||
|
||||
# Check for existing resource with the same name
|
||||
if resource.name is not None:
|
||||
existing = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(namespaced_name=resource.name)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
raise DuplicateResourceError(resource.name)
|
||||
|
||||
now_iso = datetime.now(tz=UTC).isoformat()
|
||||
db_model = ResourceModel(
|
||||
resource_id=resource.resource_id,
|
||||
namespaced_name=resource.name,
|
||||
namespace=resource.name.split("/")[0]
|
||||
if resource.name and "/" in resource.name
|
||||
else None,
|
||||
type_name=resource.resource_type_name,
|
||||
resource_kind=resource.classification,
|
||||
location=resource.location,
|
||||
description=resource.description,
|
||||
read_only=not resource.capabilities.writable
|
||||
if hasattr(resource, "capabilities")
|
||||
else False,
|
||||
auto_discovered=False,
|
||||
sandbox_strategy=resource.sandbox_strategy,
|
||||
content_hash=resource.content_hash,
|
||||
properties_json=json.dumps(resource.properties)
|
||||
if resource.properties
|
||||
else None,
|
||||
metadata_json=None,
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
)
|
||||
session.add(db_model)
|
||||
session.flush()
|
||||
return resource
|
||||
except (ResourceTypeNotFoundError, DuplicateResourceError):
|
||||
raise
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
|
||||
raise DuplicateResourceError(
|
||||
resource.name or resource.resource_id
|
||||
) from exc
|
||||
raise DatabaseError(f"Failed to create resource: {exc}") from exc
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(f"Failed to create resource: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def get(self, resource_id: str) -> Any | None:
|
||||
"""Get a resource by its ULID primary key.
|
||||
|
||||
Returns:
|
||||
A ``Resource`` domain object, or ``None`` if not found.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(ResourceModel).filter_by(resource_id=resource_id).first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_domain(row)
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to get resource '{resource_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def get_by_name(self, namespaced_name: str) -> Any | None:
|
||||
"""Get a resource by its namespaced name.
|
||||
|
||||
Returns:
|
||||
A ``Resource`` domain object, or ``None`` if not found.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(namespaced_name=namespaced_name)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_domain(row)
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to get resource by name '{namespaced_name}': {exc}"
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def list_resources(
|
||||
self,
|
||||
type_name: str | None = None,
|
||||
namespace: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Any]:
|
||||
"""List resources with optional filters and pagination.
|
||||
|
||||
Args:
|
||||
type_name: Optional resource type name filter.
|
||||
namespace: Optional namespace filter.
|
||||
limit: Maximum number of results (default 100).
|
||||
offset: Number of results to skip (default 0).
|
||||
|
||||
Returns:
|
||||
List of ``Resource`` domain objects.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
query = session.query(ResourceModel)
|
||||
if type_name is not None:
|
||||
query = query.filter_by(type_name=type_name)
|
||||
if namespace is not None:
|
||||
query = query.filter_by(namespace=namespace)
|
||||
rows = (
|
||||
query.order_by(ResourceModel.created_at)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(row) for row in rows]
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(f"Failed to list resources: {exc}") from exc
|
||||
|
||||
@database_retry
|
||||
def update(self, resource: Any) -> Any:
|
||||
"""Update metadata of an existing resource.
|
||||
|
||||
Args:
|
||||
resource: The ``Resource`` domain model with updated fields.
|
||||
|
||||
Returns:
|
||||
The same resource after persistence.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundRepoError: If the resource is not found.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(resource_id=resource.resource_id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
raise ResourceNotFoundRepoError(resource.resource_id)
|
||||
|
||||
row.namespaced_name = resource.name # type: ignore[assignment]
|
||||
row.namespace = ( # type: ignore[assignment]
|
||||
resource.name.split("/")[0]
|
||||
if resource.name and "/" in resource.name
|
||||
else None
|
||||
)
|
||||
row.description = resource.description # type: ignore[assignment]
|
||||
row.location = resource.location # type: ignore[assignment]
|
||||
row.read_only = ( # type: ignore[assignment]
|
||||
not resource.capabilities.writable
|
||||
if hasattr(resource, "capabilities")
|
||||
else False
|
||||
)
|
||||
row.sandbox_strategy = resource.sandbox_strategy # type: ignore[assignment]
|
||||
row.content_hash = resource.content_hash # type: ignore[assignment]
|
||||
row.properties_json = ( # type: ignore[assignment]
|
||||
json.dumps(resource.properties) if resource.properties else None
|
||||
)
|
||||
row.updated_at = datetime.now(tz=UTC).isoformat() # type: ignore[assignment]
|
||||
session.flush()
|
||||
return resource
|
||||
except ResourceNotFoundRepoError:
|
||||
raise
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to update resource '{resource.resource_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def delete(self, resource_id: str) -> bool:
|
||||
"""Delete a resource by its ULID.
|
||||
|
||||
Before deletion, verifies no edges reference this resource.
|
||||
|
||||
Args:
|
||||
resource_id: The ULID of the resource to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if the resource was deleted, ``False`` if not found.
|
||||
|
||||
Raises:
|
||||
ResourceHasEdgesError: If edges still reference this resource.
|
||||
DatabaseError: On transient or unexpected DB errors.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
edge_count: int = (
|
||||
session.query(ResourceEdgeModel)
|
||||
.filter(
|
||||
(ResourceEdgeModel.parent_id == resource_id)
|
||||
| (ResourceEdgeModel.child_id == resource_id)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if edge_count > 0:
|
||||
raise ResourceHasEdgesError(resource_id, edge_count)
|
||||
|
||||
row = (
|
||||
session.query(ResourceModel).filter_by(resource_id=resource_id).first()
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return True
|
||||
except ResourceHasEdgesError:
|
||||
raise
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
session.rollback()
|
||||
raise DatabaseError(
|
||||
f"Failed to delete resource '{resource_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
@database_retry
|
||||
def resolve_namespaced_name(self, name_or_id: str) -> Any | None:
|
||||
"""Resolve a resource by namespaced name first, then try ULID.
|
||||
|
||||
This helper tries name lookup first (most common case),
|
||||
falling back to ULID-based lookup.
|
||||
|
||||
Args:
|
||||
name_or_id: Either a namespaced name or a ULID string.
|
||||
|
||||
Returns:
|
||||
A ``Resource`` domain object, or ``None`` if not found.
|
||||
"""
|
||||
import re
|
||||
|
||||
session = self._session()
|
||||
try:
|
||||
# Try by name first
|
||||
row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(namespaced_name=name_or_id)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return self._to_domain(row)
|
||||
|
||||
# Try by ULID (26 chars, Crockford base32)
|
||||
ulid_pattern = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
if ulid_pattern.match(name_or_id):
|
||||
row = (
|
||||
session.query(ResourceModel)
|
||||
.filter_by(resource_id=name_or_id)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return self._to_domain(row)
|
||||
|
||||
return None
|
||||
except (OperationalError, SQLAlchemyDatabaseError) as exc:
|
||||
raise DatabaseError(
|
||||
f"Failed to resolve resource '{name_or_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(row: ResourceModel) -> Any:
|
||||
"""Convert a ``ResourceModel`` row to a ``Resource`` domain object."""
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
properties: dict[str, str | int | float | bool | None] = {}
|
||||
raw_props = cast("str | None", row.properties_json)
|
||||
if raw_props:
|
||||
properties = json.loads(raw_props)
|
||||
|
||||
sandbox_strat: SandboxStrategy | None = None
|
||||
if row.sandbox_strategy is not None:
|
||||
sandbox_strat = SandboxStrategy(cast(str, row.sandbox_strategy))
|
||||
|
||||
return Resource(
|
||||
resource_id=cast(str, row.resource_id),
|
||||
name=cast("str | None", row.namespaced_name),
|
||||
resource_type_name=cast(str, row.type_name),
|
||||
classification=PhysVirt(cast(str, row.resource_kind)),
|
||||
description=cast("str | None", row.description),
|
||||
properties=properties,
|
||||
location=cast("str | None", row.location),
|
||||
content_hash=cast("str | None", row.content_hash),
|
||||
sandbox_strategy=sandbox_strat,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True,
|
||||
writable=not cast(bool, row.read_only),
|
||||
sandboxable=True,
|
||||
checkpointable=False,
|
||||
),
|
||||
created_at=datetime.fromisoformat(cast(str, row.created_at)),
|
||||
updated_at=datetime.fromisoformat(cast(str, row.updated_at)),
|
||||
)
|
||||
|
||||
@@ -37,3 +37,15 @@ ResourceTypeArgSchema # noqa: B018, F821
|
||||
_action_spec_dict # noqa: B018, F821
|
||||
_plan_spec_dict # noqa: B018, F821
|
||||
_FORMAT_HELP # noqa: B018, F821
|
||||
|
||||
# Resource repository error classes — public API for service layer
|
||||
ResourceTypeNotFoundError # noqa: B018, F821
|
||||
ResourceNotFoundRepoError # noqa: B018, F821
|
||||
ResourceHasEdgesError # noqa: B018, F821
|
||||
ResourceTypeHasResourcesError # noqa: B018, F821
|
||||
DuplicateResourceTypeError # noqa: B018, F821
|
||||
DuplicateResourceError # noqa: B018, F821
|
||||
|
||||
# Resource repository classes — public API
|
||||
ResourceTypeRepository # noqa: B018, F821
|
||||
ResourceRepository # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user