Merge branch 'master' into develop-20260217

This commit is contained in:
2026-02-18 03:27:00 +00:00
parent 629f286bd3
commit 24866b2244
110 changed files with 18163 additions and 738 deletions
@@ -0,0 +1,28 @@
"""Merge resource_links and automation_profiles branches
Revision ID: 71cd40eb661f
Revises: a6_001_automation_profiles, b1_001_resource_links
Create Date: 2026-02-17 15:14:18.061795
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "71cd40eb661f"
down_revision: str | Sequence[str] | None = (
"a6_001_automation_profiles",
"b1_001_resource_links",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
@@ -0,0 +1,139 @@
"""Add automation_profiles table.
This migration creates the ``automation_profiles`` table for persisting
custom automation profiles. Built-in profiles are resolved in-memory
and are **not** stored in this table.
Revision ID: a6_001_automation_profiles
Revises: c1_001_tool_registry
Create Date: 2026-02-17 14:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a6_001_automation_profiles"
down_revision: str | Sequence[str] | None = "c1_001_tool_registry"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create automation_profiles table."""
op.create_table(
"automation_profiles",
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"schema_version",
sa.Text(),
nullable=False,
server_default="1.0",
),
sa.Column(
"auto_strategize",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_execute",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_apply",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_decisions_strategize",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_decisions_execute",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_validation_fix",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_strategy_revision",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_reversion_from_apply",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_child_plans",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_retry_transient",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"auto_checkpoint_restore",
sa.Float(),
nullable=False,
server_default=sa.text("0.0"),
),
sa.Column(
"require_sandbox",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column(
"require_checkpoints",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
sa.Column(
"allow_unsafe_tools",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column("created_at", sa.Text(), nullable=False),
sa.Column("updated_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("name"),
)
op.create_index(
"ix_automation_profiles_name",
"automation_profiles",
["name"],
unique=True,
)
def downgrade() -> None:
"""Drop automation_profiles table."""
op.drop_index(
"ix_automation_profiles_name",
table_name="automation_profiles",
)
op.drop_table("automation_profiles")
+87
View File
@@ -0,0 +1,87 @@
"""Add resource_links table for validated DAG parent-child links.
This migration creates the ``resource_links`` table that stores
validated parent-child relationships between resources. Unlike the
``resource_edges`` table (which stores raw DAG edges with link-type
metadata), ``resource_links`` records relationships that have passed
cycle detection and type compatibility checks.
Revision ID: b1_001_resource_links
Revises: b1_001_resource_registry
Create Date: 2026-02-17 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b1_001_resource_links"
down_revision: str | Sequence[str] | None = "b1_001_resource_registry"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create the resource_links table."""
op.create_table(
"resource_links",
sa.Column(
"parent_id",
sa.String(26),
sa.ForeignKey(
"resources.resource_id",
ondelete="CASCADE",
name="fk_resource_links_parent",
),
nullable=False,
),
sa.Column(
"child_id",
sa.String(26),
sa.ForeignKey(
"resources.resource_id",
ondelete="CASCADE",
name="fk_resource_links_child",
),
nullable=False,
),
sa.Column(
"created_at",
sa.String(30),
nullable=False,
),
sa.PrimaryKeyConstraint("parent_id", "child_id"),
sa.CheckConstraint(
"parent_id != child_id",
name="ck_resource_links_no_self_loop",
),
)
op.create_index(
"ix_resource_links_child",
"resource_links",
["child_id"],
unique=False,
)
op.create_index(
"ix_resource_links_parent",
"resource_links",
["parent_id"],
unique=False,
)
def downgrade() -> None:
"""Drop the resource_links table."""
op.drop_index(
"ix_resource_links_parent",
table_name="resource_links",
)
op.drop_index(
"ix_resource_links_child",
table_name="resource_links",
)
op.drop_table("resource_links")
+188
View File
@@ -0,0 +1,188 @@
"""Add tool registry tables (tools, tool_bindings, validation_attachments).
This migration creates the three tool registry tables:
- ``tools``: Registered tool/validation definitions with JSON schemas,
capability metadata, and source information.
- ``tool_bindings``: Resource slot bindings for tools (context, static,
parameter modes).
- ``validation_attachments``: Attaches validations to resources with
required/informational mode.
Revision ID: c1_001_tool_registry
Revises: b0_001_projects
Create Date: 2026-02-17 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c1_001_tool_registry"
down_revision: str | Sequence[str] | None = "b0_001_projects"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create tools, tool_bindings, and validation_attachments tables."""
# --- tools table ---
op.create_table(
"tools",
sa.Column("tool_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("tool_type", sa.Text(), nullable=False),
sa.Column("source_type", sa.Text(), nullable=False),
sa.Column("input_schema", sa.Text(), nullable=True),
sa.Column("output_schema", sa.Text(), nullable=True),
sa.Column(
"read_only",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"writes",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"checkpointable",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"side_effects",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column("config_yaml", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), nullable=False),
sa.Column("updated_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("tool_id"),
sa.UniqueConstraint("name", name="uq_tools_name"),
sa.CheckConstraint(
"tool_type IN ('tool', 'validation')",
name="ck_tools_tool_type",
),
sa.CheckConstraint(
"source_type IN ('mcp', 'agent_skill', 'builtin', 'custom', 'wrapped')",
name="ck_tools_source_type",
),
)
op.create_index(
"ix_tools_name",
"tools",
["name"],
unique=False,
)
op.create_index(
"ix_tools_tool_type",
"tools",
["tool_type"],
unique=False,
)
# --- tool_bindings table ---
op.create_table(
"tool_bindings",
sa.Column("binding_id", sa.Text(), nullable=False),
sa.Column(
"tool_id",
sa.Text(),
sa.ForeignKey(
"tools.tool_id",
ondelete="CASCADE",
name="fk_tool_bindings_tool",
),
nullable=False,
),
sa.Column("slot_name", sa.Text(), nullable=False),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("binding_mode", sa.Text(), nullable=False),
sa.Column(
"access_level",
sa.Text(),
nullable=False,
server_default="read_only",
),
sa.Column("created_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("binding_id"),
sa.UniqueConstraint(
"tool_id",
"slot_name",
name="uq_tool_bindings_tool_slot",
),
sa.CheckConstraint(
"binding_mode IN ('context', 'static', 'parameter')",
name="ck_tool_bindings_mode",
),
sa.CheckConstraint(
"access_level IN ('read_only', 'read_write')",
name="ck_tool_bindings_access",
),
)
# --- validation_attachments table ---
op.create_table(
"validation_attachments",
sa.Column(
"attachment_id",
sa.Text(),
nullable=False,
),
sa.Column(
"resource_id",
sa.Text(),
sa.ForeignKey(
"resources.resource_id",
ondelete="CASCADE",
name="fk_validation_attachments_resource",
),
nullable=False,
),
sa.Column(
"validation_name",
sa.Text(),
nullable=False,
),
sa.Column(
"mode",
sa.Text(),
nullable=False,
server_default="required",
),
sa.Column("created_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("attachment_id"),
sa.UniqueConstraint(
"resource_id",
"validation_name",
name="uq_validation_attachments_resource_validation",
),
)
op.create_index(
"ix_validation_attachments_resource_id",
"validation_attachments",
["resource_id"],
unique=False,
)
def downgrade() -> None:
"""Drop tool registry tables."""
op.drop_index(
"ix_validation_attachments_resource_id",
table_name="validation_attachments",
)
op.drop_table("validation_attachments")
op.drop_table("tool_bindings")
op.drop_index("ix_tools_tool_type", table_name="tools")
op.drop_index("ix_tools_name", table_name="tools")
op.drop_table("tools")
+126
View File
@@ -0,0 +1,126 @@
"""ASV benchmarks for AutomationProfile validation and serialization.
Measures the performance of:
- AutomationProfile model construction (Pydantic validation)
- AutomationProfile.model_dump() serialization
- AutomationProfile.from_config() factory
- Built-in profile lookup via get_builtin_profile()
- BUILTIN_PROFILES iteration
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
AutomationProfile,
get_builtin_profile,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
AutomationProfile,
get_builtin_profile,
)
def _make_profile() -> AutomationProfile:
"""Create a fully-populated profile for benchmarking."""
return AutomationProfile(
name="bench/test-profile",
description="Benchmark profile",
auto_strategize=0.7,
auto_execute=0.5,
auto_apply=1.0,
auto_decisions_strategize=0.6,
auto_decisions_execute=0.8,
auto_validation_fix=0.3,
auto_strategy_revision=0.9,
auto_reversion_from_apply=0.4,
auto_child_plans=0.7,
auto_retry_transient=0.1,
auto_checkpoint_restore=0.5,
require_sandbox=True,
require_checkpoints=True,
allow_unsafe_tools=False,
)
class ProfileValidationSuite:
"""Benchmark AutomationProfile construction."""
def time_profile_construction(self) -> None:
"""Benchmark fully-populated profile creation."""
_make_profile()
def time_profile_minimal_construction(self) -> None:
"""Benchmark minimal profile creation."""
AutomationProfile(name="bench/minimal")
def time_profile_all_thresholds_max(self) -> None:
"""Benchmark profile with all thresholds at 1.0."""
AutomationProfile(
name="bench/max",
auto_strategize=1.0,
auto_execute=1.0,
auto_apply=1.0,
auto_decisions_strategize=1.0,
auto_decisions_execute=1.0,
auto_validation_fix=1.0,
auto_strategy_revision=1.0,
auto_reversion_from_apply=1.0,
auto_child_plans=1.0,
auto_retry_transient=1.0,
auto_checkpoint_restore=1.0,
)
class ProfileSerializationSuite:
"""Benchmark AutomationProfile serialization."""
def setup(self) -> None:
"""Create objects for serialization benchmarks."""
self.profile = _make_profile()
def time_profile_model_dump(self) -> None:
"""Benchmark model_dump() serialization."""
self.profile.model_dump()
def time_profile_model_dump_json(self) -> None:
"""Benchmark model_dump_json() JSON serialization."""
self.profile.model_dump_json()
class ProfileFromConfigSuite:
"""Benchmark AutomationProfile.from_config() factory."""
def setup(self) -> None:
"""Prepare config dicts for benchmarks."""
self.config = {
"name": "bench/from-config",
"description": "Config benchmark",
"auto_strategize": 0.7,
"auto_execute": 0.5,
"auto_apply": 1.0,
}
def time_profile_from_config(self) -> None:
"""Benchmark from_config()."""
AutomationProfile.from_config(self.config)
class BuiltinProfileSuite:
"""Benchmark built-in profile operations."""
def time_get_builtin_profile(self) -> None:
"""Benchmark get_builtin_profile() lookup."""
get_builtin_profile("cautious")
def time_iterate_all_builtins(self) -> None:
"""Benchmark iterating all built-in profiles."""
for name in BUILTIN_PROFILES:
_ = BUILTIN_PROFILES[name]
@@ -0,0 +1,96 @@
"""ASV benchmarks for AutomationProfileService resolution latency.
Measures the performance of:
- Profile resolution with various precedence levels
- Legacy automation_level mapping
- Profile listing
- Built-in profile lookup via the service
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
try:
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
except ModuleNotFoundError:
sys.path.insert(
0,
str(Path(__file__).resolve().parents[1] / "src"),
)
from cleveragents.application.services.automation_profile_service import (
AutomationProfileService,
)
class ProfileResolutionSuite:
"""Benchmark profile resolution precedence."""
def setup(self) -> None:
"""Create a service instance for benchmarks."""
os.environ.pop("CLEVERAGENTS_AUTOMATION_PROFILE", None)
self.svc = AutomationProfileService(repo=None, global_default="manual")
def time_resolve_plan_level(self) -> None:
"""Benchmark plan-level resolution."""
self.svc.resolve_profile(plan_profile="full-auto")
def time_resolve_action_level(self) -> None:
"""Benchmark action-level resolution."""
self.svc.resolve_profile(action_profile="auto")
def time_resolve_project_level(self) -> None:
"""Benchmark project-level resolution."""
self.svc.resolve_profile(project_profile="ci")
def time_resolve_global_fallback(self) -> None:
"""Benchmark global fallback resolution."""
self.svc.resolve_profile()
def time_resolve_full_precedence(self) -> None:
"""Benchmark with all levels set."""
self.svc.resolve_profile(
plan_profile="full-auto",
action_profile="auto",
project_profile="supervised",
)
class LegacyMappingSuite:
"""Benchmark legacy automation_level mapping."""
def setup(self) -> None:
"""Create service instance."""
self.svc = AutomationProfileService(repo=None)
def time_map_manual(self) -> None:
"""Benchmark mapping 'manual'."""
self.svc.map_legacy_level("manual")
def time_map_full_auto(self) -> None:
"""Benchmark mapping 'full_auto'."""
self.svc.map_legacy_level("full_auto")
def time_resolve_legacy_auto(self) -> None:
"""Benchmark full legacy resolution."""
self.svc.resolve_legacy_level("auto")
class ProfileListingSuite:
"""Benchmark profile listing."""
def setup(self) -> None:
"""Create service instance."""
self.svc = AutomationProfileService(repo=None)
def time_list_all_profiles(self) -> None:
"""Benchmark listing all profiles."""
self.svc.list_profiles()
def time_get_builtin_profile(self) -> None:
"""Benchmark getting a built-in profile."""
self.svc.get_profile("cautious")
+215
View File
@@ -0,0 +1,215 @@
"""ASV benchmarks for BindingResolutionService.
Measures resolve latency for contextual, static, and parameter
bindings across varying numbers of linked resources.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from cleveragents.application.services.binding_resolution_service import (
BindingResolutionService,
)
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.project import (
LinkedResource,
NamespacedProject,
)
from cleveragents.domain.models.core.resource import PhysVirt, Resource
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
SandboxStrategy,
)
from cleveragents.domain.models.core.tool import (
BindingMode,
ResourceAccessMode,
ResourceSlot,
Tool,
ToolSource,
ToolType,
)
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_bench_counter = 0
def _bench_ulid() -> str:
global _bench_counter
_bench_counter += 1
n = _bench_counter
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0AQDYTR4BX{suffix}"
def _make_registry(
resources: dict[str, Resource],
types: dict[str, ResourceTypeSpec],
) -> MagicMock:
registry = MagicMock()
def show_resource(name_or_id: str) -> Resource:
res = resources.get(name_or_id)
if res is None:
raise NotFoundError(
resource_type="resource",
resource_id=name_or_id,
)
return res
def show_type(name: str) -> ResourceTypeSpec:
spec = types.get(name)
if spec is None:
raise NotFoundError(
resource_type="resource_type",
resource_id=name,
)
return spec
registry.show_resource = show_resource
registry.show_type = show_type
return registry
class BindingResolutionContextualBench:
"""Benchmark contextual binding resolution latency."""
timeout = 60
params: list[int] = [1, 10, 50]
param_names: list[str] = ["num_resources"]
def setup(self, num_resources: int) -> None:
global _bench_counter
_bench_counter = 0
resources: dict[str, Resource] = {}
links: list[LinkedResource] = []
for i in range(num_resources):
rid = _bench_ulid()
res = Resource(
resource_id=rid,
name=None,
resource_type_name="git-checkout",
classification=PhysVirt.PHYSICAL,
)
resources[rid] = res
alias = "repo" if i == 0 else f"res-{i}"
links.append(LinkedResource(resource_id=rid, alias=alias))
types: dict[str, ResourceTypeSpec] = {
"git-checkout": ResourceTypeSpec(
name="git-checkout",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.NONE,
built_in=True,
),
}
self.registry = _make_registry(resources, types)
self.project = NamespacedProject(
name="bench",
namespace="local",
linked_resources=links,
)
self.tool = Tool(
name="bench/reader",
description="Benchmark tool",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name="repo",
resource_type="git-checkout",
access=ResourceAccessMode.READ_WRITE,
binding=BindingMode.CONTEXTUAL,
),
],
)
self.service = BindingResolutionService(self.registry)
def time_resolve_contextual(self, num_resources: int) -> None:
self.service.resolve(self.tool, self.project)
class BindingResolutionStaticBench:
"""Benchmark static binding resolution latency."""
timeout = 60
def setup(self) -> None:
global _bench_counter
_bench_counter = 0
rid = _bench_ulid()
res = Resource(
resource_id=rid,
name="bench/config",
resource_type_name="fs-directory",
classification=PhysVirt.PHYSICAL,
)
resources = {
"bench/config": res,
rid: res,
}
types: dict[str, ResourceTypeSpec] = {
"fs-directory": ResourceTypeSpec(
name="fs-directory",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.NONE,
built_in=True,
),
}
self.registry = _make_registry(resources, types)
self.project = NamespacedProject(name="bench", namespace="local")
self.tool = Tool(
name="bench/static",
description="Benchmark static tool",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name="config",
resource_type="fs-directory",
access=ResourceAccessMode.READ_ONLY,
binding=BindingMode.STATIC,
static_resource="bench/config",
),
],
)
self.service = BindingResolutionService(self.registry)
def time_resolve_static(self) -> None:
self.service.resolve(self.tool, self.project)
class BindingResolutionParameterBench:
"""Benchmark parameter binding (deferred) resolution."""
timeout = 60
def setup(self) -> None:
registry = MagicMock()
self.project = NamespacedProject(name="bench", namespace="local")
self.tool = Tool(
name="bench/param",
description="Benchmark param tool",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
resource_slots=[
ResourceSlot(
name="target",
resource_type="fs-directory",
access=ResourceAccessMode.READ_WRITE,
binding=BindingMode.PARAMETER,
),
],
)
self.service = BindingResolutionService(registry)
def time_resolve_parameter_deferred(self) -> None:
self.service.resolve(self.tool, self.project)
+126
View File
@@ -0,0 +1,126 @@
"""ASV benchmarks for ChangeSet capture overhead.
Measures the cost of creating entries, recording to a store,
computing summaries, and wrapping file tools with capture.
"""
import hashlib
import tempfile
from typing import ClassVar
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
InMemoryChangeSetStore,
SpecChangeSet,
)
from cleveragents.tool.builtins.changeset import (
ChangeSetCapture,
)
from cleveragents.tool.builtins.file_tools import (
FILE_WRITE_SPEC,
)
class ChangeEntryCreation:
"""Benchmark ChangeEntry instantiation."""
def setup(self):
self.kwargs = {
"plan_id": "plan-bench",
"resource_id": "res-bench",
"tool_name": "builtin/file-write",
"operation": ChangeOperation.CREATE,
"path": "bench/file.py",
"after_hash": hashlib.sha256(b"content").hexdigest(),
}
def time_create_entry(self):
ChangeEntry(**self.kwargs)
class SpecChangeSetSummary:
"""Benchmark SpecChangeSet summary computation."""
params: ClassVar[list[int]] = [10, 100, 1000]
param_names: ClassVar[list[str]] = ["num_entries"]
def setup(self, num_entries):
entries = []
ops = list(ChangeOperation)
for i in range(num_entries):
entries.append(
ChangeEntry(
plan_id="plan-bench",
resource_id=f"res-{i % 5}",
tool_name="builtin/file-write",
operation=ops[i % len(ops)],
path=f"file_{i}.py",
)
)
self.cs = SpecChangeSet(plan_id="plan-bench", entries=entries)
def time_summary(self, num_entries):
self.cs.summary()
def time_paths_changed(self, num_entries):
_ = self.cs.paths_changed
def time_resources_involved(self, num_entries):
_ = self.cs.resources_involved
class InMemoryStoreRecording:
"""Benchmark InMemoryChangeSetStore operations."""
params: ClassVar[list[int]] = [10, 100, 1000]
param_names: ClassVar[list[str]] = ["num_entries"]
def setup(self, num_entries):
self.store = InMemoryChangeSetStore()
self.cs_id = self.store.start("plan-bench")
self.entries = [
ChangeEntry(
plan_id="plan-bench",
resource_id="res-1",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path=f"file_{i}.py",
)
for i in range(num_entries)
]
def time_record_entries(self, num_entries):
store = InMemoryChangeSetStore()
cs_id = store.start("plan-bench")
for entry in self.entries:
store.record(cs_id, entry)
def time_summarize(self, num_entries):
self.store.summarize(self.cs_id)
class CaptureWrapOverhead:
"""Benchmark wrapping a tool with capture."""
def setup(self):
self.tmpdir = tempfile.mkdtemp()
self.capture = ChangeSetCapture(
plan_id="plan-bench",
resource_id="res-bench",
sandbox_root=self.tmpdir,
)
def time_wrap_tool(self):
self.capture.wrap_tool(FILE_WRITE_SPEC)
def time_wrapped_execution(self):
wrapped = self.capture.wrap_tool(FILE_WRITE_SPEC)
wrapped.handler(
{
"path": "bench_out.txt",
"content": "benchmark",
"sandbox_root": self.tmpdir,
}
)
self.capture.clear()
-1
View File
@@ -7,7 +7,6 @@ negligible overhead to instantiation paths.
from __future__ import annotations
import warnings
from typing import Any
from unittest.mock import MagicMock
+152
View File
@@ -0,0 +1,152 @@
"""ASV benchmarks for project context CLI command overhead.
Measures the cost of:
- Reading a context policy from the DB
- Writing a context policy to the DB
- Policy serialization round-trip for CLI output
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
try:
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import (
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
NamespacedProjectRepository,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
class _NoCloseSession:
"""Session wrapper that no-ops close()."""
def __init__(self, real: object) -> None:
object.__setattr__(self, "_real", real)
def close(self) -> None:
pass
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_real"), name)
def _make_repo_and_factory() -> tuple[NamespacedProjectRepository, Any]:
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
sess = sessionmaker(bind=engine, expire_on_commit=False)()
wrapper = _NoCloseSession(sess)
def factory() -> Any:
return wrapper
repo = NamespacedProjectRepository(session_factory=factory)
return repo, factory
def _make_policy() -> ProjectContextPolicy:
return ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
execute_view=ContextView(
include_paths=["src/**", "lib/**"],
),
)
class ContextPolicyReadWriteSuite:
"""Benchmark policy read/write to DB."""
timeout = 30.0
def setup(self) -> None:
from cleveragents.cli.commands.project_context import (
_write_policy,
)
self.repo, self.sf = _make_repo_and_factory()
parsed = parse_namespaced_name("bench-ctx")
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
)
self.repo.create(proj)
_write_policy(self.sf, "local/bench-ctx", _make_policy())
def time_read_policy(self) -> None:
from cleveragents.cli.commands.project_context import (
_read_policy,
)
_read_policy(self.sf, "local/bench-ctx")
def time_write_policy(self) -> None:
from cleveragents.cli.commands.project_context import (
_write_policy,
)
_write_policy(self.sf, "local/bench-ctx", _make_policy())
class ContextPolicySerializeSuite:
"""Benchmark policy serialization for CLI output."""
timeout = 30.0
def setup(self) -> None:
self.policy = _make_policy()
def time_model_dump(self) -> None:
self.policy.model_dump(mode="json")
def time_model_dump_json(self) -> None:
self.policy.model_dump_json()
def time_json_roundtrip(self) -> None:
blob = self.policy.model_dump_json()
ProjectContextPolicy.model_validate_json(blob)
def time_resolve_and_dump(self) -> None:
for phase in [
"default",
"strategize",
"execute",
"apply",
]:
v = self.policy.resolve_view(phase)
v.model_dump(mode="json")
+142
View File
@@ -0,0 +1,142 @@
"""ASV benchmarks for ProjectContextPolicy validation overhead.
Measures the performance of:
- ContextView construction (Pydantic validation)
- ProjectContextPolicy construction
- resolve_view() for each phase
- JSON serialization round-trip
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
def _make_view() -> ContextView:
"""Create a fully-populated ContextView."""
return ContextView(
include_resources=["db-*", "cache-*"],
exclude_resources=["db-test"],
include_paths=["src/**/*.py", "lib/**"],
exclude_paths=["*.pyc", "__pycache__/**"],
max_file_size=1048576,
max_total_size=10485760,
)
def _make_policy() -> ProjectContextPolicy:
"""Create a fully-populated ProjectContextPolicy."""
return ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_resources=["db-test"],
include_paths=["src/**"],
exclude_paths=["*.pyc"],
max_file_size=1048576,
max_total_size=10485760,
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
),
execute_view=ContextView(
include_paths=["src/**", "lib/**"],
),
apply_view=ContextView(
exclude_paths=["tests/**"],
),
)
class ContextViewValidationSuite:
"""Benchmark ContextView model construction."""
def time_view_construction(self) -> None:
"""Benchmark creating a fully-populated ContextView."""
_make_view()
def time_view_minimal_construction(self) -> None:
"""Benchmark creating a minimal ContextView."""
ContextView()
def time_view_with_size_limits(self) -> None:
"""Benchmark ContextView with size limit validation."""
ContextView(
max_file_size=1048576,
max_total_size=10485760,
)
class PolicyValidationSuite:
"""Benchmark ProjectContextPolicy model construction."""
def time_policy_construction(self) -> None:
"""Benchmark creating a fully-populated policy."""
_make_policy()
def time_policy_empty_construction(self) -> None:
"""Benchmark creating an empty policy."""
ProjectContextPolicy()
def time_policy_default_only(self) -> None:
"""Benchmark policy with only default view."""
ProjectContextPolicy(
default_view=_make_view(),
)
class PolicyResolveSuite:
"""Benchmark resolve_view() for each phase."""
def setup(self) -> None:
"""Create policy for resolve benchmarks."""
self.policy = _make_policy()
def time_resolve_default(self) -> None:
"""Benchmark resolve_view('default')."""
self.policy.resolve_view("default")
def time_resolve_strategize(self) -> None:
"""Benchmark resolve_view('strategize')."""
self.policy.resolve_view("strategize")
def time_resolve_execute(self) -> None:
"""Benchmark resolve_view('execute')."""
self.policy.resolve_view("execute")
def time_resolve_apply(self) -> None:
"""Benchmark resolve_view('apply')."""
self.policy.resolve_view("apply")
class PolicySerializationSuite:
"""Benchmark policy serialization."""
def setup(self) -> None:
"""Create policy for serialization benchmarks."""
self.policy = _make_policy()
def time_model_dump(self) -> None:
"""Benchmark model_dump() dict serialization."""
self.policy.model_dump()
def time_model_dump_json(self) -> None:
"""Benchmark model_dump_json() JSON serialization."""
self.policy.model_dump_json()
def time_json_roundtrip(self) -> None:
"""Benchmark JSON serialize + deserialize."""
json_str = self.policy.model_dump_json()
ProjectContextPolicy.model_validate_json(json_str)
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.models import (
+234
View File
@@ -0,0 +1,234 @@
"""ASV benchmarks for resource DAG link and auto_discover."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
def _setup_db() -> Any:
"""Create in-memory database 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: Any, _rec: Any) -> None:
conn.cursor().execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_BENCH_CTR = 0
def _bench_ulid() -> str:
global _BENCH_CTR
_BENCH_CTR += 1
n = _BENCH_CTR
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HDAGBNCH0AQDYTR4B{suffix:>7s}"[:26]
def _seed_types(
rt_repo: Any,
parent_name: str = "bench/dag-parent",
child_name: str = "bench/dag-child",
) -> None:
"""Seed parent and child resource types."""
from cleveragents.domain.models.core.resource_type import (
ResourceKind,
ResourceTypeSpec,
SandboxStrategy,
)
parent_spec = ResourceTypeSpec(
name=parent_name,
description="Bench parent type",
resource_kind=ResourceKind.PHYSICAL,
sandbox_strategy=SandboxStrategy.NONE,
user_addable=True,
cli_args=[],
parent_types=[],
child_types=[child_name],
auto_discovery={
"enabled": True,
"rules": [{"type": child_name, "pattern": "*"}],
},
equivalence=None,
handler=None,
capabilities={
"read": True,
"write": True,
"sandbox": True,
"checkpoint": False,
},
built_in=False,
)
child_spec = ResourceTypeSpec(
name=child_name,
description="Bench child 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(parent_spec)
rt_repo.create(child_spec)
def _make_resource(
res_repo: Any,
type_name: str,
) -> str:
"""Create a resource and return its ID."""
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
)
rid = _bench_ulid()
res = Resource(
resource_id=rid,
name=None,
resource_type_name=type_name,
classification=PhysVirt.PHYSICAL,
description="Bench resource",
properties={},
location=None,
content_hash=None,
sandbox_strategy=None,
capabilities=ResourceCapabilities(),
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
res_repo.create(res)
return rid
class TimeLinkChild:
"""Benchmark ResourceRepository.link_child()."""
def setup(self) -> None:
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_types(self.rt_repo)
self.parent_id = _make_resource(self.res_repo, "bench/dag-parent")
self._child_ctr = 0
def time_link_child(self) -> None:
child_id = _make_resource(self.res_repo, "bench/dag-child")
self.res_repo.link_child(self.parent_id, child_id)
class TimeUnlinkChild:
"""Benchmark ResourceRepository.unlink_child()."""
def setup(self) -> None:
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_types(self.rt_repo)
self.parent_id = _make_resource(self.res_repo, "bench/dag-parent")
self.child_id = _make_resource(self.res_repo, "bench/dag-child")
self.res_repo.link_child(self.parent_id, self.child_id)
def time_unlink_child(self) -> None:
self.res_repo.unlink_child(self.parent_id, self.child_id)
# Re-link so the benchmark can run multiple times
self.res_repo.link_child(self.parent_id, self.child_id)
class TimeAutoDiscoverChildren:
"""Benchmark ResourceRepository.auto_discover_children()."""
def setup(self) -> None:
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_types(self.rt_repo)
self.parent_id = _make_resource(self.res_repo, "bench/dag-parent")
def time_auto_discover_children(self) -> None:
self.res_repo.auto_discover_children(self.parent_id)
class TimeCycleDetection:
"""Benchmark cycle detection with deep DAG chains."""
params: list[int] = [5, 10, 50]
param_names: list[str] = ["chain_depth"]
def setup(self, chain_depth: int) -> None:
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_types(
self.rt_repo,
"bench/chain-type",
"bench/chain-type",
)
# Build a chain: r0 -> r1 -> ... -> rN
self.chain_ids: list[str] = []
for _ in range(chain_depth + 1):
rid = _make_resource(self.res_repo, "bench/chain-type")
self.chain_ids.append(rid)
for i in range(chain_depth):
self.res_repo.link_child(
self.chain_ids[i],
self.chain_ids[i + 1],
)
def time_cycle_detection(self, chain_depth: int) -> None:
"""Attempt to close the cycle (should raise)."""
try:
self.res_repo.link_child(
self.chain_ids[-1],
self.chain_ids[0],
)
except Exception:
pass
+101
View File
@@ -0,0 +1,101 @@
"""ASV benchmarks for filesystem resource type YAML loading."""
from __future__ import annotations
from pathlib import Path
_EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" / "resource-types"
_FS_MOUNT_YAML = _EXAMPLES_DIR / "fs-mount.yaml"
_FS_FILE_YAML = _EXAMPLES_DIR / "fs-file.yaml"
_FS_DIRECTORY_YAML = _EXAMPLES_DIR / "fs-directory.yaml"
class TimeFsSchemaLoad:
"""Benchmark filesystem resource type YAML schema loading."""
def setup(self) -> None:
from cleveragents.resource.schema import (
ResourceTypeConfigSchema,
)
self.schema_cls = ResourceTypeConfigSchema
self.fs_mount_path = _FS_MOUNT_YAML
self.fs_file_path = _FS_FILE_YAML
self.fs_directory_path = _FS_DIRECTORY_YAML
def time_load_fs_mount(self) -> None:
self.schema_cls.from_yaml_file(self.fs_mount_path)
def time_load_fs_file(self) -> None:
self.schema_cls.from_yaml_file(self.fs_file_path)
def time_load_fs_directory(self) -> None:
self.schema_cls.from_yaml_file(self.fs_directory_path)
def time_load_all_fs_types(self) -> None:
self.schema_cls.from_yaml_file(self.fs_mount_path)
self.schema_cls.from_yaml_file(self.fs_file_path)
self.schema_cls.from_yaml_file(self.fs_directory_path)
class TimeFsDomainModelLoad:
"""Benchmark filesystem resource type domain model creation."""
def setup(self) -> None:
import yaml
from cleveragents.domain.models.core.resource_type import (
ResourceTypeSpec,
)
self.spec_cls = ResourceTypeSpec
with open(_FS_MOUNT_YAML) as f:
self.fs_mount_config: dict[str, object] = yaml.safe_load(f)
with open(_FS_FILE_YAML) as f:
self.fs_file_config: dict[str, object] = yaml.safe_load(f)
with open(_FS_DIRECTORY_YAML) as f:
self.fs_directory_config: dict[str, object] = yaml.safe_load(f)
def time_fs_mount_from_config(self) -> None:
self.spec_cls.from_config(self.fs_mount_config)
def time_fs_file_from_config(self) -> None:
self.spec_cls.from_config(self.fs_file_config)
def time_fs_directory_from_config(self) -> None:
self.spec_cls.from_config(self.fs_directory_config)
def time_all_fs_from_config(self) -> None:
self.spec_cls.from_config(self.fs_mount_config)
self.spec_cls.from_config(self.fs_file_config)
self.spec_cls.from_config(self.fs_directory_config)
class TimeFsCliDict:
"""Benchmark as_cli_dict rendering for filesystem types."""
def setup(self) -> None:
import yaml
from cleveragents.domain.models.core.resource_type import (
ResourceTypeSpec,
)
with open(_FS_MOUNT_YAML) as f:
mount_config: dict[str, object] = yaml.safe_load(f)
with open(_FS_FILE_YAML) as f:
file_config: dict[str, object] = yaml.safe_load(f)
with open(_FS_DIRECTORY_YAML) as f:
dir_config: dict[str, object] = yaml.safe_load(f)
self.mount_spec = ResourceTypeSpec.from_config(mount_config)
self.file_spec = ResourceTypeSpec.from_config(file_config)
self.dir_spec = ResourceTypeSpec.from_config(dir_config)
def time_fs_mount_cli_dict(self) -> None:
self.mount_spec.as_cli_dict()
def time_fs_file_cli_dict(self) -> None:
self.file_spec.as_cli_dict()
def time_fs_directory_cli_dict(self) -> None:
self.dir_spec.as_cli_dict()
@@ -0,0 +1,122 @@
# AutomationProfileService Reference
## Overview
The `AutomationProfileService` resolves the effective automation profile
for plan execution using a four-level precedence hierarchy.
## Profile Precedence
Profiles are resolved in order (first non-null wins):
| Priority | Source | Description |
|----------|---------------|----------------------------------------------|
| 1 | Plan-level | Explicitly set via `--automation-profile` |
| 2 | Action-level | Default profile set on the action template |
| 3 | Project-level | Set via project configuration |
| 4 | Global-level | Config key or `CLEVERAGENTS_AUTOMATION_PROFILE` env var |
If no profile is set at any level, the global default is `manual`.
## Configuration
### Config Key
```yaml
core:
automation_profile: "supervised"
```
This sets the global default profile name. The Settings field is
`default_automation_profile`.
### Environment Variable Override
```bash
export CLEVERAGENTS_AUTOMATION_PROFILE=auto
```
The environment variable takes precedence over the config file value
when the config field is empty.
## Legacy Mapping
The `automation_level` setting is retained for backward compatibility.
It is mapped to built-in profiles as follows:
| `automation_level` Value | Built-in Profile |
|--------------------------|------------------|
| `manual` | `manual` |
| `supervised` | `supervised` |
| `auto` | `auto` |
| `full_auto` | `full-auto` |
When `default_automation_profile` is set, it takes precedence over
`default_automation_level`.
## Auto-Progress Behavior
The `PlanLifecycleService` uses profile thresholds to decide whether
to auto-progress plans:
- **Strategize → Execute**: auto-progresses when `profile.auto_execute < 1.0`
- **Execute → Apply**: auto-progresses when `profile.auto_apply < 1.0`
A threshold of `0.0` means fully automatic (no human gate required).
A threshold of `1.0` means human approval is always required.
## Built-in Profiles
Eight built-in profiles ship with every installation:
| Profile | auto_execute | auto_apply | Behavior |
|---------------|-------------|------------|-------------------------|
| `manual` | 1.0 | 1.0 | Human approves all |
| `review` | 0.0 | 1.0 | Human reviews before apply |
| `supervised` | 1.0 | 1.0 | Human reviews strategy + execution |
| `cautious` | 0.7 | 1.0 | Probabilistic gates |
| `trusted` | 0.0 | 1.0 | Auto most, human apply |
| `auto` | 0.0 | 1.0 | Fully auto except revert|
| `ci` | 0.0 | 0.0 | CI pipeline mode |
| `full-auto` | 0.0 | 0.0 | No gates at all |
## Persistence
Custom profiles are stored in the `automation_profiles` table with
the namespaced name as the primary key. The repository supports
list, show (get_by_name), upsert, and delete operations with a
`schema_version` guard for optimistic concurrency.
## API
### `resolve_profile(plan_profile, action_profile, project_profile)`
Resolves the effective profile using the four-level precedence.
### `get_profile(name)`
Looks up a profile by name (built-in first, then repository).
### `map_legacy_level(level)`
Maps a legacy `automation_level` string to a profile name.
### `resolve_legacy_level(level)`
Resolves a legacy `automation_level` to a full `AutomationProfile`.
### `list_profiles()`
Returns all built-in and custom profiles.
### `create_profile(config)`
Creates and persists a custom profile.
### `update_profile(name, config)`
Updates a persisted custom profile.
### `delete_profile(name)`
Deletes a persisted custom profile.
+110
View File
@@ -0,0 +1,110 @@
# Automation Profiles
Automation Profiles control how much autonomy the CleverAgents system has at each phase of plan execution. Each profile defines a set of **threshold values** and **safety requirements** that determine when the system may proceed automatically versus when it must wait for human approval.
## Threshold Semantics
Each threshold field is a float in the range `[0.0, 1.0]`:
| Value | Meaning |
|-------|---------|
| `0.0` | Fully automatic — no human gate required |
| `1.0` | Always requires human approval |
| `0.0 < v < 1.0` | Probabilistic — the system may proceed if its confidence exceeds the threshold |
### Threshold Fields
| Field | Category | Description |
|-------|----------|-------------|
| `auto_strategize` | Phase transition | Gate before entering the strategy phase |
| `auto_execute` | Phase transition | Gate before entering the execution phase |
| `auto_apply` | Phase transition | Gate before applying changes |
| `auto_decisions_strategize` | Decision autonomy | Gate for decisions during strategy |
| `auto_decisions_execute` | Decision autonomy | Gate for decisions during execution |
| `auto_validation_fix` | Self-repair | Gate for automatic validation fixes |
| `auto_strategy_revision` | Self-repair | Gate for automatic strategy revision |
| `auto_reversion_from_apply` | Self-repair | Gate for reverting from the apply phase |
| `auto_child_plans` | Child plans | Gate for spawning child plans |
| `auto_retry_transient` | Retry | Gate for retrying transient failures |
| `auto_checkpoint_restore` | Checkpoint | Gate for automatic checkpoint restoration |
### Safety Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `require_sandbox` | bool | `true` | Execution must happen in a sandbox |
| `require_checkpoints` | bool | `true` | Checkpoints must be created before writes |
| `allow_unsafe_tools` | bool | `false` | Tools flagged as `unsafe` may be invoked |
## Built-in Profiles
Eight profiles ship with every CleverAgents installation:
| Flag | manual | review | supervised | cautious | trusted | auto | ci | full-auto |
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| auto_strategize | 1.0 | 0.0 | 0.0 | 0.7 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_execute | 1.0 | 0.0 | 1.0 | 0.7 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_apply | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 | 0.0 |
| auto_decisions_strategize | 1.0 | 1.0 | 0.0 | 0.6 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_decisions_execute | 1.0 | 1.0 | 1.0 | 0.8 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_validation_fix | 1.0 | 1.0 | 1.0 | 0.7 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_strategy_revision | 1.0 | 1.0 | 1.0 | 0.8 | 1.0 | 0.0 | 0.0 | 0.0 |
| auto_reversion_from_apply | 1.0 | 1.0 | 1.0 | 0.9 | 1.0 | 1.0 | 0.0 | 0.0 |
| auto_child_plans | 1.0 | 0.0 | 1.0 | 0.7 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_retry_transient | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| auto_checkpoint_restore | 1.0 | 1.0 | 1.0 | 0.6 | 1.0 | 0.0 | 0.0 | 0.0 |
| require_sandbox | true | true | true | true | true | true | true | false |
| require_checkpoints | true | true | true | true | true | true | true | false |
| allow_unsafe_tools | false | false | false | false | false | false | false | true |
### Profile Descriptions
- **manual** — Human approves every action. Maximum safety, minimum autonomy.
- **review** — Strategy and execution proceed automatically; human reviews before apply. Good default for development.
- **supervised** — Human reviews strategy and execution decisions. Strategy creation itself is automatic.
- **cautious** — Probabilistic gates on most actions. The system proceeds when confident, asks when unsure.
- **trusted** — Automatic for most phases, but human approval required for apply and reversion. Suitable for experienced teams.
- **auto** — Fully automatic except reversion from apply. Suitable for well-tested pipelines.
- **ci** — Designed for CI/CD pipelines. All thresholds at 0.0 but sandbox and checkpoints remain required.
- **full-auto** — No gates, no sandbox, no checkpoints, unsafe tools allowed. Use with extreme caution.
## Resolution Precedence
When determining which profile applies to a given plan execution, the system resolves profiles in the following order (highest priority first):
1. **Plan-level override** — A profile specified directly on the plan.
2. **Project-level setting** — The default profile configured for the project.
3. **Organization-level default** — The organization's default profile.
4. **System default** — Falls back to the `review` built-in profile.
At each level, the profile may be specified by:
- A built-in name (e.g. `cautious`)
- A namespaced custom profile (e.g. `acme/strict`)
## Custom Profiles
Custom profiles use a `namespace/name` naming convention:
```yaml
name: acme/strict
description: Strict profile for production deployments
schema_version: "1.0"
auto_strategize: 0.9
auto_execute: 0.9
auto_apply: 1.0
auto_decisions_strategize: 0.8
auto_decisions_execute: 0.9
auto_validation_fix: 0.5
auto_strategy_revision: 0.9
auto_reversion_from_apply: 1.0
auto_child_plans: 0.8
auto_retry_transient: 0.3
auto_checkpoint_restore: 0.7
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
```
See `docs/schema/automation_profile.schema.yaml` for the full YAML schema and `examples/profiles/` for example configurations.
+163
View File
@@ -0,0 +1,163 @@
# ChangeSet Model Reference
The ChangeSet domain model captures all file changes made by tools during the
Execute phase. It is the foundation for plan diff, review, and apply workflows.
## Overview
| Model | Purpose |
|---|---|
| `ChangeOperation` | Enum of operation types (CREATE, MODIFY, DELETE, RENAME) |
| `ChangeEntry` | Single recorded mutation with content hashes and metadata |
| `SpecChangeSet` | Collection of entries for a plan, with computed summaries |
| `ChangeSetStore` | Protocol for persisting and querying changesets |
| `InMemoryChangeSetStore` | In-memory implementation for M1 milestone |
## ChangeOperation
```python
from cleveragents.domain.models.core.change import ChangeOperation
ChangeOperation.CREATE # "create" — new file
ChangeOperation.MODIFY # "modify" — content changed
ChangeOperation.DELETE # "delete" — file removed
ChangeOperation.RENAME # "rename" — file moved/renamed
```
## ChangeEntry
Each `ChangeEntry` represents a single tool-caused mutation.
### Fields
| Field | Type | Description |
|---|---|---|
| `entry_id` | `str` | Auto-generated ULID uniquely identifying this entry |
| `plan_id` | `str` | ULID of the plan that owns this change |
| `resource_id` | `str` | ULID of the resource affected |
| `tool_name` | `str` | Namespaced tool name (e.g. `builtin/file-write`) |
| `operation` | `ChangeOperation` | Type of change |
| `path` | `str` | Repo-relative file path |
| `before_hash` | `str \| None` | SHA-256 of file before change (`None` for create) |
| `after_hash` | `str \| None` | SHA-256 of file after change (`None` for delete) |
| `before_mode` | `int \| None` | File mode before change |
| `after_mode` | `int \| None` | File mode after change |
| `timestamp` | `datetime` | UTC timestamp of the change |
### ULID Fields
The `entry_id` and `plan_id` fields use [ULIDs](https://github.com/ulid/spec)
(Universally Unique Lexicographically Sortable Identifiers). ULIDs are 128-bit
identifiers that encode a timestamp and random component, making them sortable
by creation time while remaining globally unique.
```
01ARZ3NDEKTSV4RRFFQ69G5FAV
└─────────┘└────────────┘
timestamp randomness
(48 bit) (80 bit)
```
### Example
```python
from cleveragents.domain.models.core.change import (
ChangeEntry, ChangeOperation,
)
entry = ChangeEntry(
plan_id="01HXYZ123456789ABCDEF",
resource_id="01HXYZ000000000000001",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="src/models/user.py",
after_hash="e3b0c44298fc1c149afbf4c8996fb924...",
)
```
## SpecChangeSet
A `SpecChangeSet` groups all `ChangeEntry` records for a single plan execution.
### Fields
| Field | Type | Description |
|---|---|---|
| `changeset_id` | `str` | Auto-generated ULID for the changeset |
| `plan_id` | `str` | ULID of the plan |
| `entries` | `list[ChangeEntry]` | Ordered list of change entries |
| `created_at` | `datetime` | UTC timestamp when created |
### Computed Properties
| Property | Type | Description |
|---|---|---|
| `creates` | `int` | Count of CREATE entries |
| `modifies` | `int` | Count of MODIFY entries |
| `deletes` | `int` | Count of DELETE entries |
| `renames` | `int` | Count of RENAME entries |
| `paths_changed` | `set[str]` | Unique file paths affected |
| `resources_involved` | `set[str]` | Unique resource IDs |
### Example
```python
from cleveragents.domain.models.core.change import (
ChangeEntry, ChangeOperation, SpecChangeSet,
)
cs = SpecChangeSet(
plan_id="01HXYZ123456789ABCDEF",
entries=[
ChangeEntry(
plan_id="01HXYZ123456789ABCDEF",
resource_id="01HXYZ000000000000001",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="src/new.py",
),
],
)
print(cs.creates) # 1
print(cs.paths_changed) # {'src/new.py'}
print(cs.summary()) # {'total': 1, 'creates': 1, ...}
```
## ChangeSetStore
The `ChangeSetStore` protocol defines the interface for changeset persistence:
```python
class ChangeSetStore(Protocol):
def start(self, plan_id: str) -> str: ...
def record(self, changeset_id: str, entry: ChangeEntry) -> None: ...
def get(self, changeset_id: str) -> SpecChangeSet | None: ...
def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]: ...
def summarize(self, changeset_id: str) -> dict: ...
```
### InMemoryChangeSetStore
The `InMemoryChangeSetStore` is the M1 implementation that stores changesets in
a plain Python dict. It is suitable for single-process tests and the M1 runtime.
```python
from cleveragents.domain.models.core.change import (
ChangeEntry, ChangeOperation, InMemoryChangeSetStore,
)
store = InMemoryChangeSetStore()
cs_id = store.start("01HXYZ123456789ABCDEF")
store.record(cs_id, ChangeEntry(
plan_id="01HXYZ123456789ABCDEF",
resource_id="01HXYZ000000000000001",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="src/new.py",
))
cs = store.get(cs_id)
print(store.summarize(cs_id))
```
+102
View File
@@ -95,3 +95,105 @@ Run benchmarks via:
```bash
nox -s benchmark
```
## Tool and Validation Registry Tables
## Tables
### `tools`
Stores registered tool and validation definitions.
| Column | Type | Constraints | Description |
|-----------------|---------|-------------------------------------|------------------------------------------|
| `tool_id` | TEXT | PRIMARY KEY | ULID identifier |
| `name` | TEXT | NOT NULL, UNIQUE | Namespaced name (`namespace/short_name`) |
| `description` | TEXT | | Human-readable description |
| `tool_type` | TEXT | NOT NULL, CHECK (`tool`/`validation`) | Discriminator for registry queries |
| `source_type` | TEXT | NOT NULL, CHECK (see below) | Implementation source |
| `input_schema` | TEXT | | JSON Schema for tool inputs |
| `output_schema` | TEXT | | JSON Schema for tool outputs |
| `read_only` | BOOLEAN | NOT NULL, DEFAULT FALSE | Tool only reads, never writes |
| `writes` | BOOLEAN | NOT NULL, DEFAULT FALSE | Tool can write to resources |
| `checkpointable`| BOOLEAN | NOT NULL, DEFAULT FALSE | Tool supports checkpoint/rollback |
| `side_effects` | BOOLEAN | NOT NULL, DEFAULT FALSE | Tool has known side effects |
| `config_yaml` | TEXT | | Raw YAML config for reconstruction |
| `created_at` | TEXT | NOT NULL | ISO-8601 timestamp |
| `updated_at` | TEXT | NOT NULL | ISO-8601 timestamp |
**Check constraints:**
- `ck_tools_tool_type`: `tool_type IN ('tool', 'validation')`
- `ck_tools_source_type`: `source_type IN ('mcp', 'agent_skill', 'builtin', 'custom', 'wrapped')`
**Indexes:**
- `ix_tools_name` on `(name)`
- `ix_tools_tool_type` on `(tool_type)`
---
### `tool_bindings`
Stores resource slot bindings for tools.
| Column | Type | Constraints | Description |
|----------------|---------|--------------------------------------|--------------------------------------|
| `binding_id` | TEXT | PRIMARY KEY | ULID identifier |
| `tool_id` | TEXT | NOT NULL, FK → `tools.tool_id` CASCADE | Parent tool reference |
| `slot_name` | TEXT | NOT NULL | Named resource slot |
| `resource_type`| TEXT | NOT NULL | Expected resource type |
| `binding_mode` | TEXT | NOT NULL, CHECK (see below) | How the slot is resolved |
| `access_level` | TEXT | NOT NULL, DEFAULT `read_only` | Access level on bound resource |
| `created_at` | TEXT | NOT NULL | ISO-8601 timestamp |
**Check constraints:**
- `ck_tool_bindings_mode`: `binding_mode IN ('context', 'static', 'parameter')`
- `ck_tool_bindings_access`: `access_level IN ('read_only', 'read_write')`
**Unique constraints:**
- `uq_tool_bindings_tool_slot`: `UNIQUE(tool_id, slot_name)`
---
### `validation_attachments`
Links validations to resources with mode semantics.
| Column | Type | Constraints | Description |
|-------------------|---------|-----------------------------------------------|------------------------------------|
| `attachment_id` | TEXT | PRIMARY KEY | ULID identifier |
| `resource_id` | TEXT | NOT NULL, FK → `resources.resource_id` CASCADE | Target resource |
| `validation_name` | TEXT | NOT NULL | References `tools.name` (validation)|
| `mode` | TEXT | NOT NULL, DEFAULT `required` | `required` or `informational` |
| `created_at` | TEXT | NOT NULL | ISO-8601 timestamp |
**Check constraints:**
- `ck_validation_attachments_mode`: `mode IN ('required', 'informational')`
**Unique constraints:**
- `uq_validation_attachments_resource_validation`: `UNIQUE(resource_id, validation_name)`
**Indexes:**
- `ix_validation_attachments_resource_id` on `(resource_id)`
---
## Relationships
```
tools 1 ──< tool_bindings (tool_id FK, CASCADE delete)
resources 1 ──< validation_attachments (resource_id FK, CASCADE delete)
validation_attachments.validation_name → tools.name (logical, not enforced by FK)
```
## Migration
- **Revision**: `c1_001_tool_registry`
- **Depends on**: `b0_001_projects`
- **File**: `alembic/versions/c1_001_tool_registry.py`
+134
View File
@@ -0,0 +1,134 @@
# Project Context CLI Reference
The `agents project context` commands manage per-project context policies
that control what resources and files are visible during each ACMS phase.
## Overview
Context policies use **view inheritance**:
| Phase | Inherits from |
|------------|---------------|
| default | (none) |
| strategize | default |
| execute | strategize |
| apply | execute |
An empty policy defaults to including everything.
---
## `agents project context set`
Persist a context policy view for a project.
### Usage
```
agents project context set PROJECT [OPTIONS]
```
### Arguments
| Argument | Description |
|-----------|------------------------------|
| `PROJECT` | Project namespaced name |
### Options
| Option | Type | Description |
|----------------------|-----------|------------------------------------------|
| `--view`, `-v` | `str` | Phase view: default, strategize, execute, apply (default: default) |
| `--include-resource` | `str` | Resource pattern to include (repeatable) |
| `--exclude-resource` | `str` | Resource pattern to exclude (repeatable) |
| `--include-path` | `str` | File path glob to include (repeatable) |
| `--exclude-path` | `str` | File path glob to exclude (repeatable) |
| `--max-file-size` | `int` | Max file size in bytes (None = no limit) |
| `--max-total-size` | `int` | Max total context size in bytes |
| `--clear` | `flag` | Clear (reset) the view for the phase |
| `--format`, `-f` | `str` | Output format (json, yaml, plain, table, rich) |
### Examples
```bash
# Set default view with resource filters
agents project context set local/my-app \
--view default \
--include-resource "db-*" \
--exclude-resource "db-test"
# Set strategize view with path filters
agents project context set local/my-app \
--view strategize \
--include-path "src/**/*.py" \
--exclude-path "*.pyc"
# Set size limits
agents project context set local/my-app \
--max-file-size 1048576 \
--max-total-size 10485760
# Clear execute view (inherit from strategize)
agents project context set local/my-app --view execute --clear
```
---
## `agents project context show`
Display the context policy for a project.
### Usage
```
agents project context show PROJECT [OPTIONS]
```
### Arguments
| Argument | Description |
|-----------|-------------------------|
| `PROJECT` | Project namespaced name |
### Options
| Option | Type | Description |
|-----------------|-------|--------------------------------------------------|
| `--view`, `-v` | `str` | Show resolved view for a specific phase |
| `--format`, `-f`| `str` | Output format (json, yaml, plain, table, rich) |
### Examples
```bash
# Show all views
agents project context show local/my-app
# Show resolved view for execute phase
agents project context show local/my-app --view execute
# JSON output
agents project context show local/my-app --format json
```
---
## `agents project context inspect`
> **Stub** — requires ACMS wiring (not yet implemented).
Inspect the effective context for a project. This command will show
which resources and files would be included/excluded for each phase
based on the current policy and resource registry state.
Raises `NotImplementedError` with a descriptive message.
---
## `agents project context simulate`
> **Stub** — requires ACMS wiring (not yet implemented).
Simulate the context window for a project. This command will show
the estimated token counts and context composition for each phase.
Raises `NotImplementedError` with a descriptive message.
+164
View File
@@ -0,0 +1,164 @@
# Project Context Policy
A `ProjectContextPolicy` controls what context (resources and files) is available
during each ACMS phase. It uses **view inheritance** so each phase can selectively
override or inherit from its parent.
## Inheritance Chain
```
default → strategize → execute → apply
```
| Phase | Inherits from |
|--------------|---------------|
| `default` | *(none)* |
| `strategize` | `default` |
| `execute` | `strategize` |
| `apply` | `execute` |
When resolving a view for a phase, the system walks up the inheritance chain and
returns the first explicitly-set `ContextView`. If no overrides are found, the
`default_view` is returned.
## ContextView
Each view controls:
| Field | Type | Default | Description |
|---------------------|----------------|---------|------------------------------------------|
| `include_resources` | `list[str]` | `[]` | Resource names/patterns to include |
| `exclude_resources` | `list[str]` | `[]` | Resource names/patterns to exclude |
| `include_paths` | `list[str]` | `[]` | File path globs to include |
| `exclude_paths` | `list[str]` | `[]` | File path globs to exclude |
| `max_file_size` | `int \| None` | `None` | Max file size in bytes (None = no limit) |
| `max_total_size` | `int \| None` | `None` | Max total context size (None = no limit) |
**Empty lists mean "include everything"** — no filtering is applied.
Exclusions always take precedence over inclusions.
## Examples
### Empty policy (include everything)
```python
from cleveragents.domain.models.core.context_policy import (
ProjectContextPolicy,
)
policy = ProjectContextPolicy()
view = policy.resolve_view("execute")
# view.include_resources == [] (all resources)
# view.include_paths == [] (all paths)
# view.max_file_size is None (no limit)
```
### Default view with overrides at strategize
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
exclude_paths=["*.pyc"],
max_file_size=1_048_576, # 1 MB
),
strategize_view=ContextView(
include_resources=["db-*", "cache-*"],
max_file_size=2_097_152, # 2 MB
),
)
# Strategize uses its own view
view = policy.resolve_view("strategize")
assert view.include_resources == ["db-*", "cache-*"]
assert view.max_file_size == 2_097_152
# Execute inherits from strategize (no execute_view set)
view = policy.resolve_view("execute")
assert view.include_resources == ["db-*", "cache-*"]
# Apply also inherits from strategize
view = policy.resolve_view("apply")
assert view.include_resources == ["db-*", "cache-*"]
```
### Override at execute level only
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["db-*"],
),
execute_view=ContextView(
include_resources=["db-*", "api-*"],
max_total_size=10_485_760, # 10 MB
),
)
# Strategize inherits from default (no strategize_view set)
view = policy.resolve_view("strategize")
assert view.include_resources == ["db-*"]
# Execute uses its own view
view = policy.resolve_view("execute")
assert view.include_resources == ["db-*", "api-*"]
# Apply inherits from execute
view = policy.resolve_view("apply")
assert view.include_resources == ["db-*", "api-*"]
```
### Size limits
```python
from cleveragents.domain.models.core.context_policy import (
ContextView,
)
# Valid size limits
view = ContextView(
max_file_size=1_048_576, # 1 MB per file
max_total_size=10_485_760, # 10 MB total
)
# None means no limit (default)
view = ContextView()
assert view.max_file_size is None
assert view.max_total_size is None
# Zero or negative values are rejected
# ContextView(max_file_size=0) -> ValidationError
# ContextView(max_file_size=-1) -> ValidationError
```
## JSON Serialization
```python
import json
from cleveragents.domain.models.core.context_policy import (
ContextView,
ProjectContextPolicy,
)
policy = ProjectContextPolicy(
default_view=ContextView(include_resources=["db-*"]),
strategize_view=ContextView(include_resources=["cache-*"]),
)
# Serialize
data = json.loads(policy.model_dump_json())
# Deserialize
restored = ProjectContextPolicy.model_validate(data)
assert restored == policy
```
+157
View File
@@ -0,0 +1,157 @@
# Resource DAG
Resources in CleverAgents form a **directed acyclic graph** (DAG) where
parent resources contain or reference child resources. This document
describes the linking rules, cycle detection, type compatibility
enforcement, and auto-discovery behaviour.
## DAG Rules
| Rule | Description |
|------|-------------|
| **No self-loops** | A resource cannot be its own child. |
| **No cycles** | If resource A is an ancestor of B, then B cannot become a parent of A. |
| **Type compatibility** | The child's resource type must appear in the parent type's `child_types` list. |
| **Unique links** | A given (parent, child) pair can only be linked once. |
| **Both must exist** | Both the parent and the child resource must be registered before linking. |
## API
### `link_child(parent_id, child_id)`
Links a child resource to a parent in the DAG.
1. Validates that both resources exist in the registry.
2. Checks **type compatibility** — the child resource's type must be
listed in the parent resource type's `child_types` field.
3. Performs **cycle detection** — walks the ancestor chain of the
parent to ensure the child is not already an ancestor.
4. Persists the link in the `resource_links` table.
**Errors:**
- `ResourceNotFoundRepoError` — parent or child does not exist.
- `TypeIncompatibleError` — child type not in parent's `child_types`.
- `CycleDetectedError` — linking would create a cycle.
- `DuplicateResourceLinkError` — link already exists.
### `unlink_child(parent_id, child_id)`
Removes a parent-child link from the DAG.
1. Validates that both resources exist.
2. Validates the link exists.
3. Deletes the link from `resource_links`.
**Errors:**
- `ResourceNotFoundRepoError` — parent or child does not exist.
- `LinkNotFoundError` — the link does not exist.
### `get_children(resource_id)`
Returns all direct children of a resource (via `resource_links`).
### `get_parents(resource_id)`
Returns all direct parents of a resource (via `resource_links`).
## Cycle Detection
Cycle detection uses a **breadth-first search** upward through the
`resource_links` table starting from the proposed parent. If the
proposed child is found among the ancestors, the link is rejected
with a `CycleDetectedError` that includes the cycle path.
### Example
```
A -> B -> C
```
Attempting to link `C -> A` would be rejected because `A` is an
ancestor of `C`. The error message includes the path:
`A -> B -> C -> A`.
## Type Compatibility
Each resource type defines a `child_types` list of allowed child
type names. When linking, the system verifies:
```
child.resource_type_name in parent_type.child_types
```
If the parent type's `child_types` list is empty, **any** child type
is allowed (no restriction).
### Example
```yaml
# git-checkout type
child_types: ["fs-directory", "git"]
```
Only resources of type `fs-directory` or `git` can be linked as
children of a `git-checkout` resource.
## Auto-Discovery
### `auto_discover_children(resource_id)`
Materializes child resources based on the parent's type auto-discovery
configuration.
1. Looks up the resource and its type.
2. Reads the `auto_discovery` configuration from the type.
3. For each discovery rule where `enabled` is `true`:
- Checks the child type exists in the database.
- Checks type compatibility with the parent.
- Creates a new child resource with `auto_discovered = true`.
- Links the child to the parent via `resource_links`.
4. Returns the list of newly created child resources.
### Auto-Discovery Configuration
Auto-discovery is configured per resource type in YAML:
```yaml
auto_discovery:
enabled: true
rules:
- type: fs-directory
pattern: "*/"
- type: fs-file
pattern: "*"
```
Each rule specifies:
- `type` — the child resource type name to create.
- `pattern` — a glob pattern (used by handlers for actual file
discovery; the repository layer creates placeholder entries).
### When Does Auto-Discovery Run?
Auto-discovery is triggered by calling
`auto_discover_children(resource_id)`. This is typically invoked:
- When a resource is first registered.
- When a resource's contents change (e.g., new files appear).
- On demand via CLI commands.
## Database Schema
### `resource_links` Table
| Column | Type | Description |
|--------|------|-------------|
| `parent_id` | `String(26)` | FK to `resources.resource_id` |
| `child_id` | `String(26)` | FK to `resources.resource_id` |
| `created_at` | `String(30)` | ISO-8601 timestamp |
Primary key: `(parent_id, child_id)`
Constraints:
- `parent_id != child_id` (no self-loops)
- Foreign keys cascade on delete
+158
View File
@@ -0,0 +1,158 @@
# Built-in Resource Types
CleverAgents ships with several built-in resource types that cover common
filesystem and version-control workflows. Built-in types use simple
unnamespaced names and are registered automatically at startup.
## fs-mount
A physical mount point on the local system. Use this type to represent
the root of a filesystem hierarchy that CleverAgents should manage.
| Field | Value |
|---|---|
| **resource_kind** | physical |
| **sandbox_strategy** | copy_on_write |
| **user_addable** | true |
| **handler** | `cleveragents.resource.handlers.fs_mount` |
### CLI Arguments
| Name | Type | Required | Description |
|---|---|---|---|
| `path` | path | yes | Path to the mount point |
### Parent / Child Types
- **Parent types**: none (always top-level)
- **Child types**: `fs-directory`
### Auto-Discovery Rules
| Rule Type | Pattern | Description |
|---|---|---|
| `fs-directory` | `/` | Discovers the root directory under the mount |
### Capabilities
| Capability | Enabled |
|---|---|
| read | yes |
| write | yes |
| sandbox | yes |
| checkpoint | no |
---
## fs-directory
A filesystem directory. Can appear under a `git-checkout`, another
`fs-directory`, or an `fs-mount`.
| Field | Value |
|---|---|
| **resource_kind** | physical |
| **sandbox_strategy** | copy_on_write |
| **user_addable** | true |
| **handler** | `cleveragents.resource.handlers.fs_directory` |
### CLI Arguments
| Name | Type | Required | Description |
|---|---|---|---|
| `path` | path | yes | Path to the directory |
### Parent / Child Types
- **Parent types**: `git-checkout`, `fs-directory`, `fs-mount`
- **Child types**: `fs-directory`, `fs-file`, `fs-symlink`, `fs-hardlink`
### Auto-Discovery Rules
| Rule Type | Pattern | Description |
|---|---|---|
| `fs-directory` | `*/` | Discovers immediate subdirectories |
| `fs-file` | `*` | Discovers immediate files |
### Capabilities
| Capability | Enabled |
|---|---|
| read | yes |
| write | yes |
| sandbox | yes |
| checkpoint | no |
---
## fs-file
A regular file on the local filesystem. This type is auto-discovered
only; users cannot add it manually.
| Field | Value |
|---|---|
| **resource_kind** | physical |
| **sandbox_strategy** | copy_on_write |
| **user_addable** | false |
| **handler** | `cleveragents.resource.handlers.fs_file` |
### CLI Arguments
None (auto-discovered only).
### Parent / Child Types
- **Parent types**: `fs-directory`
- **Child types**: none
### Capabilities
| Capability | Enabled |
|---|---|
| read | yes |
| write | yes |
| sandbox | no |
| checkpoint | no |
---
## git-checkout
A local git repository checkout. Serves as a top-level entry point
for version-controlled projects.
| Field | Value |
|---|---|
| **resource_kind** | physical |
| **sandbox_strategy** | git_worktree |
| **user_addable** | true |
| **handler** | `cleveragents.resource.handlers.git_checkout` |
### CLI Arguments
| Name | Type | Required | Description |
|---|---|---|---|
| `path` | path | yes | Path to the local git repository |
| `branch` | string | no | Branch to checkout (default: current) |
### Parent / Child Types
- **Parent types**: none (always top-level)
- **Child types**: `git`, `fs-directory`
### Auto-Discovery Rules
| Rule Type | Pattern | Description |
|---|---|---|
| `git` | `.git` | Discovers git metadata |
| `fs-directory` | `**/` | Discovers all subdirectories recursively |
### Capabilities
| Capability | Enabled |
|---|---|
| read | yes |
| write | yes |
| sandbox | yes |
| checkpoint | yes |
+132
View File
@@ -0,0 +1,132 @@
# Tool Resource Bindings
Tools in CleverAgents declare **resource slots** that describe which
resources they operate on. At activation time the binding resolution
service maps each slot to a concrete resource from the registry.
## Binding Modes
### Contextual Binding (default)
The slot declares only a `resource_type` requirement. The system
searches the plan's project for linked resources that match at
activation time.
**Resolution rules:**
1. Collect all linked resources whose type matches the slot's
`resource_type` (including sub-types via `parent_types`).
2. If exactly **one** resource matches, it is automatically bound.
3. If **multiple** resources match, the slot `name` is used as a
hint: the resource whose alias (or short name) equals the slot
name is selected. If no alias matches, a `ValidationError` is
raised.
4. If **no** resource matches and the slot is `required`, a
`ValidationError` is raised.
```yaml
resource_slots:
- name: repo
resource_type: git-checkout
access: read_write
binding: contextual # default -- can be omitted
```
### Static Binding
The slot is hardcoded to a specific registered resource by name via
the `static_resource` field. Validated at registration time.
```yaml
resource_slots:
- name: config_dir
resource_type: fs-directory
access: read_only
binding: static
static_resource: local/shared-config
```
### Parameter Binding
The resource reference is passed as a tool argument at invocation
time. Resolution is deferred until the tool is actually called.
```yaml
resource_slots:
- name: target
resource_type: fs-directory
access: read_write
binding: parameter
```
At invocation the caller supplies `target=<resource-name-or-id>`.
## Resolution Order
For each resource slot on the tool:
1. Determine the binding mode (`static`, `parameter`, or
`contextual`).
2. **Static** -- look up the named resource in the registry;
verify type compatibility.
3. **Parameter** -- if invocation params include the slot name,
resolve eagerly; otherwise mark as *deferred*.
4. **Contextual** -- search the project's linked resources for
type-compatible matches and apply disambiguation rules.
## Type Compatibility
A resource is *type-compatible* with a slot when:
- The resource's type **exactly matches** the slot's
`resource_type`, **or**
- The resource's type declares the slot's `resource_type` in its
`parent_types` list (sub-type relationship).
For example, `fs-directory` lists `git-checkout` in its
`parent_types`, so an `fs-directory` resource satisfies a slot
requiring `git-checkout`.
## Examples
### Single Contextual Resource
```python
from cleveragents.application.services.binding_resolution_service import (
BindingResolutionService,
)
service = BindingResolutionService(resource_registry)
results = service.resolve(tool, project)
# results[0].binding_mode == "contextual"
# results[0].resource_id == "01HGZ..."
```
### Mixed Bindings
A tool may combine all three modes:
```yaml
resource_slots:
- name: repo
resource_type: git-checkout
access: read_write
binding: contextual
- name: config
resource_type: fs-directory
access: read_only
binding: static
static_resource: local/shared-config
- name: output
resource_type: fs-directory
access: write_only
binding: parameter
```
## API Reference
See:
- [`BindingResolutionService`](../../src/cleveragents/application/services/binding_resolution_service.py)
- [`BindingResult`](../../src/cleveragents/domain/models/core/resource_slot.py)
- [`ResourceSlot`](../../src/cleveragents/domain/models/core/tool.py)
+110
View File
@@ -0,0 +1,110 @@
# Automation Profile YAML Schema
# Defines the expected structure for automation profile configuration files.
# See docs/specification.md Section "Automation Profiles" for full details.
type: object
required:
- name
properties:
name:
type: string
minLength: 1
pattern: "^[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$"
description: >
Profile name: bare built-in name (e.g. 'manual')
or namespaced 'namespace/name' (e.g. 'acme/strict')
description:
type: string
description: "Human-readable description of the profile"
schema_version:
type: string
default: "1.0"
description: "Schema version for forward compatibility"
# Phase-transition thresholds (0.0 = auto, 1.0 = human)
auto_strategize:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic strategy approval"
auto_execute:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic execution approval"
auto_apply:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic apply approval"
# Decision-autonomy thresholds
auto_decisions_strategize:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic decisions during strategy"
auto_decisions_execute:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic decisions during execution"
# Self-repair thresholds
auto_validation_fix:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic validation fix"
auto_strategy_revision:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic strategy revision"
auto_reversion_from_apply:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic reversion from apply"
# Child plan and retry thresholds
auto_child_plans:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic child plan spawning"
auto_retry_transient:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic retry of transient failures"
auto_checkpoint_restore:
type: number
minimum: 0.0
maximum: 1.0
default: 0.0
description: "Threshold for automatic checkpoint restore"
# Safety requirements
require_sandbox:
type: boolean
default: true
description: "Whether a sandbox is required for execution"
require_checkpoints:
type: boolean
default: true
description: "Whether checkpoints are required"
allow_unsafe_tools:
type: boolean
default: false
description: "Whether unsafe tools may be used"
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: auto
# Fully automatic except reversion.
# Everything proceeds without human gates except reversion from apply.
name: auto
description: Fully automatic except reversion
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.0
auto_decisions_execute: 0.0
# Self-repair thresholds
auto_validation_fix: 0.0
auto_strategy_revision: 0.0
auto_reversion_from_apply: 1.0
# Child plan and retry thresholds
auto_child_plans: 0.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 0.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+32
View File
@@ -0,0 +1,32 @@
# Built-in profile: cautious
# Probabilistic gates on most actions.
# Intermediate thresholds allow the system to proceed automatically
# only when confidence is high.
name: cautious
description: Probabilistic gates on most actions
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.7
auto_execute: 0.7
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.6
auto_decisions_execute: 0.8
# Self-repair thresholds
auto_validation_fix: 0.7
auto_strategy_revision: 0.8
auto_reversion_from_apply: 0.9
# Child plan and retry thresholds
auto_child_plans: 0.7
auto_retry_transient: 0.0
auto_checkpoint_restore: 0.6
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: ci
# Designed for CI pipelines.
# All thresholds at 0.0 — fully automatic in a sandboxed environment.
name: ci
description: Designed for CI pipelines
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 0.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.0
auto_decisions_execute: 0.0
# Self-repair thresholds
auto_validation_fix: 0.0
auto_strategy_revision: 0.0
auto_reversion_from_apply: 0.0
# Child plan and retry thresholds
auto_child_plans: 0.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 0.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: full-auto
# No gates, no sandbox, no checkpoints.
# Maximum autonomy — use with extreme caution.
name: full-auto
description: No gates, no sandbox, no checkpoints
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 0.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.0
auto_decisions_execute: 0.0
# Self-repair thresholds
auto_validation_fix: 0.0
auto_strategy_revision: 0.0
auto_reversion_from_apply: 0.0
# Child plan and retry thresholds
auto_child_plans: 0.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 0.0
# Safety requirements
require_sandbox: false
require_checkpoints: false
allow_unsafe_tools: true
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: manual
# Human approves every action.
# All thresholds set to 1.0 — nothing proceeds without explicit approval.
name: manual
description: Human approves every action
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 1.0
auto_execute: 1.0
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 1.0
auto_decisions_execute: 1.0
# Self-repair thresholds
auto_validation_fix: 1.0
auto_strategy_revision: 1.0
auto_reversion_from_apply: 1.0
# Child plan and retry thresholds
auto_child_plans: 1.0
auto_retry_transient: 1.0
auto_checkpoint_restore: 1.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: review
# Human reviews before apply.
# Strategy and execution proceed automatically; apply requires review.
name: review
description: Human reviews before apply
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 1.0
auto_decisions_execute: 1.0
# Self-repair thresholds
auto_validation_fix: 1.0
auto_strategy_revision: 1.0
auto_reversion_from_apply: 1.0
# Child plan and retry thresholds
auto_child_plans: 0.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 1.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: supervised
# Human reviews strategy and execution phases.
# Strategy is automatic; execution and apply require human review.
name: supervised
description: Human reviews strategy and execution
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 1.0
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.0
auto_decisions_execute: 1.0
# Self-repair thresholds
auto_validation_fix: 1.0
auto_strategy_revision: 1.0
auto_reversion_from_apply: 1.0
# Child plan and retry thresholds
auto_child_plans: 1.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 1.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+31
View File
@@ -0,0 +1,31 @@
# Built-in profile: trusted
# Auto for most actions, human for apply and revert.
# High autonomy with safety nets at critical boundaries.
name: trusted
description: Auto for most, human for apply and revert
schema_version: "1.0"
# Phase-transition thresholds
auto_strategize: 0.0
auto_execute: 0.0
auto_apply: 1.0
# Decision-autonomy thresholds
auto_decisions_strategize: 0.0
auto_decisions_execute: 0.0
# Self-repair thresholds
auto_validation_fix: 0.0
auto_strategy_revision: 1.0
auto_reversion_from_apply: 1.0
# Child plan and retry thresholds
auto_child_plans: 0.0
auto_retry_transient: 0.0
auto_checkpoint_restore: 1.0
# Safety requirements
require_sandbox: true
require_checkpoints: true
allow_unsafe_tools: false
+9 -2
View File
@@ -10,8 +10,15 @@ cli_args:
type: path
required: true
description: "Path to the directory"
parent_types: ["git-checkout", "fs-directory"]
child_types: ["fs-directory", "fs-file"]
parent_types: ["git-checkout", "fs-directory", "fs-mount"]
child_types: ["fs-directory", "fs-file", "fs-symlink", "fs-hardlink"]
auto_discovery:
enabled: true
rules:
- type: fs-directory
pattern: "*/"
- type: fs-file
pattern: "*"
capabilities:
read: true
write: true
+16
View File
@@ -0,0 +1,16 @@
schema_version: "1.0"
name: fs-file
description: "A regular file on the local filesystem"
resource_kind: physical
sandbox_strategy: copy_on_write
user_addable: false
built_in: true
cli_args: []
parent_types: ["fs-directory"]
child_types: []
capabilities:
read: true
write: true
sandbox: false
checkpoint: false
handler: "cleveragents.resource.handlers.fs_file"
+25
View File
@@ -0,0 +1,25 @@
schema_version: "1.0"
name: fs-mount
description: "A physical mount point on the local system"
resource_kind: physical
sandbox_strategy: copy_on_write
user_addable: true
built_in: true
cli_args:
- name: path
type: path
required: true
description: "Path to the mount point"
parent_types: []
child_types: ["fs-directory"]
auto_discovery:
enabled: true
rules:
- type: fs-directory
pattern: "/"
capabilities:
read: true
write: true
sandbox: true
checkpoint: false
handler: "cleveragents.resource.handlers.fs_mount"
+3 -3
View File
@@ -15,14 +15,14 @@ cli_args:
required: false
description: "Branch to checkout (default: current)"
parent_types: []
child_types: ["fs-directory", "fs-file"]
child_types: ["git", "fs-directory"]
auto_discovery:
enabled: true
rules:
- type: git
pattern: ".git"
- type: fs-directory
pattern: "**/"
- type: fs-file
pattern: "**/*"
capabilities:
read: true
write: true
+276
View File
@@ -0,0 +1,276 @@
Feature: Automation Profile Domain Model
As a developer
I want automation profile domain models with threshold validation
So that plan execution autonomy can be configured and enforced
# ---- Profile validation: valid thresholds ----
Scenario: Profile accepts threshold of 0.0
When I create a profile with auto_strategize 0.0
Then the profile model should be created
And the profile auto_strategize should be 0.0
Scenario: Profile accepts threshold of 0.5
When I create a profile with auto_strategize 0.5
Then the profile model should be created
And the profile auto_strategize should be 0.5
Scenario: Profile accepts threshold of 1.0
When I create a profile with auto_strategize 1.0
Then the profile model should be created
And the profile auto_strategize should be 1.0
# ---- Profile validation: invalid thresholds ----
Scenario: Profile rejects threshold below 0.0
When I try to create a profile with auto_strategize -0.1
Then a profile validation error should be raised
And the profile error should mention "greater than or equal"
Scenario: Profile rejects threshold above 1.0
When I try to create a profile with auto_strategize 1.1
Then a profile validation error should be raised
And the profile error should mention "less than or equal"
Scenario: Profile rejects auto_execute below 0.0
When I try to create a profile with auto_execute -0.5
Then a profile validation error should be raised
Scenario: Profile rejects auto_execute above 1.0
When I try to create a profile with auto_execute 2.0
Then a profile validation error should be raised
Scenario: Profile rejects auto_apply below 0.0
When I try to create a profile with auto_apply -0.01
Then a profile validation error should be raised
Scenario: Profile rejects auto_decisions_strategize above 1.0
When I try to create a profile with auto_decisions_strategize 1.5
Then a profile validation error should be raised
Scenario: Profile rejects auto_decisions_execute below 0.0
When I try to create a profile with auto_decisions_execute -1.0
Then a profile validation error should be raised
Scenario: Profile rejects auto_validation_fix above 1.0
When I try to create a profile with auto_validation_fix 9.9
Then a profile validation error should be raised
Scenario: Profile rejects auto_strategy_revision below 0.0
When I try to create a profile with auto_strategy_revision -0.001
Then a profile validation error should be raised
Scenario: Profile rejects auto_reversion_from_apply above 1.0
When I try to create a profile with auto_reversion_from_apply 1.01
Then a profile validation error should be raised
Scenario: Profile rejects auto_child_plans below 0.0
When I try to create a profile with auto_child_plans -0.5
Then a profile validation error should be raised
Scenario: Profile rejects auto_retry_transient above 1.0
When I try to create a profile with auto_retry_transient 100.0
Then a profile validation error should be raised
Scenario: Profile rejects auto_checkpoint_restore below 0.0
When I try to create a profile with auto_checkpoint_restore -0.1
Then a profile validation error should be raised
# ---- Built-in profiles load correctly ----
Scenario: Built-in manual profile loads with expected values
When I load the built-in profile "manual"
Then the profile model should be created
And the profile auto_strategize should be 1.0
And the profile auto_execute should be 1.0
And the profile auto_apply should be 1.0
And the profile require_sandbox should be true
And the profile require_checkpoints should be true
And the profile allow_unsafe_tools should be false
Scenario: Built-in review profile loads with expected values
When I load the built-in profile "review"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_execute should be 0.0
And the profile auto_apply should be 1.0
And the profile auto_child_plans should be 0.0
Scenario: Built-in supervised profile loads with expected values
When I load the built-in profile "supervised"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_execute should be 1.0
And the profile auto_decisions_strategize should be 0.0
And the profile auto_child_plans should be 1.0
Scenario: Built-in cautious profile loads with expected values
When I load the built-in profile "cautious"
Then the profile model should be created
And the profile auto_strategize should be 0.7
And the profile auto_execute should be 0.7
And the profile auto_decisions_strategize should be 0.6
And the profile auto_decisions_execute should be 0.8
And the profile auto_validation_fix should be 0.7
And the profile auto_strategy_revision should be 0.8
And the profile auto_reversion_from_apply should be 0.9
And the profile auto_child_plans should be 0.7
And the profile auto_checkpoint_restore should be 0.6
Scenario: Built-in trusted profile loads with expected values
When I load the built-in profile "trusted"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_execute should be 0.0
And the profile auto_apply should be 1.0
And the profile auto_strategy_revision should be 1.0
And the profile auto_reversion_from_apply should be 1.0
Scenario: Built-in auto profile loads with expected values
When I load the built-in profile "auto"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_apply should be 1.0
And the profile auto_reversion_from_apply should be 1.0
And the profile auto_strategy_revision should be 0.0
Scenario: Built-in ci profile loads with expected values
When I load the built-in profile "ci"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_apply should be 0.0
And the profile require_sandbox should be true
And the profile allow_unsafe_tools should be false
Scenario: Built-in full-auto profile loads with expected values
When I load the built-in profile "full-auto"
Then the profile model should be created
And the profile auto_strategize should be 0.0
And the profile auto_apply should be 0.0
And the profile require_sandbox should be false
And the profile require_checkpoints should be false
And the profile allow_unsafe_tools should be true
# ---- All 8 built-in profiles exist ----
Scenario: All 8 built-in profiles are registered
Then there should be 8 built-in profiles
And built-in profile "manual" should exist
And built-in profile "review" should exist
And built-in profile "supervised" should exist
And built-in profile "cautious" should exist
And built-in profile "trusted" should exist
And built-in profile "auto" should exist
And built-in profile "ci" should exist
And built-in profile "full-auto" should exist
# ---- Custom profile from YAML dict loads correctly ----
Scenario: Custom profile from YAML config loads correctly
When I load a profile from config with name "acme/strict" and auto_apply 0.8
Then the profile model should be created
And the profile name should be "acme/strict"
And the profile auto_apply should be 0.8
Scenario: Profile from config missing name raises error
When I try to load a profile from config missing name
Then a profile config error should be raised with "name"
# ---- Name format validation ----
Scenario: Profile name accepts bare name
When I create a profile with name "manual"
Then the profile model should be created
And the profile name should be "manual"
Scenario: Profile name accepts namespaced name
When I create a profile with name "acme/strict"
Then the profile model should be created
And the profile name should be "acme/strict"
Scenario: Profile name rejects spaces
When I try to create a profile with invalid name "bad name"
Then a profile validation error should be raised
And the profile error should mention "Profile name"
Scenario: Profile name rejects empty string
When I try to create a profile with empty name
Then a profile validation error should be raised
Scenario: Profile name rejects double slash
When I try to create a profile with invalid name "bad//name"
Then a profile validation error should be raised
Scenario: Profile name rejects trailing slash
When I try to create a profile with invalid name "bad/"
Then a profile validation error should be raised
# ---- get_builtin_profile helper ----
Scenario: get_builtin_profile returns correct profile
When I call get_builtin_profile with "ci"
Then the profile model should be created
And the profile name should be "ci"
Scenario: get_builtin_profile raises for unknown name
When I try to call get_builtin_profile with "nonexistent"
Then a profile key error should be raised
# ---- Schema version ----
Scenario: Profile has default schema version
When I create a profile with name "test-profile"
Then the profile schema_version should be "1.0"
Scenario: Profile accepts custom schema version
When I create a profile with schema_version "2.0"
Then the profile schema_version should be "2.0"
# ---- Safety field defaults ----
Scenario: Profile safety fields have correct defaults
When I create a profile with name "test-defaults"
Then the profile require_sandbox should be true
And the profile require_checkpoints should be true
And the profile allow_unsafe_tools should be false
# ---- Description field ----
Scenario: Profile description defaults to empty string
When I create a profile with name "no-desc"
Then the profile description should be empty
Scenario: Profile description accepts custom value
When I create a profile with description "My custom profile"
Then the profile description should be "My custom profile"
# ---- validate_assignment enforcement ----
Scenario: Assigning invalid threshold raises error
When I create a profile with name "assign-test"
And I try to assign auto_strategize 1.5 on the profile
Then a profile validation error should be raised
# ---- Built-in profile retry and checkpoint values ----
Scenario: Manual profile has auto_retry_transient 1.0
When I load the built-in profile "manual"
Then the profile auto_retry_transient should be 1.0
And the profile auto_checkpoint_restore should be 1.0
Scenario: Review profile has auto_retry_transient 0.0
When I load the built-in profile "review"
Then the profile auto_retry_transient should be 0.0
And the profile auto_checkpoint_restore should be 1.0
Scenario: Cautious profile has auto_retry_transient 0.0
When I load the built-in profile "cautious"
Then the profile auto_retry_transient should be 0.0
# ---- Model dump and round-trip ----
Scenario: Profile model_dump produces valid dict
When I create a profile and dump it with auto_apply 0.5
Then the profile model dump should have key "name"
And the profile model dump should have key "auto_apply"
And the profile model dump auto_apply should be 0.5
@@ -0,0 +1,89 @@
Feature: Automation Profile CRUD operations with repository
As a developer
I want to create, read, update, and delete custom profiles via a repository
So that custom automation profiles are persisted correctly
# ---- get_profile from repository (lines 160-162) ----
Scenario: Get custom profile from repository
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I fetch profile "acme/custom"
Then the fetched profile name should be "acme/custom"
# ---- list_profiles with repository (lines 221-222) ----
Scenario: List profiles includes custom profiles from repository
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I list all available profiles
Then the available profiles should include "manual"
And the available profiles should include "full-auto"
And the available profiles should include "acme/custom"
# ---- create_profile valid (lines 239-248) ----
Scenario: Create a custom profile with valid config
Given a profile service with a mock repository
When I create a profile with name "team/deploy" and description "Deploy profile"
Then the created profile name should be "team/deploy"
And the created profile description should be "Deploy profile"
And the mock repository should contain "team/deploy"
# ---- create_profile builtin name raises ValidationError (lines 240-241) ----
Scenario: Create profile with builtin name raises ValidationError
Given a profile service with a mock repository
When I try to create a profile with builtin name "manual"
Then a crud validation error should be raised
And the crud validation error message should mention "manual"
# ---- update_profile valid (lines 269-282) ----
Scenario: Update a custom profile with valid config
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I update profile "acme/custom" with description "Updated description"
Then the updated profile name should be "acme/custom"
And the updated profile description should be "Updated description"
And the mock repository should contain "acme/custom"
# ---- update_profile builtin name raises ValidationError (line 269-270) ----
Scenario: Update builtin profile raises ValidationError
Given a profile service with a mock repository
When I try to update builtin profile "auto"
Then a crud validation error should be raised
And the crud validation error message should mention "auto"
# ---- update_profile without repo raises NotFoundError (lines 275-276) ----
Scenario: Update profile without repository raises NotFoundError
Given a profile service without a repository
When I try to update profile "acme/custom" without repo
Then a crud not found error should be raised
And the crud not found error message should mention "acme/custom"
# ---- delete_profile valid (lines 294-304) ----
Scenario: Delete a custom profile with valid name
Given a profile service with a mock repository
And the mock repository contains a profile named "acme/custom"
When I delete profile "acme/custom"
Then the mock repository should not contain "acme/custom"
# ---- delete_profile builtin name raises ValidationError (lines 294-295) ----
Scenario: Delete builtin profile raises ValidationError
Given a profile service with a mock repository
When I try to delete builtin profile "supervised"
Then a crud validation error should be raised
And the crud validation error message should mention "supervised"
# ---- delete_profile without repo raises NotFoundError (lines 298-299) ----
Scenario: Delete profile without repository raises NotFoundError
Given a profile service without a repository
When I try to delete profile "acme/custom" without repo
Then a crud not found error should be raised
And the crud not found error message should mention "acme/custom"
+124
View File
@@ -0,0 +1,124 @@
Feature: Automation Profile Service
As a developer
I want to resolve automation profiles with precedence
So that plan execution autonomy is configured correctly
# ---- Precedence resolution ----
Scenario: Plan-level profile takes highest precedence
Given an automation profile service with global default "manual"
When I resolve profile with plan "full-auto" action "auto" project "supervised"
Then the resolved profile name should be "full-auto"
Scenario: Action-level profile used when plan is not set
Given an automation profile service with global default "manual"
When I resolve profile with plan None action "auto" project "supervised"
Then the resolved profile name should be "auto"
Scenario: Project-level profile used when plan and action are not set
Given an automation profile service with global default "manual"
When I resolve profile with plan None action None project "supervised"
Then the resolved profile name should be "supervised"
Scenario: Global default used when no level is set
Given an automation profile service with global default "cautious"
When I resolve profile with plan None action None project None
Then the resolved profile name should be "cautious"
Scenario: Default global is manual when nothing configured
Given an automation profile service with no configuration
When I resolve profile with plan None action None project None
Then the resolved profile name should be "manual"
# ---- Missing profile errors ----
Scenario: Missing profile raises NotFoundError
Given an automation profile service with global default "manual"
When I try to get profile "nonexistent-profile"
Then a profile not found error should be raised
And the profile service error should mention "nonexistent-profile"
Scenario: Missing profile in resolve raises NotFoundError
Given an automation profile service with global default "does-not-exist"
When I try to resolve profile with all None
Then a profile not found error should be raised
# ---- Legacy automation_level mapping ----
Scenario: Legacy manual maps to manual profile
Given an automation profile service with global default "manual"
When I map legacy level "manual"
Then the mapped profile name should be "manual"
Scenario: Legacy supervised maps to supervised profile
Given an automation profile service with global default "manual"
When I map legacy level "supervised"
Then the mapped profile name should be "supervised"
Scenario: Legacy auto maps to auto profile
Given an automation profile service with global default "manual"
When I map legacy level "auto"
Then the mapped profile name should be "auto"
Scenario: Legacy full_auto maps to full-auto profile
Given an automation profile service with global default "manual"
When I map legacy level "full_auto"
Then the mapped profile name should be "full-auto"
Scenario: Unknown legacy level raises ValidationError
Given an automation profile service with global default "manual"
When I try to map legacy level "unknown_level"
Then a legacy mapping validation error should be raised
And the validation error should mention "unknown_level"
Scenario: Resolve legacy level returns full profile
Given an automation profile service with global default "manual"
When I resolve legacy level "auto"
Then the resolved profile should have auto_apply 1.0
# ---- Environment variable override ----
Scenario: Env var overrides empty global default
Given an automation profile service with no configuration
And the env var CLEVERAGENTS_AUTOMATION_PROFILE is set to "ci"
When I resolve profile with plan None action None project None
Then the resolved profile name should be "ci"
Scenario: Explicit global default takes precedence over env var
Given an automation profile service with global default "review"
And the env var CLEVERAGENTS_AUTOMATION_PROFILE is set to "ci"
When I resolve profile with plan None action None project None
Then the resolved profile name should be "review"
# ---- CRUD via repository ----
Scenario: List profiles includes built-ins
Given an automation profile service with global default "manual"
When I list all profiles
Then the profile list should include "manual"
And the profile list should include "full-auto"
And the profile list should have at least 8 entries
# ---- Get built-in profile ----
Scenario: Get built-in profile by name
Given an automation profile service with global default "manual"
When I get profile "auto"
Then the retrieved profile name should be "auto"
And the retrieved profile auto_apply should be 1.0
# ---- Profile threshold checks ----
Scenario: Manual profile has all thresholds at 1.0
Given an automation profile service with global default "manual"
When I get profile "manual"
Then the retrieved profile auto_strategize should be 1.0
And the retrieved profile auto_execute should be 1.0
And the retrieved profile auto_apply should be 1.0
Scenario: Full-auto profile has all thresholds at 0.0
Given an automation profile service with global default "manual"
When I get profile "full-auto"
Then the retrieved profile auto_strategize should be 0.0
And the retrieved profile auto_execute should be 0.0
And the retrieved profile auto_apply should be 0.0
+125
View File
@@ -0,0 +1,125 @@
@phase1 @domain @tool_binding
Feature: Tool Resource Binding Resolution
As a system activating a tool within a plan
I want resource slots to be resolved to concrete resources
So that the tool has access to the correct resources at runtime
Background:
Given a mock resource registry
And a binding resolution service using the registry
# ---------------------------------------------------------------------------
# Contextual binding -- single match (auto-bind)
# ---------------------------------------------------------------------------
@contextual @single_match
Scenario: Contextual binding with single matching resource
Given a project "local/my-project" with a linked resource:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000001 | git-checkout | repo |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have mode "contextual"
And the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000001"
And the binding for slot "repo" should not be deferred
# ---------------------------------------------------------------------------
# Contextual binding -- multiple matches with alias hint
# ---------------------------------------------------------------------------
@contextual @alias_hint
Scenario: Contextual binding with multiple matches uses alias hint
Given a project "local/multi" with linked resources:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000010 | git-checkout | repo |
| 01HGZ6FE0AQDYTR4BX00000011 | git-checkout | backup |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000010"
# ---------------------------------------------------------------------------
# Contextual binding -- no match (error)
# ---------------------------------------------------------------------------
@contextual @no_match @error_handling
Scenario: Contextual binding with no matching resource raises error
Given a project "local/empty" with no linked resources
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "No resource of type"
# ---------------------------------------------------------------------------
# Static binding resolution
# ---------------------------------------------------------------------------
@static
Scenario: Static binding resolves named resource
Given a resource "local/shared-config" of type "fs-directory" in the registry
And a tool "local/deployer" with a static slot "config_dir" bound to "local/shared-config" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved
Then the binding for slot "config_dir" should have mode "static"
And the binding for slot "config_dir" should have resource_id "01HGZ6FE0AQDYTR4BX00000020"
And the binding for slot "config_dir" should not be deferred
# ---------------------------------------------------------------------------
# Parameter binding (deferred)
# ---------------------------------------------------------------------------
@parameter @deferred
Scenario: Parameter binding is deferred when no invocation params given
Given a tool "local/builder" with a parameter slot "target" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved
Then the binding for slot "target" should have mode "parameter"
And the binding for slot "target" should be deferred
And the binding for slot "target" should have no resource_id
# ---------------------------------------------------------------------------
# Type compatibility -- pass (sub-type)
# ---------------------------------------------------------------------------
@type_compat @pass
Scenario: Type compatibility passes for sub-type via parent_types
Given a project "local/typed" with a linked resource:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000030 | fs-directory | repo |
And the resource type "fs-directory" has parent_types including "git-checkout"
And a tool "local/compat" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000030"
# ---------------------------------------------------------------------------
# Type compatibility -- fail
# ---------------------------------------------------------------------------
@type_compat @fail @error_handling
Scenario: Type compatibility fails for incompatible types
Given a resource "local/wrong-type" of type "fs-file" in the registry
And the resource type "fs-file" has no parent_types
And a tool "local/strict" with a static slot "data" bound to "local/wrong-type" of type "git-checkout"
And a project "local/proj" with no linked resources
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "Type mismatch"
# ---------------------------------------------------------------------------
# Mixed bindings on same tool
# ---------------------------------------------------------------------------
@mixed
Scenario: Mixed bindings on same tool resolve correctly
Given a project "local/mixed" with a linked resource:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000040 | git-checkout | repo |
And a resource "local/shared-config" of type "fs-directory" in the registry
And a tool "local/multi-bind" with mixed slots:
| name | resource_type | binding | static_resource |
| repo | git-checkout | contextual | |
| config | fs-directory | static | local/shared-config |
| output | fs-directory | parameter | |
When the bindings are resolved
Then there should be 3 binding results
And the binding for slot "repo" should have mode "contextual"
And the binding for slot "config" should have mode "static"
And the binding for slot "output" should have mode "parameter"
And the binding for slot "output" should be deferred
@@ -0,0 +1,122 @@
@phase1 @domain @tool_binding @coverage
Feature: Binding Resolution Service - Coverage Gaps
As a developer ensuring binding resolution correctness
I want to cover edge cases in static, parameter, contextual,
disambiguation, and type-compatibility paths
So that every code path is exercised
Background:
Given a mock resource registry
And a binding resolution service using the registry
# ---------------------------------------------------------------------------
# Static binding -- unknown resource (lines 147-148)
# ---------------------------------------------------------------------------
@static @error_handling
Scenario: Static binding raises ValidationError for unknown resource
Given a tool "local/deployer" with a static slot "cfg" bound to "local/nonexistent" of type "fs-directory"
And a project "local/proj" with no linked resources
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "Static binding for slot"
# ---------------------------------------------------------------------------
# Parameter binding -- provided ref not found (lines 198-201)
# ---------------------------------------------------------------------------
@parameter @error_handling
Scenario: Parameter binding raises ValidationError for unknown ref
Given a tool "local/builder" with a parameter slot "target" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved with invocation params:
| slot | ref |
| target | local/does-not-exist |
Then a binding ValidationError should be raised mentioning "Parameter binding for slot"
# ---------------------------------------------------------------------------
# Parameter binding -- valid ref resolves (lines 209, 214)
# ---------------------------------------------------------------------------
@parameter @resolve
Scenario: Parameter binding resolves successfully with valid ref
Given a named resource "01HGZ6FE0AQDYTR4BX00000060" called "local/my-dir" of type "fs-directory" in the registry
And a tool "local/builder" with a parameter slot "target" of type "fs-directory"
And a project "local/proj" with no linked resources
When the bindings are resolved with invocation params:
| slot | ref |
| target | local/my-dir |
Then the binding for slot "target" should have mode "parameter"
And the binding for slot "target" should have resource_id "01HGZ6FE0AQDYTR4BX00000060"
And the binding for slot "target" should not be deferred
# ---------------------------------------------------------------------------
# Contextual binding -- no match, slot NOT required (line 274)
# ---------------------------------------------------------------------------
@contextual @optional
Scenario: Contextual binding returns None resource when slot is not required
Given a project "local/empty" with no linked resources
And a tool "local/reader" with an optional contextual slot "extras" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "extras" should have mode "contextual"
And the binding for slot "extras" should have no resource_id
And the binding for slot "extras" should not be deferred
# ---------------------------------------------------------------------------
# _find_matching_resources -- NotFoundError skipped (lines 298-299)
# ---------------------------------------------------------------------------
@contextual @not_found_skip
Scenario: Contextual binding skips linked resources that raise NotFoundError
Given a project "local/mixed" with linked resources including a missing one:
| resource_id | type_name | alias | exists |
| 01HGZ6FE0AQDYTR4BX00000070 | git-checkout | gone | false |
| 01HGZ6FE0AQDYTR4BX00000071 | git-checkout | repo | true |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000071"
And the binding for slot "repo" should not be deferred
# ---------------------------------------------------------------------------
# _disambiguate -- name suffix matching (lines 325-329)
# ---------------------------------------------------------------------------
@contextual @disambiguate @name_suffix
Scenario: Disambiguation matches slot name against resource name suffix
Given a project "local/multi" with named linked resources:
| resource_id | type_name | alias | resource_name |
| 01HGZ6FE0AQDYTR4BX00000080 | git-checkout | alpha | local/frontend/repo |
| 01HGZ6FE0AQDYTR4BX00000081 | git-checkout | beta | local/backend/other |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the bindings are resolved
Then the binding for slot "repo" should have resource_id "01HGZ6FE0AQDYTR4BX00000080"
And the binding for slot "repo" should have resource_name "local/frontend/repo"
# ---------------------------------------------------------------------------
# _disambiguate -- ambiguous (lines 337-338)
# ---------------------------------------------------------------------------
@contextual @disambiguate @ambiguous @error_handling
Scenario: Disambiguation raises ValidationError for ambiguous candidates
Given a project "local/multi" with named linked resources:
| resource_id | type_name | alias | resource_name |
| 01HGZ6FE0AQDYTR4BX00000090 | git-checkout | alpha | local/first |
| 01HGZ6FE0AQDYTR4BX00000091 | git-checkout | beta | local/second |
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "Ambiguous contextual binding"
# ---------------------------------------------------------------------------
# _is_type_compatible -- show_type raises NotFoundError (lines 369-370)
# ---------------------------------------------------------------------------
@type_compat @not_found
Scenario: Type compatibility returns False when show_type raises NotFoundError
Given a project "local/typed" with a linked resource:
| resource_id | type_name | alias |
| 01HGZ6FE0AQDYTR4BX00000095 | unknown-type | repo |
And the resource type "unknown-type" is removed from the registry
And a tool "local/reader" with a contextual slot "repo" of type "git-checkout"
And the slot "repo" is required
When the binding resolution is attempted
Then a binding ValidationError should be raised mentioning "No resource of type"
+124
View File
@@ -0,0 +1,124 @@
@unit
Feature: ChangeSet capture and domain model
As a developer using CleverAgents
I want file tool mutations to be captured in a ChangeSet
So that changes can be reviewed, diffed, and applied safely
# ---- ChangeOperation enum ----
Scenario: ChangeOperation enum has expected members
Given I import the ChangeOperation enum
Then it should have members CREATE, MODIFY, DELETE, RENAME
# ---- ChangeEntry model ----
Scenario: Create a ChangeEntry with all fields
Given I import the ChangeEntry model
When I create a ChangeEntry for a create operation
Then the entry should have a ULID entry_id
And the entry should have operation "create"
And the entry should have a UTC timestamp
And the before_hash should be None
Scenario: ChangeEntry for modify operation has before and after hashes
Given I import the ChangeEntry model
When I create a ChangeEntry for a modify operation with hashes
Then the before_hash should not be None
And the after_hash should not be None
Scenario: ChangeEntry for delete operation has no after_hash
Given I import the ChangeEntry model
When I create a ChangeEntry for a delete operation
Then the after_hash should be None
And the before_hash should not be None
Scenario: ChangeEntry for rename operation
Given I import the ChangeEntry model
When I create a ChangeEntry for a rename operation
Then the entry should have operation "rename"
# ---- SpecChangeSet model ----
Scenario: SpecChangeSet summary counts are correct
Given I import the SpecChangeSet model
When I create a SpecChangeSet with mixed operations
Then the creates count should be 1
And the modifies count should be 2
And the deletes count should be 1
And the renames count should be 1
And paths_changed should have 5 entries
And resources_involved should have 2 entries
Scenario: SpecChangeSet summary dict
Given I import the SpecChangeSet model
When I create a SpecChangeSet with mixed operations
Then the summary dict should have correct totals
Scenario: Empty SpecChangeSet has zero counts
Given I import the SpecChangeSet model
When I create an empty SpecChangeSet
Then the creates count should be 0
And the modifies count should be 0
And the deletes count should be 0
And the renames count should be 0
# ---- InMemoryChangeSetStore ----
Scenario: InMemoryChangeSetStore start/record/get flow
Given I have an InMemoryChangeSetStore
When I start a changeset for plan "plan-abc"
And I record a create entry in the changeset
Then I can get the changeset by ID
And the store changeset should have 1 entry
Scenario: InMemoryChangeSetStore get_for_plan
Given I have an InMemoryChangeSetStore
When I start a changeset for plan "plan-abc"
And I start a changeset for plan "plan-abc"
And I start a changeset for plan "plan-xyz"
Then get_for_plan "plan-abc" should return 2 changesets
And get_for_plan "plan-xyz" should return 1 changeset
Scenario: InMemoryChangeSetStore summarize
Given I have an InMemoryChangeSetStore
When I start a changeset for plan "plan-s"
And I record a create entry in the changeset
And I record a modify entry in the changeset
Then the summarized changeset should show 2 total
Scenario: InMemoryChangeSetStore get returns None for unknown ID
Given I have an InMemoryChangeSetStore
Then getting a non-existent changeset returns None
Scenario: InMemoryChangeSetStore record raises on unknown ID
Given I have an InMemoryChangeSetStore
Then recording to a non-existent changeset raises KeyError
Scenario: InMemoryChangeSetStore summarize returns empty for unknown
Given I have an InMemoryChangeSetStore
Then summarizing a non-existent changeset returns empty dict
# ---- ChangeSetCapture integration ----
Scenario: ChangeSetCapture records resource_id and tool_name
Given I have a ChangeSetCapture with resource_id "res-1"
When I create a ChangeSetEntry via capture
Then the entry resource_id should be "res-1"
And the entry tool_name should be set
Scenario: ChangeSetCapture normalizes paths
Given I have a ChangeSetCapture with sandbox_root
When I capture a write to a nested path
Then the captured path should be repo-relative
Scenario: ChangeSetCapture converts to spec changeset
Given I have a ChangeSetCapture with resource_id "res-2"
When I add several entries via capture
Then to_spec_changeset should return a SpecChangeSet
# ---- Multi-resource plan ----
Scenario: Multi-resource plan captures correctly
Given I have two ChangeSetCapture instances for different resources
When each capture records changes for its resource
Then each changeset should only have its resource entries
+21
View File
@@ -62,6 +62,27 @@ def before_scenario(context, scenario):
# Ensure mock AI flag is always set so plan service tests can resolve actors
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
# Re-establish protective env vars every scenario. These are set once
# in before_all, but individual scenarios (e.g. migration_runner tests)
# temporarily remove them. If any cleanup path fails to restore them
# the remaining scenarios in a serial run would be affected.
os.environ["BEHAVE_TESTING"] = "true"
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
# Flush the in-memory engine cache so no stale engines leak between
# scenarios. In serial mode (coverage_report) every feature shares
# the same process, so a previous scenario's real-or-fake engine can
# survive into the next one.
try:
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
for _url, engine in list(MEMORY_ENGINES.items()):
with contextlib.suppress(Exception):
engine.dispose()
MEMORY_ENGINES.clear()
except ImportError:
pass
# Clean up any lingering test environment variables from previous tests
for env_var in [
"CLEVERAGENTS_MOCK_SHOULD_FAIL",
+106
View File
@@ -0,0 +1,106 @@
Feature: Project CLI coverage boost for uncovered error paths
As a developer
I want to exercise the uncovered error-handling branches in project.py
So that line and branch coverage improves for cli/commands/project.py
Background:
Given the project CLI coverage mocks are prepared
# ── _get_resource_link_repo / _get_resource_registry_service helpers ──
Scenario: _get_resource_link_repo returns a link repo from the container
When I call the real _get_resource_link_repo helper
Then the coverage link repo helper should return successfully
Scenario: _get_resource_registry_service returns a registry service from the container
When I call the real _get_resource_registry_service helper
Then the coverage registry service helper should return successfully
# ── create command: resource linking raises NotFoundError ──
Scenario: create command warns when resource linking raises NotFoundError
Given the project repo mock is configured for successful create
And the link repo mock raises NotFoundError on create_link
When I invoke the CLI create command with name "notfound-proj" and resource "bad-res"
Then the coverage CLI result should contain "Warning"
And the coverage CLI exit code should be 0
# ── create command: resource linking raises DatabaseError ──
Scenario: create command warns when resource linking raises DatabaseError
Given the project repo mock is configured for successful create
And the link repo mock raises DatabaseError on create_link
When I invoke the CLI create command with name "dblink-proj" and resource "fail-res"
Then the coverage CLI result should contain "Warning"
And the coverage CLI exit code should be 0
# ── create command: re-fetch project raises generic Exception ──
Scenario: create command falls back when re-fetching project raises Exception
Given the project repo mock is configured for create but get raises Exception
When I invoke the CLI create command with name "refetch-proj" without resources
Then the coverage CLI result should contain "created"
And the coverage CLI exit code should be 0
# ── link-resource command: create_link raises DatabaseError ──
Scenario: link-resource command fails when create_link raises DatabaseError
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock raises DatabaseError on create_link for link-resource
When I invoke the CLI link-resource command for project "local/linkdb-proj" resource "some-res"
Then the coverage CLI result should contain "Error linking resource"
And the coverage CLI exit code should be 1
# ── unlink-resource command: user declines confirmation ──
Scenario: unlink-resource command aborts when user declines confirmation
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock returns a matching link for unlink
When I invoke the CLI unlink-resource command without yes and user declines
Then the coverage CLI exit code should be 1
# ── unlink-resource command: remove_link raises DatabaseError ──
Scenario: unlink-resource command fails when remove_link raises DatabaseError
Given the project repo mock returns a valid project for get
And the registry mock returns a valid resource
And the link repo mock returns a matching link for unlink
And the link repo mock raises DatabaseError on remove_link
When I invoke the CLI unlink-resource command with yes
Then the coverage CLI result should contain "Error unlinking resource"
And the coverage CLI exit code should be 1
# ── list command: list_projects raises DatabaseError ──
Scenario: list command fails when list_projects raises DatabaseError
Given the project repo mock raises DatabaseError on list_projects
When I invoke the CLI list command
Then the coverage CLI result should contain "Error listing projects"
And the coverage CLI exit code should be 1
# ── delete command: user declines confirmation ──
Scenario: delete command aborts when user declines confirmation
Given the project repo mock returns a project with no linked resources
When I invoke the CLI delete command without yes and user declines
Then the coverage CLI exit code should be 1
# ── delete command: repo.delete raises DatabaseError ──
Scenario: delete command fails when repo.delete raises DatabaseError
Given the project repo mock returns a project with no linked resources
And the project repo mock raises DatabaseError on delete
When I invoke the CLI delete command with yes
Then the coverage CLI result should contain "Error deleting project"
And the coverage CLI exit code should be 1
# ── delete command: repo.delete returns False ──
Scenario: delete command fails when repo.delete returns False
Given the project repo mock returns a project with no linked resources
And the project repo mock returns False on delete
When I invoke the CLI delete command with yes
Then the coverage CLI result should contain "could not be deleted"
And the coverage CLI exit code should be 1
+114
View File
@@ -0,0 +1,114 @@
Feature: Project context CLI commands (B2.cli)
As a CleverAgents user
I want to manage project context policies via CLI commands
So that I can control what resources and files are visible per ACMS phase
Background:
Given a project context CLI in-memory database is initialized
And a project "local/ctx-app" exists for context CLI
# ── context set basic options ────────────────────────────────
Scenario: Set default view with include resources
When I run context set on "local/ctx-app" with view "default" and include-resource "db-*"
Then the context set command should succeed
And the stored policy default view should include resource "db-*"
Scenario: Set default view with exclude resources
When I run context set on "local/ctx-app" with view "default" and exclude-resource "db-test"
Then the context set command should succeed
And the stored policy default view should exclude resource "db-test"
Scenario: Set default view with include paths
When I run context set on "local/ctx-app" with view "default" and include-path "src/**/*.py"
Then the context set command should succeed
And the stored policy default view should include path "src/**/*.py"
Scenario: Set default view with exclude paths
When I run context set on "local/ctx-app" with view "default" and exclude-path "*.pyc"
Then the context set command should succeed
And the stored policy default view should exclude path "*.pyc"
Scenario: Set default view with max file size
When I run context set on "local/ctx-app" with view "default" and max-file-size 1048576
Then the context set command should succeed
And the stored policy default view max file size should be 1048576
Scenario: Set default view with max total size
When I run context set on "local/ctx-app" with view "default" and max-total-size 10485760
Then the context set command should succeed
And the stored policy default view max total size should be 10485760
Scenario: Set strategize view
When I run context set on "local/ctx-app" with view "strategize" and include-resource "cache-*"
Then the context set command should succeed
And the stored policy strategize view should include resource "cache-*"
Scenario: Set invalid view name fails
When I run context set on "local/ctx-app" with invalid view "bogus"
Then the context set command should fail
Scenario: Set view on nonexistent project fails
When I run context set on "local/no-such-project" with view "default" and include-resource "x"
Then the context set command should fail
# ── context set --clear ─────────────────────────────────────
Scenario: Clear a view resets it to inherit
Given I have set a strategize view on "local/ctx-app" with defaults
When I run context set on "local/ctx-app" with view "strategize" and clear flag
Then the context set command should succeed
And the stored policy strategize view should be None
# ── context show ─────────────────────────────────────────────
Scenario: Show displays the policy for a project
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" without options
Then the context show command should succeed
Scenario: Show with view displays resolved view
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" with view "default"
Then the context show command should succeed
Scenario: Show on nonexistent project fails
When I run context show on "local/nonexistent-proj" without options
Then the context show command should fail
# ── inspect raises NotImplementedError ──────────────────────
Scenario: Inspect raises NotImplementedError with clear message
When I run context inspect on "local/ctx-app"
Then a NotImplementedError should be raised with message containing "ACMS"
# ── simulate raises NotImplementedError ─────────────────────
Scenario: Simulate raises NotImplementedError with clear message
When I run context simulate on "local/ctx-app"
Then a NotImplementedError should be raised with message containing "ACMS"
# ── view inheritance ─────────────────────────────────────────
Scenario: Execute inherits from strategize when not overridden
Given I have set a strategize view on "local/ctx-app" with include-resource "strat-res"
When I run context show on "local/ctx-app" with view "execute"
Then the resolved view should include resource "strat-res"
Scenario: Apply inherits from execute when not overridden
Given I have set an execute view on "local/ctx-app" with include-resource "exec-res"
When I run context show on "local/ctx-app" with view "apply"
Then the resolved view should include resource "exec-res"
Scenario: Strategize inherits from default when not overridden
Given I have set a default view on "local/ctx-app" with include-resource "def-res"
When I run context show on "local/ctx-app" with view "strategize"
Then the resolved view should include resource "def-res"
# ── JSON format output ──────────────────────────────────────
Scenario: Show with JSON format returns valid JSON
Given I have set a default view on "local/ctx-app" with include-resource "db-*"
When I run context show on "local/ctx-app" with format "json"
Then the context show command should succeed
And the context output should be valid JSON
+148
View File
@@ -0,0 +1,148 @@
Feature: Project Context Policy Domain Model
As a developer
I want a project context policy model with view inheritance
So that context filtering can be configured per ACMS phase
# ---- Empty policy defaults ----
Scenario: Empty policy defaults to including everything
When I create an empty project context policy
Then the default view should include all resources
And the default view should include all paths
And the default view should have no file size limit
And the default view should have no total size limit
# ---- View inheritance ----
Scenario: Strategize inherits from default when not overridden
Given a policy with only a default view
When I resolve the view for phase "strategize"
Then the resolved view should be the default view
Scenario: Execute inherits from strategize when not overridden
Given a policy with default and strategize views
When I resolve the view for phase "execute"
Then the resolved view should be the strategize view
Scenario: Apply inherits from execute when not overridden
Given a policy with default strategize and execute views
When I resolve the view for phase "apply"
Then the resolved view should be the execute view
Scenario: Execute inherits from default when strategize is None
Given a policy with only a default view
When I resolve the view for phase "execute"
Then the resolved view should be the default view
Scenario: Apply falls through to default when all overrides are None
Given a policy with only a default view
When I resolve the view for phase "apply"
Then the resolved view should be the default view
Scenario: Resolve default phase returns default view
Given a policy with only a default view
When I resolve the view for phase "default"
Then the resolved view should be the default view
# ---- Override isolation ----
Scenario: Override at execute level does not affect strategize
Given a policy with a custom execute view
When I resolve the view for phase "strategize"
Then the resolved view should be the default view
Scenario: Override at execute returns execute view
Given a policy with a custom execute view
When I resolve the view for phase "execute"
Then the resolved view should be the execute view
# ---- Invalid phase name ----
Scenario: Invalid phase name raises error
Given a policy with only a default view
When I try to resolve the view for phase "invalid_phase"
Then a context policy error should be raised
And the context policy error should mention "Invalid phase"
Scenario: Unknown phase name raises error
Given a policy with only a default view
When I try to resolve the view for phase "plan"
Then a context policy error should be raised
And the context policy error should mention "Invalid phase"
# ---- Include/exclude resource patterns ----
Scenario: Include resources filters correctly
When I create a context view with include resources "db-*,cache-*"
Then the context view should have 2 include resources
Scenario: Exclude resources filters correctly
When I create a context view with exclude resources "temp-*"
Then the context view should have 1 exclude resource
Scenario: Combined include and exclude resources
When I create a context view with include "db-*" and exclude "db-test"
Then the context view should have 1 include resource
And the context view should have 1 exclude resource
# ---- Include/exclude path globs ----
Scenario: Include paths accepts globs
When I create a context view with include paths "src/**/*.py,tests/**"
Then the context view should have 2 include paths
Scenario: Exclude paths accepts globs
When I create a context view with exclude paths "*.pyc,__pycache__/**"
Then the context view should have 2 exclude paths
# ---- Size limit validation ----
Scenario: Valid max file size is accepted
When I create a context view with max file size 1048576
Then the context view max file size should be 1048576
Scenario: None max file size means no limit
When I create a context view with no file size limit
Then the context view max file size should be None
Scenario: Zero max file size raises error
When I try to create a context view with max file size 0
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Negative max file size raises error
When I try to create a context view with max file size -100
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Valid max total size is accepted
When I create a context view with max total size 10485760
Then the context view max total size should be 10485760
Scenario: Zero max total size raises error
When I try to create a context view with max total size 0
Then a context policy error should be raised
And the context policy error should mention "positive integer"
Scenario: Negative max total size raises error
When I try to create a context view with max total size -50
Then a context policy error should be raised
And the context policy error should mention "positive integer"
# ---- Serialization round-trip ----
Scenario: Policy survives JSON round-trip
Given a fully populated context policy
When I serialize and deserialize the policy
Then the deserialized policy should match the original
# ---- ContextView model_dump ----
Scenario: ContextView model_dump has expected keys
When I create a context view with defaults
Then the context view dump should have key "include_resources"
And the context view dump should have key "exclude_resources"
And the context view dump should have key "include_paths"
And the context view dump should have key "exclude_paths"
And the context view dump should have key "max_file_size"
And the context view dump should have key "max_total_size"
@@ -0,0 +1,505 @@
@phase1 @domain @repository @error_handling_coverage
Feature: Repository error handling coverage for major repository classes
As a system operating under unstable database conditions
I want all repository error-handling paths to be covered
So that OperationalError, IntegrityError, and DatabaseError paths are exercised
# ==========================================================================
# NamespacedProjectRepository error handling
# ==========================================================================
# --- create: IntegrityError (UNIQUE) -> DatabaseError "already exists" ----
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on UNIQUE IntegrityError
Given a namespaced project repository with session raising UNIQUE IntegrityError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "already exists"
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a namespaced project repository with session raising non-UNIQUE IntegrityError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create project"
# --- create: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository create raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on flush
When a namespaced project is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create project"
# --- get: OperationalError -> DatabaseError -------------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository get raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get project"
# --- list_projects: OperationalError -> DatabaseError ---------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository list_projects raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When namespaced projects are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list projects"
# --- update: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository update raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update project"
# --- delete: OperationalError -> DatabaseError ----------------------------
@project_repo @transient_error
Scenario: NamespacedProjectRepository delete raises DatabaseError on OperationalError
Given a namespaced project repository with session raising OperationalError on query
When a namespaced project is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete project"
# ==========================================================================
# ToolRepository error handling
# ==========================================================================
# --- add: IntegrityError (UNIQUE) -> DuplicateToolError -------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DuplicateToolError on UNIQUE IntegrityError
Given a tool repository with session raising UNIQUE IntegrityError on flush
When a tool is added and a repo error is expected
Then a repo DuplicateToolError should be raised
# --- add: IntegrityError (non-UNIQUE) -> DatabaseError --------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DatabaseError on non-UNIQUE IntegrityError
Given a tool repository with session raising non-UNIQUE IntegrityError on flush
When a tool is added and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to add tool"
# --- add: OperationalError -> DatabaseError -------------------------------
@tool_repo @transient_error
Scenario: ToolRepository add raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on flush
When a tool is added and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to add tool"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@tool_repo @transient_error
Scenario: ToolRepository get_by_name raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a tool is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get tool by name"
# --- list_all: OperationalError -> DatabaseError --------------------------
@tool_repo @transient_error
Scenario: ToolRepository list_all raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When all tools are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list tools"
# --- update: ToolNotFoundError (not found) --------------------------------
@tool_repo @not_found
Scenario: ToolRepository update raises ToolNotFoundError when tool not found
Given a tool repository backed by a clean in-memory database
When a non-existent tool is updated and a repo error is expected
Then a repo ToolNotFoundError should be raised
# --- update: OperationalError -> DatabaseError ----------------------------
@tool_repo @transient_error
Scenario: ToolRepository update raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a non-existent tool is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update tool"
# --- remove: ToolNotFoundError (not found) --------------------------------
@tool_repo @not_found
Scenario: ToolRepository remove raises ToolNotFoundError when tool not found
Given a tool repository backed by a clean in-memory database
When a non-existent tool is removed and a repo error is expected
Then a repo ToolNotFoundError should be raised
# --- remove: OperationalError -> DatabaseError ----------------------------
@tool_repo @transient_error
Scenario: ToolRepository remove raises DatabaseError on OperationalError
Given a tool repository with session raising OperationalError on query
When a non-existent tool is removed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to remove tool"
# ==========================================================================
# AutomationProfileRepository error handling
# ==========================================================================
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository get_by_name raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get profile"
# --- list_all: OperationalError -> DatabaseError --------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository list_all raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When all automation profiles are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list profiles"
# --- upsert: OperationalError -> DatabaseError ----------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository upsert raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is upserted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to upsert profile"
# --- delete: OperationalError -> DatabaseError ----------------------------
@profile_repo @transient_error
Scenario: AutomationProfileRepository delete raises DatabaseError on OperationalError
Given an automation profile repository with session raising OperationalError on query
When an automation profile is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete profile"
# ==========================================================================
# ResourceTypeRepository error handling
# ==========================================================================
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a resource type repository with session that passes query but raises non-UNIQUE IntegrityError on flush
When a resource type is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource type"
# --- create: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository create raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on flush
When a resource type is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource type"
# --- get: OperationalError -> DatabaseError -------------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository get raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource type"
# --- list_types: OperationalError -> DatabaseError ------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository list_types raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When resource types are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list resource types"
# --- update: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository update raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update resource type"
# --- delete: OperationalError -> DatabaseError ----------------------------
@resource_type_repo @transient_error
Scenario: ResourceTypeRepository delete raises DatabaseError on OperationalError
Given a resource type repository with session raising OperationalError on query
When a resource type is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete resource type"
# ==========================================================================
# ResourceRepository error handling
# ==========================================================================
# --- create: IntegrityError (UNIQUE) -> DuplicateResourceError ------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DuplicateResourceError on UNIQUE IntegrityError
Given a resource repository with session that passes initial queries but raises UNIQUE IntegrityError on flush
When a resource is created and a repo error is expected
Then a repo DuplicateResourceError should be raised
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a resource repository with session that passes initial queries but raises non-UNIQUE IntegrityError on flush
When a resource is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource"
# --- create: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository create raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on flush
When a resource is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create resource"
# --- get: OperationalError -> DatabaseError -------------------------------
@resource_repo @transient_error
Scenario: ResourceRepository get raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is fetched by ID and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@resource_repo @transient_error
Scenario: ResourceRepository get_by_name raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get resource by name"
# --- list_resources: OperationalError -> DatabaseError --------------------
@resource_repo @transient_error
Scenario: ResourceRepository list_resources raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resources are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list resources"
# --- update: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository update raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update resource"
# --- delete: OperationalError -> DatabaseError ----------------------------
@resource_repo @transient_error
Scenario: ResourceRepository delete raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete resource"
# --- resolve_namespaced_name: OperationalError -> DatabaseError -----------
@resource_repo @transient_error
Scenario: ResourceRepository resolve_namespaced_name raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource is resolved by name-or-id and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to resolve resource"
# ==========================================================================
# ProjectResourceLinkRepository error handling
# ==========================================================================
# --- create_link: IntegrityError -> DatabaseError -------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository create_link raises DatabaseError on IntegrityError
Given a project resource link repository with session that passes query but raises IntegrityError on flush
When a project resource link is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create link"
# --- create_link: OperationalError -> DatabaseError -----------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository create_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on flush
When a project resource link is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create link"
# --- list_links: OperationalError -> DatabaseError ------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository list_links raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When project resource links are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list links"
# --- get_link: OperationalError -> DatabaseError --------------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository get_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When a project resource link is fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get link"
# --- remove_link: OperationalError -> DatabaseError -----------------------
@link_repo @transient_error
Scenario: ProjectResourceLinkRepository remove_link raises DatabaseError on OperationalError
Given a project resource link repository with session raising OperationalError on query
When a project resource link is removed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to remove link"
# ==========================================================================
# ValidationAttachmentRepository error handling
# ==========================================================================
# --- attach: IntegrityError (UNIQUE) -> DuplicateValidationAttachmentError
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DuplicateValidationAttachmentError on UNIQUE IntegrityError
Given a validation attachment repository with session that passes query but raises UNIQUE IntegrityError on flush
When a validation is attached and a repo error is expected
Then a repo DuplicateValidationAttachmentError should be raised
# --- attach: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DatabaseError on non-UNIQUE IntegrityError
Given a validation attachment repository with session that passes query but raises non-UNIQUE IntegrityError on flush
When a validation is attached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to attach validation"
# --- attach: OperationalError -> DatabaseError ----------------------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository attach raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on flush
When a validation is attached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to attach validation"
# --- detach: OperationalError -> DatabaseError ----------------------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository detach raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on query
When a validation is detached and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to detach validation"
# --- list_for_resource: OperationalError -> DatabaseError -----------------
@validation_repo @transient_error
Scenario: ValidationAttachmentRepository list_for_resource raises DatabaseError on OperationalError
Given a validation attachment repository with session raising OperationalError on query
When validation attachments are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list attachments"
# ==========================================================================
# LifecyclePlanRepository error handling
# ==========================================================================
# --- create: IntegrityError (non-UNIQUE) -> DatabaseError -----------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository create raises DatabaseError on non-UNIQUE IntegrityError
Given a lifecycle plan repository with session raising non-UNIQUE IntegrityError on flush
When a lifecycle plan is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create plan"
# --- create: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository create raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on flush
When a lifecycle plan is created and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to create plan"
# --- get: OperationalError -> DatabaseError -------------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository get raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is fetched by ID and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get plan"
# --- get_by_name: OperationalError -> DatabaseError -----------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository get_by_name raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is fetched by name and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get plan by name"
# --- update: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository update raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is updated and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to update plan"
# --- delete: OperationalError -> DatabaseError ----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository delete raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When a lifecycle plan is deleted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to delete plan"
# --- list_plans: OperationalError -> DatabaseError ------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository list_plans raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When lifecycle plans are listed and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to list plans"
# --- count: OperationalError -> DatabaseError -----------------------------
@plan_repo @transient_error
Scenario: LifecyclePlanRepository count raises DatabaseError on OperationalError
Given a lifecycle plan repository with session raising OperationalError on query
When lifecycle plans are counted and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to count plans"
# ==========================================================================
# ResourceRepository DAG operations error handling
# ==========================================================================
# --- link_child: IntegrityError -> DatabaseError --------------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository link_child raises DatabaseError on IntegrityError
Given a resource repository with session that has resources but raises IntegrityError on link flush
When a resource child is linked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to link"
# --- link_child: OperationalError -> DatabaseError ------------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository link_child raises DatabaseError on OperationalError during link
Given a resource repository with session that has resources but raises OperationalError on link flush
When a resource child is linked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to link"
# --- unlink_child: OperationalError -> DatabaseError ----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository unlink_child raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When a resource child is unlinked and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to unlink"
# --- get_children: OperationalError -> DatabaseError ----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository get_children raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resource children are fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get children"
# --- get_parents: OperationalError -> DatabaseError -----------------------
@resource_repo @dag @transient_error
Scenario: ResourceRepository get_parents raises DatabaseError on OperationalError
Given a resource repository with session raising OperationalError on query
When resource parents are fetched and a repo error is expected
Then a repo DatabaseError should be raised containing "Failed to get parents"
@@ -0,0 +1,349 @@
@unit
Feature: Repository uncovered lines coverage
Target the remaining uncovered lines in repositories.py to boost
line and branch coverage.
# -------------------------------------------------------------------
# LinkNotFoundError (lines 1595-1598)
# -------------------------------------------------------------------
Scenario: LinkNotFoundError stores parent and child IDs
Given a LinkNotFoundError for parent "P1" and child "C1"
Then the LinkNotFoundError message should contain "P1" and "C1"
And the LinkNotFoundError parent_id should be "P1"
And the LinkNotFoundError child_id should be "C1"
# -------------------------------------------------------------------
# ResourceTypeRepository DuplicateResourceTypeError from UNIQUE IntegrityError (line 1708)
# -------------------------------------------------------------------
Scenario: ResourceTypeRepository create raises DuplicateResourceTypeError on UNIQUE IntegrityError
Given a resource type repository backed by an in-memory database for uncovered lines
And a resource type "test/dup-type" exists in the database for uncovered lines
When the same resource type "test/dup-type" is created again for uncovered lines
Then a DuplicateResourceTypeError should be raised for uncovered lines
# -------------------------------------------------------------------
# ResourceTypeRepository._to_domain equivalence_json not None (line 1923)
# -------------------------------------------------------------------
Scenario: ResourceTypeRepository _to_domain parses equivalence_json when present
Given a resource type repository backed by an in-memory database for uncovered lines
And a resource type row with equivalence_json set to '{"field": "name"}'
When the resource type is retrieved by name "test/equiv-type" for uncovered lines
Then the resource type equivalence should contain key "field"
# -------------------------------------------------------------------
# ResourceRepository.list_resources namespace filter (line 2114)
# -------------------------------------------------------------------
Scenario: ResourceRepository list_resources filters by namespace
Given a resource repository backed by an in-memory database for uncovered lines
And resources exist in namespace "ns-alpha" and "ns-beta" for uncovered lines
When resources are listed with namespace "ns-alpha" for uncovered lines
Then only resources from namespace "ns-alpha" should be returned
# -------------------------------------------------------------------
# ResourceRepository.unlink_child parent not found (line 2385)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises error when parent not found
Given a resource repository backed by an in-memory database for uncovered lines
When unlink_child is called with non-existent parent "NOPARENT" and child "NOCHILD" for uncovered lines
Then a ResourceNotFoundRepoError should be raised for the unlink parent
# -------------------------------------------------------------------
# ResourceRepository.unlink_child child not found (line 2391)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises error when child not found
Given a resource repository backed by an in-memory database for uncovered lines
And a resource "parent-res" exists for uncovered unlink tests
When unlink_child is called with existing parent and non-existent child "NOCHILD" for uncovered lines
Then a ResourceNotFoundRepoError should be raised for the unlink child
# -------------------------------------------------------------------
# ResourceRepository.unlink_child link not found (line 2399)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child raises LinkNotFoundError when no link exists
Given a resource repository backed by an in-memory database for uncovered lines
And two resources "parent-ul" and "child-ul" exist but are not linked for uncovered lines
When unlink_child is called for "parent-ul" and "child-ul" for uncovered lines
Then a LinkNotFoundError should be raised for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository.unlink_child re-raise domain errors (line 2407)
# -------------------------------------------------------------------
Scenario: ResourceRepository unlink_child re-raises domain errors unchanged
Given a resource repository backed by an in-memory database for uncovered lines
When unlink_child is called with non-existent parent "GHOST" and child "GHOST2" for uncovered lines re-raise
Then a ResourceNotFoundRepoError should be raised and re-raised for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository.auto_discover_children type_row is None (line 2524)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when parent type not in DB
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type "missing-type" that has no type row in DB for uncovered lines
When auto_discover_children is called for that resource for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children auto_disc not enabled (lines 2528-2533)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when auto_discover_json is null
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has null auto_discover_json for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines null
Then an empty list should be returned from auto_discover for uncovered lines
Scenario: auto_discover_children returns empty when auto_discover is disabled
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has auto_discover disabled for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines disabled
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children rules empty (lines 2535-2537)
# -------------------------------------------------------------------
Scenario: auto_discover_children returns empty when rules list is empty
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with type that has auto_discover enabled but empty rules for uncovered lines
When auto_discover_children is called for that typed resource for uncovered lines empty rules
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children child type not in DB (line 2560)
# -------------------------------------------------------------------
Scenario: auto_discover_children skips rule when child type not in DB
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules referencing non-existent child type for uncovered lines
When auto_discover_children is called for that resource with missing child type for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children child type not in allowed list (line 2564)
# -------------------------------------------------------------------
Scenario: auto_discover_children skips rule when child type not in allowed list
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules where child type is not in allowed list for uncovered lines
When auto_discover_children is called for that resource with disallowed child type for uncovered lines
Then an empty list should be returned from auto_discover for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children successful discovery (lines 2567-2616)
# -------------------------------------------------------------------
Scenario: auto_discover_children creates child resources and links
Given a resource repository backed by an in-memory database for uncovered lines
And a resource with auto_discover rules that match an existing child type for uncovered lines
When auto_discover_children is called for that resource for full discovery for uncovered lines
Then the discovered children list should not be empty for uncovered lines
And the child resource should be linked to the parent for uncovered lines
# -------------------------------------------------------------------
# auto_discover_children OperationalError (lines 2619-2624)
# -------------------------------------------------------------------
Scenario: auto_discover_children wraps OperationalError in DatabaseError
Given a resource repository with session raising OperationalError on auto_discover for uncovered lines
When auto_discover_children is called and an OperationalError occurs for uncovered lines
Then a DatabaseError should be raised mentioning auto-discover for uncovered lines
# -------------------------------------------------------------------
# _get_ancestors current in visited branch (line 2641)
# -------------------------------------------------------------------
Scenario: _get_ancestors handles cycles in visited set
Given a resource repository backed by an in-memory database for uncovered lines
And resources forming a diamond graph for ancestor traversal for uncovered lines
When _get_ancestors is called on the bottom resource for uncovered lines
Then all ancestor IDs should be returned including the bottom resource for uncovered lines
# -------------------------------------------------------------------
# ResourceRepository._to_domain sandbox_strategy not None (line 2707)
# -------------------------------------------------------------------
Scenario: ResourceRepository _to_domain converts sandbox_strategy when present
Given a resource repository backed by an in-memory database for uncovered lines
And a resource row with sandbox_strategy set to "git_worktree" for uncovered lines
When the resource is retrieved by ID for uncovered lines sandbox
Then the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE
# -------------------------------------------------------------------
# ToolRepository.add invalid tool_type (line 3174)
# -------------------------------------------------------------------
Scenario: ToolRepository add raises InvalidToolTypeError for bad tool_type
Given a tool repository backed by an in-memory database for uncovered lines
And a tool domain object with tool_type "bogus" for uncovered lines
When the tool is added to the repository for uncovered lines invalid type
Then an InvalidToolTypeError should be raised for uncovered lines
# -------------------------------------------------------------------
# ToolRepository.add schema serialization + resource slots (lines 3196-3261)
# -------------------------------------------------------------------
Scenario: ToolRepository add persists input/output schemas and resource slots
Given a tool repository backed by an in-memory database for uncovered lines
And a tool domain object with schemas and resource slots for uncovered lines
When the tool is added to the repository for uncovered lines with schemas
Then the tool should be retrievable and have schemas and bindings for uncovered lines
# -------------------------------------------------------------------
# ToolRepository.get fetch by ULID (lines 3286-3293)
# -------------------------------------------------------------------
Scenario: ToolRepository get returns tool by ULID
Given a tool repository backed by an in-memory database for uncovered lines
And a tool has been persisted and its ULID captured for uncovered lines
When the tool is fetched by ULID for uncovered lines
Then the tool domain object should be returned for uncovered lines
Scenario: ToolRepository get returns None for unknown ULID
Given a tool repository backed by an in-memory database for uncovered lines
When a tool is fetched by ULID "00000000000000000000000000" for uncovered lines
Then None should be returned for the tool get for uncovered lines
# -------------------------------------------------------------------
# ToolRepository._to_domain input/output schema JSON parse (lines 3433, 3438)
# -------------------------------------------------------------------
Scenario: ToolRepository _to_domain parses input and output schemas
Given a tool repository backed by an in-memory database for uncovered lines
And a tool with input_schema and output_schema stored as JSON for uncovered lines
When the tool is retrieved by name for uncovered lines schemas
Then the tool should have parsed input_schema and output_schema for uncovered lines
# -------------------------------------------------------------------
# ValidationAttachment.attach duplicate detection (lines 3517-3520)
# -------------------------------------------------------------------
Scenario: ValidationAttachment attach raises DuplicateValidationAttachmentError on duplicate
Given a validation attachment repository backed by an in-memory database for uncovered lines
And a validation "val-check" is already attached to resource "res-1" for uncovered lines
When the same validation "val-check" is attached again to resource "res-1" for uncovered lines
Then a DuplicateValidationAttachmentError should be raised for uncovered lines attach
# -------------------------------------------------------------------
# ValidationAttachment.attach re-raise (line 3538)
# -------------------------------------------------------------------
Scenario: ValidationAttachment attach re-raises DuplicateValidationAttachmentError
Given a validation attachment repository backed by an in-memory database for uncovered lines
And a validation "val-dup" is already attached to resource "res-2" for uncovered lines
When the same validation "val-dup" is attached again to resource "res-2" for uncovered lines re-raise
Then the DuplicateValidationAttachmentError should propagate for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.get_by_name row is None (line 3663)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository get_by_name returns None when not found
Given an automation profile repository backed by an in-memory database for uncovered lines
When a profile is fetched by name "nonexistent-profile" for uncovered lines
Then None should be returned for the profile get for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.get_by_name _to_domain (line 3665)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository get_by_name returns domain object
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "test-profile" has been upserted for uncovered lines
When a profile is fetched by name "test-profile" for uncovered lines
Then the profile domain object should be returned for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.list_all _to_domain mapping (line 3686)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository list_all returns domain objects
Given an automation profile repository backed by an in-memory database for uncovered lines
And profiles "prof-a" and "prof-b" have been upserted for uncovered lines
When all profiles are listed for uncovered lines
Then two profile domain objects should be returned for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert existing row update (lines 3722-3732)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert updates existing profile
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "update-prof" has been upserted for uncovered lines
When the profile "update-prof" is upserted again with changed description for uncovered lines
Then the profile should have the updated description for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert schema version mismatch (lines 3723-3731)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert raises on schema version mismatch
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "versioned-prof" has been upserted with schema_version "1.0" for uncovered lines
When the profile "versioned-prof" is upserted with expected_schema_version "2.0" for uncovered lines
Then an AutomationProfileSchemaVersionError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert new row insert (lines 3733-3735)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert inserts new profile
Given an automation profile repository backed by an in-memory database for uncovered lines
When a new profile "brand-new" is upserted for uncovered lines
Then the profile "brand-new" should be retrievable for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.upsert IntegrityError (lines 3739-3743)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository upsert wraps IntegrityError in DuplicateAutomationProfileError
Given an automation profile repository with session raising IntegrityError on flush for uncovered lines
When a profile is upserted and IntegrityError occurs for uncovered lines
Then a DuplicateAutomationProfileError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.delete row not found (lines 3766-3767)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository delete raises NotFoundError when missing
Given an automation profile repository backed by an in-memory database for uncovered lines
When a profile "ghost" is deleted for uncovered lines
Then an AutomationProfileNotFoundError should be raised for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository.delete success path (lines 3768-3769)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository delete removes existing profile
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "delete-me" has been upserted for uncovered lines
When the profile "delete-me" is deleted for uncovered lines
Then the profile "delete-me" should no longer exist for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository._to_domain full field coverage (lines 3784-3806)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository _to_domain maps all fields correctly
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "full-fields" with all fields set has been upserted for uncovered lines
When a profile is fetched by name "full-fields" for uncovered lines
Then all profile fields should match the original values for uncovered lines
# -------------------------------------------------------------------
# AutomationProfileRepository._from_domain + _update_row (lines 3808-3855)
# -------------------------------------------------------------------
Scenario: AutomationProfileRepository _from_domain and _update_row cover all fields
Given an automation profile repository backed by an in-memory database for uncovered lines
And a profile "roundtrip" with specific field values has been upserted for uncovered lines
When the profile "roundtrip" is upserted again with different field values for uncovered lines
Then the profile "roundtrip" should have the new field values for uncovered lines
@@ -0,0 +1,67 @@
Feature: Resource CLI coverage boost for remaining uncovered lines
As a developer
I want edge-case paths in resource.py thoroughly tested
So that the line-rate and branch-rate improve beyond 0.93 / 0.88
# ---- _get_registry_service (lines 79-81) ----
Scenario: _get_registry_service delegates to the DI container
Given the DI container is mocked for resource registry
When _get_registry_service is called directly
Then the returned service should be the mock registry service
# ---- type_add --update re-raises ValidationError (line 181) ----
Scenario: type_add --update re-raises ValidationError whose message lacks "already exists"
Given a mock resource service that raises a non-already-exists ValidationError on register_type
When I invoke type add with update flag via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Validation error"
# ---- type_add FileNotFoundError (lines 193-195) ----
Scenario: type_add catches FileNotFoundError from service
Given a mock resource service that raises FileNotFoundError on register_type
When I invoke type add with a dummy config via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Config file not found"
# ---- type_remove confirm declined (lines 234-236) ----
Scenario: type_remove aborts when user declines confirmation prompt
Given a mock resource service with a removable custom type
When I invoke type remove without --yes and answer no via CliRunner
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Aborted"
# ---- type_remove row is None (lines 260-262) ----
Scenario: type_remove aborts when DB row is None after query
Given a mock resource service whose session returns no row for the type
When I invoke type remove with --yes via CliRunner for the phantom type
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "Resource type not found"
# ---- type_remove generic exception triggers rollback (lines 267-269) ----
Scenario: type_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception
When I invoke type remove with --yes via CliRunner for the failing type
Then the CliRunner exit code should be non-zero
And the mock session rollback should have been called for type remove
# ---- resource_remove edge_count > 0 (lines 653-658) ----
Scenario: resource_remove aborts when resource has edges
Given a mock resource service whose session reports edges on the resource
When I invoke resource remove with --yes via CliRunner for the edged resource
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "edge(s) still reference it"
# ---- resource_remove generic Exception rollback (lines 668-672) ----
Scenario: resource_remove rolls back session on unexpected exception
Given a mock resource service whose session delete raises a generic exception for resource
When I invoke resource remove with --yes via CliRunner for the failing resource
Then the CliRunner exit code should be non-zero
And the mock session rollback should have been called for resource remove
+166
View File
@@ -0,0 +1,166 @@
@phase1 @domain @repository @resource_dag
Feature: Resource DAG Linking and Discovery
As a system operator managing resource hierarchies
I want to link resources in a directed acyclic graph
So that parent-child relationships are enforced with type safety
Background:
Given a clean resource DAG database
And a resource type repository for DAG tests
And a resource repository for DAG tests
# ---------------------------------------------------------------------------
# link_child / unlink_child basic operations
# ---------------------------------------------------------------------------
@dag_link
Scenario: Link a child resource to a parent
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG child type "dag/child-type"
And a DAG resource "P1" typed "dag/parent-type"
And a DAG resource "C1" typed "dag/child-type"
When DAG child "C1" is linked to parent "P1"
Then the DAG link should succeed without error
And DAG children of "P1" should include "C1"
@dag_unlink
Scenario: Unlink a child resource from a parent
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG child type "dag/child-type"
And a DAG resource "P1" typed "dag/parent-type"
And a DAG resource "C1" typed "dag/child-type"
And DAG child "C1" is already linked to parent "P1"
When DAG child "C1" is unlinked from parent "P1"
Then the DAG unlink should succeed without error
And DAG children of "P1" should be empty
@dag_link @error_handling
Scenario: Linking a non-existent parent raises an error
Given a DAG child type "dag/child-type"
And a DAG resource "C1" typed "dag/child-type"
When DAG linking child "C1" to missing parent "MISSING_ID_00000000000000"
Then a DAG ResourceNotFoundRepoError should be raised
@dag_link @error_handling
Scenario: Linking a non-existent child raises an error
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG resource "P1" typed "dag/parent-type"
When DAG linking missing child "MISSING_ID_00000000000000" to parent "P1"
Then a DAG ResourceNotFoundRepoError should be raised
@dag_link @error_handling
Scenario: Duplicate link raises an error
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG child type "dag/child-type"
And a DAG resource "P1" typed "dag/parent-type"
And a DAG resource "C1" typed "dag/child-type"
And DAG child "C1" is already linked to parent "P1"
When DAG child "C1" is linked to parent "P1" again
Then a DAG DuplicateResourceLinkError should be raised
# ---------------------------------------------------------------------------
# Cycle detection
# ---------------------------------------------------------------------------
@dag_cycle
Scenario: Self-link is rejected
Given a DAG parent type "dag/parent-type" allowing children '["dag/parent-type"]'
And a DAG resource "P1" typed "dag/parent-type"
When DAG resource "P1" is linked to itself
Then a DAG CycleDetectedError should be raised
@dag_cycle
Scenario: Direct cycle A->B->A is rejected
Given a DAG parent type "dag/parent-type" allowing children '["dag/parent-type"]'
And a DAG resource "A" typed "dag/parent-type"
And a DAG resource "B" typed "dag/parent-type"
And DAG child "B" is already linked to parent "A"
When DAG child "A" is linked to parent "B"
Then a DAG CycleDetectedError should be raised
@dag_cycle
Scenario: Transitive cycle A->B->C->A is rejected
Given a DAG parent type "dag/parent-type" allowing children '["dag/parent-type"]'
And a DAG resource "A" typed "dag/parent-type"
And a DAG resource "B" typed "dag/parent-type"
And a DAG resource "C" typed "dag/parent-type"
And DAG child "B" is already linked to parent "A"
And DAG child "C" is already linked to parent "B"
When DAG child "A" is linked to parent "C"
Then a DAG CycleDetectedError should be raised
# ---------------------------------------------------------------------------
# Type compatibility enforcement
# ---------------------------------------------------------------------------
@dag_type_compat
Scenario: Linking incompatible types is rejected
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG child type "dag/other-type"
And a DAG resource "P1" typed "dag/parent-type"
And a DAG resource "C1" typed "dag/other-type"
When DAG child "C1" is linked to parent "P1"
Then a DAG TypeIncompatibleError should be raised
@dag_type_compat
Scenario: Linking compatible types succeeds
Given a DAG parent type "dag/parent-type" allowing children '["dag/child-type"]'
And a DAG child type "dag/child-type"
And a DAG resource "P1" typed "dag/parent-type"
And a DAG resource "C1" typed "dag/child-type"
When DAG child "C1" is linked to parent "P1"
Then the DAG link should succeed without error
# ---------------------------------------------------------------------------
# auto_discover_children
# ---------------------------------------------------------------------------
@dag_auto_discover
Scenario: Auto-discover creates child resources per type rules
Given a DAG parent type "dag/discover-parent" with auto-discovery for "dag/discover-child"
And a DAG child type "dag/discover-child"
And a DAG resource "P1" typed "dag/discover-parent"
When DAG auto_discover_children is called for "P1"
Then at least 1 DAG child should be created
And DAG created children should be typed "dag/discover-child"
And DAG created children should be linked to "P1"
@dag_auto_discover
Scenario: Auto-discover with no rules creates nothing
Given a DAG parent type "dag/no-disc-parent" without auto-discovery
And a DAG resource "P1" typed "dag/no-disc-parent"
When DAG auto_discover_children is called for "P1"
Then 0 DAG children should be created
@dag_auto_discover @error_handling
Scenario: Auto-discover for non-existent resource raises error
When DAG auto_discover_children is called for missing "MISSING_ID_00000000000000"
Then a DAG ResourceNotFoundRepoError should be raised
# ---------------------------------------------------------------------------
# Tree traversal
# ---------------------------------------------------------------------------
@dag_traversal
Scenario: Get children returns all direct children
Given a DAG parent type "dag/parent-type" allowing ch