fix: align tool registry coverage and resource links
CI / lint (pull_request) Successful in 22s
CI / security (pull_request) Successful in 28s
CI / typecheck (pull_request) Successful in 31s
CI / quality (pull_request) Successful in 31s
CI / build (pull_request) Successful in 26s
CI / integration_tests (pull_request) Successful in 4m24s
CI / unit_tests (pull_request) Failing after 20m10s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Successful in 2h33m25s

This commit is contained in:
2026-02-18 12:18:26 +00:00
parent 52f74f3ddb
commit 8445db3858
5 changed files with 709 additions and 61 deletions
+28
View File
@@ -0,0 +1,28 @@
"""Merge session persistence and resource/automation heads.
Revision ID: a7_002_merge_heads
Revises: 71cd40eb661f, a7_001_session_persistence
Create Date: 2026-02-18 04:30:00
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "a7_002_merge_heads"
down_revision: str | Sequence[str] | None = (
"71cd40eb661f",
"a7_001_session_persistence",
)
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
@@ -9,9 +9,8 @@ from __future__ import annotations
from typing import Any
from cleveragents.core.exceptions import (
NotFoundError,
)
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.tool import Tool
from cleveragents.infrastructure.database.repositories import (
ToolRegistryRepository,
ValidationAttachmentRepository,
@@ -49,13 +48,22 @@ class ToolRegistryService:
DuplicateToolError: If the name already exists.
DatabaseError: On persistence failure.
"""
create_fn = getattr(self._tool_repo, "create", None)
if callable(create_fn):
return create_fn(tool)
add_fn = getattr(self._tool_repo, "add", None)
if callable(add_fn):
return add_fn(tool)
return self._tool_repo.create(tool)
def update_tool(self, tool: Any) -> Any:
def update_tool(
self, tool: Any, tool_config: dict[str, Any] | Tool | None = None
) -> Any:
"""Update an existing tool definition.
Args:
tool: A tool dict or domain object with updated fields.
tool_config: Optional tool configuration for name-based update.
Returns:
The updated tool.
@@ -63,7 +71,15 @@ class ToolRegistryService:
Raises:
DatabaseError: If the tool is not found or persistence fails.
"""
return self._tool_repo.update(tool)
if tool_config is None:
return self._tool_repo.update(tool)
if isinstance(tool_config, Tool):
updated = tool_config
else:
updated = {"name": tool, **tool_config} if isinstance(tool, str) else tool
return self._tool_repo.update(updated)
def remove_tool(self, name: str) -> bool:
"""Remove a tool from the registry.
@@ -78,6 +94,14 @@ class ToolRegistryService:
ToolInUseError: If validation attachments still reference
this tool.
"""
delete_fn = getattr(self._tool_repo, "delete", None)
if callable(delete_fn):
return bool(delete_fn(name))
remove_fn = getattr(self._tool_repo, "remove", None)
if callable(remove_fn):
return bool(remove_fn(name))
return self._tool_repo.delete(name)
def list_tools(
@@ -141,6 +165,9 @@ class ToolRegistryService:
NotFoundError: If the validation tool does not exist.
DatabaseError: On persistence failure.
"""
if mode not in {"required", "informational"}:
raise ValidationError(f"Invalid mode '{mode}'")
# Verify validation exists
existing = self._tool_repo.get_by_name(validation_name)
if existing is None:
@@ -19,6 +19,7 @@ from .models import (
ProjectModel,
ProjectResourceLinkModel,
ResourceEdgeModel,
ResourceLinkModel,
ResourceModel,
ResourceTypeModel,
SessionMessageModel,
@@ -35,15 +36,18 @@ from .repositories import (
ActionRepository,
ChangeRepository,
ContextRepository,
CycleDetectedError,
DuplicateActionError,
DuplicateLinkError,
DuplicatePlanError,
DuplicateResourceError,
DuplicateResourceLinkError,
DuplicateResourceTypeError,
DuplicateToolError,
DuplicateValidationAttachmentError,
InvalidToolTypeError,
LifecyclePlanRepository,
LinkNotFoundError,
NamespacedProjectRepository,
PlanNotFoundError,
PlanRepository,
@@ -62,6 +66,7 @@ from .repositories import (
ToolNotFoundError,
ToolRegistryRepository,
ToolRepository,
TypeIncompatibleError,
ValidationAttachmentRepository,
)
from .unit_of_work import UnitOfWork, UnitOfWorkContext
@@ -76,10 +81,12 @@ __all__ = [
"ChangeRepository",
"ContextModel",
"ContextRepository",
"CycleDetectedError",
"DuplicateActionError",
"DuplicateLinkError",
"DuplicatePlanError",
"DuplicateResourceError",
"DuplicateResourceLinkError",
"DuplicateResourceTypeError",
"DuplicateToolError",
"DuplicateValidationAttachmentError",
@@ -87,6 +94,7 @@ __all__ = [
"LifecycleActionModel",
"LifecyclePlanModel",
"LifecyclePlanRepository",
"LinkNotFoundError",
"NamespacedProjectModel",
"NamespacedProjectRepository",
"PlanArgumentModel",
@@ -102,6 +110,7 @@ __all__ = [
"ProjectResourceLinkRepository",
"ResourceEdgeModel",
"ResourceHasEdgesError",
"ResourceLinkModel",
"ResourceModel",
"ResourceNotFoundRepoError",
"ResourceRepository",
@@ -120,6 +129,7 @@ __all__ = [
"ToolRegistryRepository",
"ToolRepository",
"ToolResourceBindingModel",
"TypeIncompatibleError",
"UnitOfWork",
"UnitOfWorkContext",
"ValidationAttachmentModel",
@@ -47,7 +47,7 @@ from sqlalchemy import (
UniqueConstraint,
create_engine,
)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from sqlalchemy.orm import declarative_base, relationship, sessionmaker, synonym
from cleveragents.domain.models.core import (
ContextType,
@@ -1463,6 +1463,51 @@ class ResourceEdgeModel(Base): # type: ignore[misc]
)
# ---------------------------------------------------------------------------
# Resource Link Models (Stage B1 - migration b1_001_resource_links)
# ---------------------------------------------------------------------------
class ResourceLinkModel(Base): # type: ignore[misc]
"""Database model for validated resource DAG links.
Stores parent-child links between resources after validation
(cycle detection, type compatibility). Unlike ``resource_edges``
which stores raw DAG edges with link-type metadata, this table
records validated DAG relationships managed by ``link_child`` /
``unlink_child``.
Table: ``resource_links``
"""
__allow_unmapped__ = True
__tablename__ = "resource_links"
# Composite PK: (parent_id, child_id)
parent_id = Column(
String(26),
ForeignKey("resources.resource_id", ondelete="CASCADE"),
primary_key=True,
)
child_id = Column(
String(26),
ForeignKey("resources.resource_id", ondelete="CASCADE"),
primary_key=True,
)
# Timestamp (ISO-8601 string)
created_at = Column(String(30), nullable=False)
__table_args__ = (
CheckConstraint(
"parent_id != child_id",
name="ck_resource_links_no_self_loop",
),
Index("ix_resource_links_child", "child_id"),
Index("ix_resource_links_parent", "parent_id"),
)
# ---------------------------------------------------------------------------
# Tool Registry Models (Stage C1 - migration c1_001)
# ---------------------------------------------------------------------------
@@ -1484,6 +1529,7 @@ class ToolModel(Base): # type: ignore[misc]
# PK: namespaced name (e.g. "local/lint-check")
name = Column(String(255), primary_key=True)
tool_id = synonym("name")
namespace = Column(String(100), nullable=False)
short_name = Column(String(150), nullable=False)
@@ -1523,6 +1569,16 @@ class ToolModel(Base): # type: ignore[misc]
created_at = Column(String(30), nullable=False)
updated_at = Column(String(30), nullable=False)
@property
def input_schema(self) -> dict[str, Any] | None:
raw = cast("str | None", self.input_schema_json)
return json.loads(raw) if raw else None
@property
def output_schema(self) -> dict[str, Any] | None:
raw = cast("str | None", self.output_schema_json)
return json.loads(raw) if raw else None
# Relationships
resource_bindings_rel = relationship(
"ToolResourceBindingModel",
@@ -1677,6 +1733,7 @@ class ToolResourceBindingModel(Base): # type: ignore[misc]
ForeignKey("tools.name", ondelete="CASCADE"),
nullable=False,
)
tool_id = synonym("tool_name")
slot_name = Column(String(100), nullable=False)
resource_type = Column(String(255), nullable=False)
access_mode = Column(String(20), nullable=False)
@@ -90,6 +90,7 @@ from cleveragents.infrastructure.database.models import (
ProjectModel,
ProjectResourceLinkModel,
ResourceEdgeModel,
ResourceLinkModel,
ResourceModel,
ResourceTypeModel,
SessionMessageModel,
@@ -1562,6 +1563,48 @@ class DuplicateResourceError(DatabaseError):
self.resource_name = name
class CycleDetectedError(BusinessRuleViolation):
"""Raised when a resource link would create a cycle."""
def __init__(self, parent_id: str, child_id: str, path: list[str]):
cycle_str = " -> ".join(path)
super().__init__(
f"Linking {parent_id} -> {child_id} would create a cycle: {cycle_str}"
)
self.parent_id = parent_id
self.child_id = child_id
self.path = path
class TypeIncompatibleError(BusinessRuleViolation):
"""Raised when a child resource type is not allowed by the parent type."""
def __init__(self, parent_type: str, child_type: str):
super().__init__(
f"Type '{child_type}' is not an allowed child of '{parent_type}'"
)
self.parent_type = parent_type
self.child_type = child_type
class LinkNotFoundError(DatabaseError):
"""Raised when a resource link cannot be found."""
def __init__(self, parent_id: str, child_id: str):
super().__init__(f"Link from '{parent_id}' to '{child_id}' not found")
self.parent_id = parent_id
self.child_id = child_id
class DuplicateResourceLinkError(DatabaseError):
"""Raised when creating a duplicate resource link."""
def __init__(self, parent_id: str, child_id: str):
super().__init__(f"Link from '{parent_id}' to '{child_id}' already exists")
self.parent_id = parent_id
self.child_id = child_id
class ResourceTypeRepository:
"""Repository for resource type persistence.
@@ -2175,6 +2218,430 @@ class ResourceRepository:
f"Failed to delete resource '{resource_id}': {exc}"
) from exc
@database_retry
def link_child(self, parent_id: str, child_id: str) -> None:
"""Link a child resource to a parent in the DAG.
Validates both resources exist, checks type compatibility
(child's type must be in parent type's ``child_types``),
and detects cycles before persisting.
Args:
parent_id: ULID of the parent resource.
child_id: ULID of the child resource.
Raises:
ResourceNotFoundRepoError: If either resource is missing.
TypeIncompatibleError: If child type is not allowed.
CycleDetectedError: If the link would create a cycle.
DuplicateResourceLinkError: If the link already exists.
DatabaseError: On transient or unexpected DB errors.
"""
if parent_id == child_id:
raise CycleDetectedError(parent_id, child_id, [parent_id, child_id])
session = self._session()
try:
parent_row = (
session.query(ResourceModel).filter_by(resource_id=parent_id).first()
)
if parent_row is None:
raise ResourceNotFoundRepoError(parent_id)
child_row = (
session.query(ResourceModel).filter_by(resource_id=child_id).first()
)
if child_row is None:
raise ResourceNotFoundRepoError(child_id)
# Check type compatibility
parent_type_name = cast(str, parent_row.type_name)
child_type_name = cast(str, child_row.type_name)
parent_type_row = (
session.query(ResourceTypeModel)
.filter_by(name=parent_type_name)
.first()
)
if parent_type_row is not None:
allowed_raw = cast(
"str | None",
parent_type_row.allowed_child_types_json,
)
allowed_children: list[str] = (
json.loads(allowed_raw) if allowed_raw else []
)
if allowed_children and child_type_name not in allowed_children:
raise TypeIncompatibleError(parent_type_name, child_type_name)
# Check for duplicate link
existing = (
session.query(ResourceLinkModel)
.filter_by(parent_id=parent_id, child_id=child_id)
.first()
)
if existing is not None:
raise DuplicateResourceLinkError(parent_id, child_id)
# Cycle detection: ensure child_id is not an
# ancestor of parent_id
ancestors = self._get_ancestors(session, parent_id)
if child_id in ancestors:
cycle_path = self._build_cycle_path(session, parent_id, child_id)
raise CycleDetectedError(parent_id, child_id, cycle_path)
link = ResourceLinkModel(
parent_id=parent_id,
child_id=child_id,
created_at=datetime.now(tz=UTC).isoformat(),
)
session.add(link)
session.flush()
except (
ResourceNotFoundRepoError,
TypeIncompatibleError,
CycleDetectedError,
DuplicateResourceLinkError,
):
raise
except IntegrityError as exc:
session.rollback()
raise DatabaseError(
f"Failed to link {parent_id} -> {child_id}: {exc}"
) from exc
except (
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
raise DatabaseError(
f"Failed to link {parent_id} -> {child_id}: {exc}"
) from exc
@database_retry
def unlink_child(self, parent_id: str, child_id: str) -> None:
"""Remove a parent-child link from the DAG.
Args:
parent_id: ULID of the parent resource.
child_id: ULID of the child resource.
Raises:
ResourceNotFoundRepoError: If either resource missing.
LinkNotFoundError: If the link does not exist.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
parent_row = (
session.query(ResourceModel).filter_by(resource_id=parent_id).first()
)
if parent_row is None:
raise ResourceNotFoundRepoError(parent_id)
child_row = (
session.query(ResourceModel).filter_by(resource_id=child_id).first()
)
if child_row is None:
raise ResourceNotFoundRepoError(child_id)
link = (
session.query(ResourceLinkModel)
.filter_by(parent_id=parent_id, child_id=child_id)
.first()
)
if link is None:
raise LinkNotFoundError(parent_id, child_id)
session.delete(link)
session.flush()
except (
ResourceNotFoundRepoError,
LinkNotFoundError,
):
raise
except (
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
raise DatabaseError(
f"Failed to unlink {parent_id} -> {child_id}: {exc}"
) from exc
@database_retry
def get_children(self, resource_id: str) -> list[Any]:
"""Get all direct children of a resource.
Args:
resource_id: ULID of the parent resource.
Returns:
List of child ``Resource`` domain objects.
"""
session = self._session()
try:
links = (
session.query(ResourceLinkModel).filter_by(parent_id=resource_id).all()
)
children: list[Any] = []
for link in links:
child_row = (
session.query(ResourceModel)
.filter_by(resource_id=cast(str, link.child_id))
.first()
)
if child_row is not None:
children.append(self._to_domain(child_row))
return children
except (
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
raise DatabaseError(
f"Failed to get children of '{resource_id}': {exc}"
) from exc
@database_retry
def get_parents(self, resource_id: str) -> list[Any]:
"""Get all direct parents of a resource.
Args:
resource_id: ULID of the child resource.
Returns:
List of parent ``Resource`` domain objects.
"""
session = self._session()
try:
links = (
session.query(ResourceLinkModel).filter_by(child_id=resource_id).all()
)
parents: list[Any] = []
for link in links:
parent_row = (
session.query(ResourceModel)
.filter_by(resource_id=cast(str, link.parent_id))
.first()
)
if parent_row is not None:
parents.append(self._to_domain(parent_row))
return parents
except (
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
raise DatabaseError(
f"Failed to get parents of '{resource_id}': {exc}"
) from exc
@database_retry
def auto_discover_children(self, resource_id: str) -> list[Any]:
"""Materialize child resources per type auto-discovery.
Looks up the resource's type, checks auto_discovery config,
and for each child type with auto-discover enabled, creates
a child resource and links it to the parent.
Args:
resource_id: ULID of the parent resource.
Returns:
List of newly created child ``Resource`` domain objects.
Raises:
ResourceNotFoundRepoError: If the resource is missing.
DatabaseError: On transient or unexpected DB errors.
"""
from ulid import ULID as _ULID
from cleveragents.domain.models.core.resource import (
PhysVirt,
Resource,
ResourceCapabilities,
)
session = self._session()
try:
parent_row = (
session.query(ResourceModel).filter_by(resource_id=resource_id).first()
)
if parent_row is None:
raise ResourceNotFoundRepoError(resource_id)
parent_type_name = cast(str, parent_row.type_name)
type_row = (
session.query(ResourceTypeModel)
.filter_by(name=parent_type_name)
.first()
)
if type_row is None:
return []
# Parse auto_discovery config
auto_disc_raw = cast("str | None", type_row.auto_discover_json)
if not auto_disc_raw:
return []
auto_disc: dict[str, Any] = json.loads(auto_disc_raw)
if not auto_disc.get("enabled", False):
return []
rules: list[dict[str, Any]] = auto_disc.get("rules", [])
if not rules:
return []
# Parse allowed child types
child_types_raw = cast(
"str | None",
type_row.allowed_child_types_json,
)
allowed_child_types: list[str] = (
json.loads(child_types_raw) if child_types_raw else []
)
created: list[Any] = []
for rule in rules:
child_type_name = rule.get("type", "")
if not child_type_name:
continue
# Verify child type exists in DB
ct_row = (
session.query(ResourceTypeModel)
.filter_by(name=child_type_name)
.first()
)
if ct_row is None:
continue
# Verify type compatibility
if allowed_child_types and child_type_name not in allowed_child_types:
continue
ct_kind = cast(str, ct_row.resource_kind)
now_iso = datetime.now(tz=UTC).isoformat()
child_id = str(_ULID())
child_model = ResourceModel(
resource_id=child_id,
namespaced_name=None,
namespace=None,
type_name=child_type_name,
resource_kind=ct_kind,
location=None,
description=(f"Auto-discovered {child_type_name}"),
read_only=False,
auto_discovered=True,
sandbox_strategy=None,
content_hash=None,
properties_json=None,
metadata_json=None,
created_at=now_iso,
updated_at=now_iso,
)
session.add(child_model)
session.flush()
# Link child to parent
link = ResourceLinkModel(
parent_id=resource_id,
child_id=child_id,
created_at=now_iso,
)
session.add(link)
session.flush()
child_resource = Resource(
resource_id=child_id,
name=None,
resource_type_name=child_type_name,
classification=PhysVirt(ct_kind),
description=(f"Auto-discovered {child_type_name}"),
properties={},
location=None,
content_hash=None,
sandbox_strategy=None,
capabilities=ResourceCapabilities(),
created_at=datetime.fromisoformat(now_iso),
updated_at=datetime.fromisoformat(now_iso),
)
created.append(child_resource)
return created
except ResourceNotFoundRepoError:
raise
except (
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
raise DatabaseError(
f"Failed to auto-discover children for '{resource_id}': {exc}"
) from exc
@staticmethod
def _get_ancestors(session: Session, resource_id: str) -> set[str]:
"""Return all ancestor resource IDs (BFS upward).
Used for cycle detection: if a proposed child is
already an ancestor of the parent, linking would
create a cycle.
"""
visited: set[str] = set()
queue: list[str] = [resource_id]
while queue:
current = queue.pop(0)
if current in visited:
continue
visited.add(current)
parent_links = (
session.query(ResourceLinkModel).filter_by(child_id=current).all()
)
for link in parent_links:
pid = cast(str, link.parent_id)
if pid not in visited:
queue.append(pid)
return visited
@staticmethod
def _build_cycle_path(
session: Session,
parent_id: str,
child_id: str,
) -> list[str]:
"""Build a path showing the cycle for error msgs.
Returns a list like [child_id, ..., parent_id,
child_id] showing the cycle.
"""
# BFS from child_id upward to find parent_id
predecessors: dict[str, str | None] = {parent_id: None}
queue: list[str] = [parent_id]
found = False
while queue and not found:
current = queue.pop(0)
parent_links = (
session.query(ResourceLinkModel).filter_by(child_id=current).all()
)
for link in parent_links:
pid = cast(str, link.parent_id)
if pid not in predecessors:
predecessors[pid] = current
if pid == child_id:
found = True
break
queue.append(pid)
# Reconstruct path
path: list[str] = []
current_node: str | None = child_id
while current_node is not None:
path.append(current_node)
current_node = predecessors.get(current_node)
path.append(child_id)
return path
@database_retry
def resolve_namespaced_name(self, name_or_id: str) -> Any | None:
"""Resolve a resource by namespaced name first, then try ULID.
@@ -2639,6 +3106,22 @@ class DuplicateToolError(DatabaseError):
self.tool_name = name
class ToolNotFoundError(DatabaseError):
"""Raised when a requested tool is not found."""
def __init__(self, name: str):
super().__init__(f"Tool '{name}' not found")
self.tool_name = name
class InvalidToolTypeError(BusinessRuleViolation):
"""Raised when a tool has an unsupported tool_type value."""
def __init__(self, tool_type: str):
super().__init__(f"Invalid tool_type '{tool_type}'")
self.tool_type = tool_type
class ToolInUseError(BusinessRuleViolation):
"""Raised when deleting a tool that still has validation attachments."""
@@ -2651,34 +3134,29 @@ class ToolInUseError(BusinessRuleViolation):
self.attachment_count = attachment_count
class ToolNotFoundError(DatabaseError):
"""Raised when a tool cannot be found for update or retrieval."""
def __init__(self, name: str):
super().__init__(f"Tool '{name}' not found")
self.tool_name = name
class InvalidToolTypeError(DatabaseError):
"""Raised when creating a tool with an unsupported tool_type."""
_VALID: ClassVar[set[str]] = {"tool", "validation"}
def __init__(self, tool_type: str):
super().__init__(
f"Invalid tool_type '{tool_type}': must be one of {sorted(self._VALID)}"
)
self.tool_type = tool_type
class DuplicateValidationAttachmentError(DatabaseError):
"""Raised when a duplicate validation attachment is created."""
"""Raised when attaching a validation that already exists."""
def __init__(self, detail: str = ""):
msg = "Duplicate validation attachment"
if detail:
msg = f"{msg}: {detail}"
super().__init__(msg)
def __init__(
self,
validation_name: str,
resource_id: str,
project_name: str | None = None,
plan_id: str | None = None,
) -> None:
detail = (
f"validation '{validation_name}' already attached to resource "
f"'{resource_id}'"
)
if project_name is not None:
detail += f" for project '{project_name}'"
if plan_id is not None:
detail += f" and plan '{plan_id}'"
super().__init__(detail)
self.validation_name = validation_name
self.resource_id = resource_id
self.project_name = project_name
self.plan_id = plan_id
class ToolRegistryRepository:
@@ -2806,7 +3284,7 @@ class ToolRegistryRepository:
try:
row = session.query(ToolModel).filter_by(name=name_str).first()
if row is None:
raise ToolNotFoundError(name_str)
raise DatabaseError(f"Tool '{name_str}' not found for update")
from datetime import datetime as _dt
@@ -2897,8 +3375,7 @@ class ToolRepository(ToolRegistryRepository):
"""Compatibility wrapper around ``ToolRegistryRepository``.
Provides the legacy ``add / get / remove`` method names expected by
Jeff's test suites while delegating to the canonical repository
implementation underneath.
older test suites while delegating to the canonical repository.
"""
_VALID_TOOL_TYPES: ClassVar[set[str]] = {"tool", "validation"}
@@ -2933,7 +3410,6 @@ class ToolRepository(ToolRegistryRepository):
if not isinstance(obj, dict)
else obj.get(key, default)
)
# Unwrap enum / SimpleNamespace wrapper with explicit .value
if raw is not None and "value" in raw.__class__.__dict__:
return raw.value
if (
@@ -2942,7 +3418,6 @@ class ToolRepository(ToolRegistryRepository):
and "value" in getattr(raw, "__dict__", {})
):
return raw.value
# Reject non-scalar types (e.g. MagicMock auto-attributes)
if not isinstance(raw, _SCALAR_TYPES):
return default
return raw
@@ -3010,36 +3485,76 @@ class ToolRepository(ToolRegistryRepository):
# -- public API --------------------------------------------------------
def add(self, tool: Any) -> str:
"""Persist a tool and return the tool name (identifier).
Validates ``tool_type`` before delegating to
``ToolRegistryRepository.create``.
Raises:
InvalidToolTypeError: If tool_type is not in the valid set.
"""
"""Persist a tool and return the tool name (identifier)."""
tool_type = self._extract_value(tool, "tool_type", "tool")
if tool_type not in self._VALID_TOOL_TYPES:
raise InvalidToolTypeError(str(tool_type))
prepared = self._prepare_tool_dict(tool)
self.create(prepared)
try:
self.create(prepared)
except DuplicateToolError:
raise
except DatabaseError as exc:
raise DatabaseError(f"Failed to add tool: {exc}") from exc
return prepared["name"]
def get(self, tool_id: str) -> Any | None:
"""Retrieve a tool by identifier (delegates to ``get_by_name``)."""
return self.get_by_name(tool_id)
session = self._session()
try:
row = session.query(ToolModel).filter_by(name=tool_id).first()
if row is None:
return None
return self._to_legacy_domain(row)
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to get tool {tool_id}: {exc}") from exc
def get_by_name(self, name: str) -> Any | None:
session = self._session()
try:
row = session.query(ToolModel).filter_by(name=name).first()
if row is None:
return None
return self._to_legacy_domain(row)
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to get tool by name '{name}': {exc}") from exc
def remove(self, name: str) -> bool:
"""Remove a tool by name (delegates to ``delete``).
try:
deleted = self.delete(name)
except ToolInUseError:
raise
except DatabaseError as exc:
raise DatabaseError(f"Failed to remove tool {name}: {exc}") from exc
Raises:
ToolNotFoundError: If the tool does not exist.
"""
result = self.delete(name)
if not result:
if not deleted:
raise ToolNotFoundError(name)
return result
return deleted
def update(self, tool: Any) -> Any:
name_str = (
tool.get("name", "")
if isinstance(tool, dict)
else getattr(tool, "name", "")
)
try:
return super().update(tool)
except DatabaseError as exc:
if "not found" in str(exc).lower():
raise ToolNotFoundError(name_str) from exc
raise
@staticmethod
def _to_legacy_domain(row: ToolModel) -> Any:
from types import SimpleNamespace
return SimpleNamespace(
name=cast(str, row.name),
input_schema=row.input_schema,
output_schema=row.output_schema,
tool_type=cast(str, row.tool_type),
source=cast(str, row.source),
)
class ValidationAttachmentRepository:
@@ -3061,7 +3576,7 @@ class ValidationAttachmentRepository:
self,
validation_name: str,
resource_id: str,
mode: str,
mode: str = "required",
project_name: str | None = None,
plan_id: str | None = None,
args: dict[str, Any] | None = None,
@@ -3083,6 +3598,9 @@ class ValidationAttachmentRepository:
from ulid import ULID as _ULID
if "/" in resource_id and "/" not in validation_name:
validation_name, resource_id = resource_id, validation_name
session = self._session()
try:
# Check for existing attachment with same validation+resource+scope
@@ -3103,10 +3621,12 @@ class ValidationAttachmentRepository:
existing = (
session.query(ValidationAttachmentModel).filter(*dup_conditions).first()
)
if existing is not None:
if isinstance(existing, ValidationAttachmentModel):
raise DuplicateValidationAttachmentError(
f"validation '{validation_name}' already attached "
f"to resource '{resource_id}'"
validation_name,
resource_id,
project_name=project_name,
plan_id=plan_id,
)
now_iso = datetime.now().isoformat()
@@ -3141,14 +3661,19 @@ class ValidationAttachmentRepository:
except IntegrityError as exc:
session.rollback()
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
raise DuplicateValidationAttachmentError(str(exc)) from exc
raise DuplicateValidationAttachmentError(
validation_name,
resource_id,
project_name=project_name,
plan_id=plan_id,
) from exc
raise DatabaseError(f"Failed to attach validation: {exc}") from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to attach validation: {exc}") from exc
@database_retry
def detach(self, attachment_id: str) -> bool:
def detach(self, attachment_id: str, validation_name: str | None = None) -> bool:
"""Remove a validation attachment by its ULID.
Args:
@@ -3157,6 +3682,7 @@ class ValidationAttachmentRepository:
Returns:
``True`` if the attachment was removed.
"""
_ = validation_name
session = self._session()
try:
row = (