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

This commit was merged in pull request #69.
This commit is contained in:
Jeffrey Phillips Freeman
2026-02-15 09:18:11 +00:00
committed by Forgejo
parent c5617b0457
commit 36c571db83
28 changed files with 6818 additions and 25 deletions
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import re
from pathlib import Path
PYPROJECT_PATH = Path("pyproject.toml")
NOXFILE_PATH = Path("noxfile.py")
-1
View File
@@ -22,7 +22,6 @@ from cleveragents.domain.models.core.plan import (
can_transition,
)
# Valid ULID constants for benchmarks
_ULID_A = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
_ULID_B = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
+212
View File
@@ -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)
-1
View File
@@ -13,7 +13,6 @@ import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
try:
from cleveragents.tool.builtins import (
-1
View File
@@ -37,7 +37,6 @@ except ModuleNotFoundError:
ToolCapability,
ToolLifecycle,
ToolSource,
ToolType,
Validation,
ValidationMode,
)
+1 -1
View File
@@ -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]:
+70
View File
@@ -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
+165
View File
@@ -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
+256
View File
@@ -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"
+792
View File
@@ -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
View File
@@ -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`
+66
View File
@@ -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)),
)
+12
View File
@@ -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