Merge branch 'master' into develop-20260217
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -7,7 +7,6 @@ negligible overhead to instantiation paths.
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
```
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 | ||||