diff --git a/admin_app/api/dependencies.py b/admin_app/api/dependencies.py
index 6ff05a7..0571b78 100644
--- a/admin_app/api/dependencies.py
+++ b/admin_app/api/dependencies.py
@@ -15,7 +15,9 @@ from admin_app.repositories import (
AuditLogRepository,
StaffAccountRepository,
StaffSessionRepository,
+ ToolArtifactRepository,
ToolDraftRepository,
+ ToolMetadataRepository,
ToolVersionRepository,
)
from admin_app.services import (
@@ -64,6 +66,14 @@ def get_tool_version_repository(db: Session = Depends(get_admin_db)) -> ToolVers
return ToolVersionRepository(db)
+def get_tool_metadata_repository(db: Session = Depends(get_admin_db)) -> ToolMetadataRepository:
+ return ToolMetadataRepository(db)
+
+
+def get_tool_artifact_repository(db: Session = Depends(get_admin_db)) -> ToolArtifactRepository:
+ return ToolArtifactRepository(db)
+
+
def get_audit_service(
repository: AuditLogRepository = Depends(get_audit_log_repository),
) -> AuditService:
@@ -100,11 +110,15 @@ def get_tool_management_service(
settings: AdminSettings = Depends(get_settings),
draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository),
version_repository: ToolVersionRepository = Depends(get_tool_version_repository),
+ metadata_repository: ToolMetadataRepository = Depends(get_tool_metadata_repository),
+ artifact_repository: ToolArtifactRepository = Depends(get_tool_artifact_repository),
) -> ToolManagementService:
return ToolManagementService(
settings=settings,
draft_repository=draft_repository,
version_repository=version_repository,
+ metadata_repository=metadata_repository,
+ artifact_repository=artifact_repository,
)
diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py
index 0c5a554..0bbdf3c 100644
--- a/admin_app/api/schemas.py
+++ b/admin_app/api/schemas.py
@@ -772,6 +772,13 @@ class AdminToolReviewQueueResponse(BaseModel):
supported_statuses: list[ToolLifecycleStatus]
+class AdminToolPublicationParameterResponse(BaseModel):
+ name: str
+ parameter_type: ToolParameterType
+ description: str
+ required: bool
+
+
class AdminToolPublicationSummaryResponse(BaseModel):
publication_id: str
tool_name: str
@@ -781,6 +788,8 @@ class AdminToolPublicationSummaryResponse(BaseModel):
version: int
status: ToolLifecycleStatus
parameter_count: int
+ parameters: list[AdminToolPublicationParameterResponse] = Field(default_factory=list)
+ author_name: str | None = None
implementation_module: str
implementation_callable: str
published_by: str | None = None
diff --git a/admin_app/db/bootstrap.py b/admin_app/db/bootstrap.py
index 3077fb3..1ed2f63 100644
--- a/admin_app/db/bootstrap.py
+++ b/admin_app/db/bootstrap.py
@@ -6,9 +6,9 @@ Cria tabelas do dominio administrativo de forma explicita, fora do startup do ap
from sqlalchemy import inspect, text
from admin_app.db.database import AdminBase, admin_engine
-from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion
+from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
-_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion)
+_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion)
def _ensure_admin_schema_evolution() -> None:
diff --git a/admin_app/db/models/__init__.py b/admin_app/db/models/__init__.py
index 261e021..ce2357c 100644
--- a/admin_app/db/models/__init__.py
+++ b/admin_app/db/models/__init__.py
@@ -2,7 +2,9 @@ from admin_app.db.models.audit_log import AuditLog
from admin_app.db.models.base import AdminTimestampedModel
from admin_app.db.models.staff_account import StaffAccount
from admin_app.db.models.staff_session import StaffSession
+from admin_app.db.models.tool_artifact import ToolArtifact
from admin_app.db.models.tool_draft import ToolDraft
+from admin_app.db.models.tool_metadata import ToolMetadata
from admin_app.db.models.tool_version import ToolVersion
__all__ = [
@@ -10,6 +12,8 @@ __all__ = [
"AuditLog",
"StaffAccount",
"StaffSession",
+ "ToolArtifact",
"ToolDraft",
+ "ToolMetadata",
"ToolVersion",
]
diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py
new file mode 100644
index 0000000..79be380
--- /dev/null
+++ b/admin_app/db/models/tool_artifact.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from enum import Enum
+
+from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import TypeDecorator
+
+from admin_app.db.models.base import AdminTimestampedModel
+
+
+class ToolArtifactStage(str, Enum):
+ GENERATION = "generation"
+ VALIDATION = "validation"
+
+
+class ToolArtifactKind(str, Enum):
+ GENERATION_REQUEST = "generation_request"
+ VALIDATION_REPORT = "validation_report"
+
+
+class ToolArtifactStorageKind(str, Enum):
+ INLINE_JSON = "inline_json"
+
+
+class ToolArtifactStatus(str, Enum):
+ PENDING = "pending"
+ SUCCEEDED = "succeeded"
+ FAILED = "failed"
+
+
+class ToolArtifactEnumType(TypeDecorator):
+ impl = String(40)
+ cache_ok = True
+
+ def __init__(self, enum_cls: type[Enum], *, length: int = 40):
+ super().__init__(length=length)
+ self.enum_cls = enum_cls
+
+ @property
+ def python_type(self):
+ return self.enum_cls
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ if isinstance(value, self.enum_cls):
+ return value.value
+ return self.enum_cls(str(value).strip().lower()).value
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return self.enum_cls(str(value).strip().lower())
+
+
+class ToolArtifact(AdminTimestampedModel):
+ __tablename__ = "tool_artifacts"
+ __table_args__ = (
+ UniqueConstraint(
+ "tool_version_id",
+ "artifact_kind",
+ name="uq_tool_artifacts_tool_version_kind",
+ ),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ artifact_id: Mapped[str] = mapped_column(String(140), unique=True, index=True, nullable=False)
+ draft_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("tool_drafts.id"),
+ nullable=False,
+ index=True,
+ )
+ tool_version_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("tool_versions.id"),
+ nullable=False,
+ index=True,
+ )
+ tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
+ version_number: Mapped[int] = mapped_column(Integer, nullable=False)
+ artifact_stage: Mapped[ToolArtifactStage] = mapped_column(
+ ToolArtifactEnumType(ToolArtifactStage),
+ nullable=False,
+ index=True,
+ )
+ artifact_kind: Mapped[ToolArtifactKind] = mapped_column(
+ ToolArtifactEnumType(ToolArtifactKind),
+ nullable=False,
+ index=True,
+ )
+ artifact_status: Mapped[ToolArtifactStatus] = mapped_column(
+ ToolArtifactEnumType(ToolArtifactStatus),
+ nullable=False,
+ default=ToolArtifactStatus.PENDING,
+ index=True,
+ )
+ storage_kind: Mapped[ToolArtifactStorageKind] = mapped_column(
+ ToolArtifactEnumType(ToolArtifactStorageKind),
+ nullable=False,
+ default=ToolArtifactStorageKind.INLINE_JSON,
+ )
+ summary: Mapped[str] = mapped_column(Text, nullable=False)
+ payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ checksum: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ author_staff_account_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("staff_accounts.id"),
+ nullable=False,
+ index=True,
+ )
+ author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
diff --git a/admin_app/db/models/tool_metadata.py b/admin_app/db/models/tool_metadata.py
new file mode 100644
index 0000000..e44bfc5
--- /dev/null
+++ b/admin_app/db/models/tool_metadata.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from admin_app.db.models.base import AdminTimestampedModel
+from admin_app.db.models.tool_draft import ToolLifecycleStatusType
+from shared.contracts import ToolLifecycleStatus
+
+
+class ToolMetadata(AdminTimestampedModel):
+ __tablename__ = "tool_metadata"
+ __table_args__ = (
+ UniqueConstraint(
+ "tool_name",
+ "version_number",
+ name="uq_tool_metadata_tool_name_version_number",
+ ),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ metadata_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
+ draft_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("tool_drafts.id"),
+ nullable=False,
+ index=True,
+ )
+ tool_version_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("tool_versions.id"),
+ nullable=False,
+ unique=True,
+ index=True,
+ )
+ tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
+ display_name: Mapped[str] = mapped_column(String(120), nullable=False)
+ domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
+ description: Mapped[str] = mapped_column(Text, nullable=False)
+ parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
+ version_number: Mapped[int] = mapped_column(Integer, nullable=False)
+ status: Mapped[ToolLifecycleStatus] = mapped_column(
+ ToolLifecycleStatusType(),
+ nullable=False,
+ default=ToolLifecycleStatus.DRAFT,
+ index=True,
+ )
+ author_staff_account_id: Mapped[int] = mapped_column(
+ Integer,
+ ForeignKey("staff_accounts.id"),
+ nullable=False,
+ index=True,
+ )
+ author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
diff --git a/admin_app/db/write_governance.py b/admin_app/db/write_governance.py
index 71f452f..d41353b 100644
--- a/admin_app/db/write_governance.py
+++ b/admin_app/db/write_governance.py
@@ -14,6 +14,8 @@ ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = (
"staff_sessions",
"tool_drafts",
"tool_versions",
+ "tool_metadata",
+ "tool_artifacts",
)
diff --git a/admin_app/repositories/__init__.py b/admin_app/repositories/__init__.py
index 391c4ca..2a6f6ca 100644
--- a/admin_app/repositories/__init__.py
+++ b/admin_app/repositories/__init__.py
@@ -2,7 +2,9 @@ from admin_app.repositories.audit_log_repository import AuditLogRepository
from admin_app.repositories.base_repository import BaseRepository
from admin_app.repositories.staff_account_repository import StaffAccountRepository
from admin_app.repositories.staff_session_repository import StaffSessionRepository
+from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
+from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository
__all__ = [
@@ -10,6 +12,8 @@ __all__ = [
"BaseRepository",
"StaffAccountRepository",
"StaffSessionRepository",
+ "ToolArtifactRepository",
"ToolDraftRepository",
+ "ToolMetadataRepository",
"ToolVersionRepository",
]
diff --git a/admin_app/repositories/tool_artifact_repository.py b/admin_app/repositories/tool_artifact_repository.py
new file mode 100644
index 0000000..e6ab57e
--- /dev/null
+++ b/admin_app/repositories/tool_artifact_repository.py
@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+import hashlib
+import json
+
+from sqlalchemy import select
+
+from admin_app.db.models import ToolArtifact
+from admin_app.db.models.tool_artifact import (
+ ToolArtifactKind,
+ ToolArtifactStage,
+ ToolArtifactStatus,
+ ToolArtifactStorageKind,
+)
+from admin_app.repositories.base_repository import BaseRepository
+
+
+class ToolArtifactRepository(BaseRepository):
+ def list_artifacts(
+ self,
+ *,
+ tool_name: str | None = None,
+ tool_version_id: int | None = None,
+ artifact_stage: ToolArtifactStage | str | None = None,
+ artifact_kind: ToolArtifactKind | str | None = None,
+ ) -> list[ToolArtifact]:
+ statement = select(ToolArtifact).order_by(
+ ToolArtifact.version_number.desc(),
+ ToolArtifact.updated_at.desc(),
+ ToolArtifact.created_at.desc(),
+ )
+ if tool_name:
+ statement = statement.where(ToolArtifact.tool_name == str(tool_name).strip().lower())
+ if tool_version_id is not None:
+ statement = statement.where(ToolArtifact.tool_version_id == tool_version_id)
+ if artifact_stage:
+ statement = statement.where(
+ ToolArtifact.artifact_stage == self._normalize_stage(artifact_stage)
+ )
+ if artifact_kind:
+ statement = statement.where(
+ ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind)
+ )
+ return list(self.db.execute(statement).scalars().all())
+
+ def get_by_tool_version_and_kind(
+ self,
+ tool_version_id: int,
+ artifact_kind: ToolArtifactKind | str,
+ ) -> ToolArtifact | None:
+ statement = select(ToolArtifact).where(
+ ToolArtifact.tool_version_id == tool_version_id,
+ ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind),
+ )
+ return self.db.execute(statement).scalar_one_or_none()
+
+ def create(
+ self,
+ *,
+ draft_id: int,
+ tool_version_id: int,
+ tool_name: str,
+ version_number: int,
+ artifact_stage: ToolArtifactStage | str,
+ artifact_kind: ToolArtifactKind | str,
+ artifact_status: ToolArtifactStatus | str,
+ summary: str,
+ payload_json: dict,
+ author_staff_account_id: int,
+ author_display_name: str,
+ storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
+ checksum: str | None = None,
+ commit: bool = True,
+ ) -> ToolArtifact:
+ normalized_kind = self._normalize_kind(artifact_kind)
+ artifact = ToolArtifact(
+ artifact_id=self.build_artifact_id(tool_name, version_number, normalized_kind),
+ draft_id=draft_id,
+ tool_version_id=tool_version_id,
+ tool_name=str(tool_name or "").strip().lower(),
+ version_number=int(version_number),
+ artifact_stage=self._normalize_stage(artifact_stage),
+ artifact_kind=normalized_kind,
+ artifact_status=self._normalize_status(artifact_status),
+ storage_kind=self._normalize_storage_kind(storage_kind),
+ summary=str(summary or "").strip(),
+ payload_json=dict(payload_json or {}),
+ checksum=checksum or self._build_payload_checksum(payload_json),
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ )
+ self.db.add(artifact)
+ if commit:
+ self.db.commit()
+ self.db.refresh(artifact)
+ else:
+ self.db.flush()
+ return artifact
+
+ def update_artifact(
+ self,
+ artifact: ToolArtifact,
+ *,
+ artifact_status: ToolArtifactStatus | str,
+ summary: str,
+ payload_json: dict,
+ author_staff_account_id: int,
+ author_display_name: str,
+ storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
+ checksum: str | None = None,
+ commit: bool = True,
+ ) -> ToolArtifact:
+ artifact.artifact_status = self._normalize_status(artifact_status)
+ artifact.storage_kind = self._normalize_storage_kind(storage_kind)
+ artifact.summary = str(summary or "").strip()
+ artifact.payload_json = dict(payload_json or {})
+ artifact.checksum = checksum or self._build_payload_checksum(payload_json)
+ artifact.author_staff_account_id = author_staff_account_id
+ artifact.author_display_name = author_display_name
+ if commit:
+ self.db.commit()
+ self.db.refresh(artifact)
+ else:
+ self.db.flush()
+ return artifact
+
+ def upsert_version_artifact(
+ self,
+ *,
+ draft_id: int,
+ tool_version_id: int,
+ tool_name: str,
+ version_number: int,
+ artifact_stage: ToolArtifactStage | str,
+ artifact_kind: ToolArtifactKind | str,
+ artifact_status: ToolArtifactStatus | str,
+ summary: str,
+ payload_json: dict,
+ author_staff_account_id: int,
+ author_display_name: str,
+ storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
+ checksum: str | None = None,
+ commit: bool = True,
+ ) -> ToolArtifact:
+ normalized_kind = self._normalize_kind(artifact_kind)
+ existing = self.get_by_tool_version_and_kind(tool_version_id, normalized_kind)
+ if existing is None:
+ return self.create(
+ draft_id=draft_id,
+ tool_version_id=tool_version_id,
+ tool_name=tool_name,
+ version_number=version_number,
+ artifact_stage=artifact_stage,
+ artifact_kind=normalized_kind,
+ artifact_status=artifact_status,
+ summary=summary,
+ payload_json=payload_json,
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ storage_kind=storage_kind,
+ checksum=checksum,
+ commit=commit,
+ )
+ return self.update_artifact(
+ existing,
+ artifact_status=artifact_status,
+ summary=summary,
+ payload_json=payload_json,
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ storage_kind=storage_kind,
+ checksum=checksum,
+ commit=commit,
+ )
+
+ @staticmethod
+ def build_artifact_id(
+ tool_name: str,
+ version_number: int,
+ artifact_kind: ToolArtifactKind | str,
+ ) -> str:
+ normalized_tool_name = str(tool_name or "").strip().lower()
+ normalized_kind = ToolArtifactRepository._normalize_kind(artifact_kind)
+ return f"tool_artifact::{normalized_tool_name}::v{int(version_number)}::{normalized_kind.value}"
+
+ @staticmethod
+ def _build_payload_checksum(payload_json: dict | None) -> str:
+ canonical_payload = json.dumps(payload_json or {}, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
+ return hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest()
+
+ @staticmethod
+ def _normalize_stage(value: ToolArtifactStage | str) -> ToolArtifactStage:
+ if isinstance(value, ToolArtifactStage):
+ return value
+ return ToolArtifactStage(str(value or "").strip().lower())
+
+ @staticmethod
+ def _normalize_kind(value: ToolArtifactKind | str) -> ToolArtifactKind:
+ if isinstance(value, ToolArtifactKind):
+ return value
+ return ToolArtifactKind(str(value or "").strip().lower())
+
+ @staticmethod
+ def _normalize_status(value: ToolArtifactStatus | str) -> ToolArtifactStatus:
+ if isinstance(value, ToolArtifactStatus):
+ return value
+ return ToolArtifactStatus(str(value or "").strip().lower())
+
+ @staticmethod
+ def _normalize_storage_kind(value: ToolArtifactStorageKind | str) -> ToolArtifactStorageKind:
+ if isinstance(value, ToolArtifactStorageKind):
+ return value
+ return ToolArtifactStorageKind(str(value or "").strip().lower())
diff --git a/admin_app/repositories/tool_metadata_repository.py b/admin_app/repositories/tool_metadata_repository.py
new file mode 100644
index 0000000..15b59d0
--- /dev/null
+++ b/admin_app/repositories/tool_metadata_repository.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from admin_app.db.models import ToolMetadata
+from admin_app.repositories.base_repository import BaseRepository
+from shared.contracts import ToolLifecycleStatus
+
+
+class ToolMetadataRepository(BaseRepository):
+ def list_metadata(
+ self,
+ *,
+ tool_name: str | None = None,
+ statuses: tuple[ToolLifecycleStatus, ...] | None = None,
+ ) -> list[ToolMetadata]:
+ statement = select(ToolMetadata).order_by(
+ ToolMetadata.version_number.desc(),
+ ToolMetadata.updated_at.desc(),
+ ToolMetadata.created_at.desc(),
+ )
+ if tool_name:
+ statement = statement.where(ToolMetadata.tool_name == str(tool_name).strip().lower())
+ if statuses:
+ statement = statement.where(ToolMetadata.status.in_(statuses))
+ return list(self.db.execute(statement).scalars().all())
+
+ def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None:
+ statement = select(ToolMetadata).where(ToolMetadata.tool_version_id == tool_version_id)
+ return self.db.execute(statement).scalar_one_or_none()
+
+ def create(
+ self,
+ *,
+ draft_id: int,
+ tool_version_id: int,
+ tool_name: str,
+ display_name: str,
+ domain: str,
+ description: str,
+ parameters_json: list[dict],
+ version_number: int,
+ status: ToolLifecycleStatus,
+ author_staff_account_id: int,
+ author_display_name: str,
+ commit: bool = True,
+ ) -> ToolMetadata:
+ metadata = ToolMetadata(
+ metadata_id=self.build_metadata_id(tool_name, version_number),
+ draft_id=draft_id,
+ tool_version_id=tool_version_id,
+ tool_name=tool_name,
+ display_name=display_name,
+ domain=domain,
+ description=description,
+ parameters_json=parameters_json,
+ version_number=version_number,
+ status=status,
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ )
+ self.db.add(metadata)
+ if commit:
+ self.db.commit()
+ self.db.refresh(metadata)
+ else:
+ self.db.flush()
+ return metadata
+
+ def update_metadata(
+ self,
+ metadata: ToolMetadata,
+ *,
+ display_name: str,
+ domain: str,
+ description: str,
+ parameters_json: list[dict],
+ status: ToolLifecycleStatus,
+ author_staff_account_id: int,
+ author_display_name: str,
+ commit: bool = True,
+ ) -> ToolMetadata:
+ metadata.display_name = display_name
+ metadata.domain = domain
+ metadata.description = description
+ metadata.parameters_json = parameters_json
+ metadata.status = status
+ metadata.author_staff_account_id = author_staff_account_id
+ metadata.author_display_name = author_display_name
+ if commit:
+ self.db.commit()
+ self.db.refresh(metadata)
+ else:
+ self.db.flush()
+ return metadata
+
+ def upsert_version_metadata(
+ self,
+ *,
+ draft_id: int,
+ tool_version_id: int,
+ tool_name: str,
+ display_name: str,
+ domain: str,
+ description: str,
+ parameters_json: list[dict],
+ version_number: int,
+ status: ToolLifecycleStatus,
+ author_staff_account_id: int,
+ author_display_name: str,
+ commit: bool = True,
+ ) -> ToolMetadata:
+ existing = self.get_by_tool_version_id(tool_version_id)
+ if existing is None:
+ return self.create(
+ draft_id=draft_id,
+ tool_version_id=tool_version_id,
+ tool_name=tool_name,
+ display_name=display_name,
+ domain=domain,
+ description=description,
+ parameters_json=parameters_json,
+ version_number=version_number,
+ status=status,
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ commit=commit,
+ )
+ return self.update_metadata(
+ existing,
+ display_name=display_name,
+ domain=domain,
+ description=description,
+ parameters_json=parameters_json,
+ status=status,
+ author_staff_account_id=author_staff_account_id,
+ author_display_name=author_display_name,
+ commit=commit,
+ )
+
+ @staticmethod
+ def build_metadata_id(tool_name: str, version_number: int) -> str:
+ normalized_tool_name = str(tool_name or "").strip().lower()
+ return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}"
diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py
index 5381760..1f32313 100644
--- a/admin_app/services/tool_management_service.py
+++ b/admin_app/services/tool_management_service.py
@@ -5,14 +5,25 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from admin_app.core.settings import AdminSettings
-from admin_app.db.models import ToolDraft, ToolVersion
+from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion
+from admin_app.db.models.tool_artifact import (
+ ToolArtifactKind,
+ ToolArtifactStage,
+ ToolArtifactStatus,
+)
+from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
+from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository
from shared.contracts import (
+ GENERATED_TOOL_ENTRYPOINT,
+ GENERATED_TOOLS_PACKAGE,
ServiceName,
TOOL_LIFECYCLE_STAGES,
ToolLifecycleStatus,
ToolParameterType,
+ build_generated_tool_module_name,
+ build_generated_tool_module_path,
)
@@ -196,6 +207,7 @@ _PARAMETER_TYPE_DESCRIPTIONS = {
_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$")
_PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
+_RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in _BOOTSTRAP_TOOL_CATALOG)
class ToolManagementService:
@@ -204,19 +216,26 @@ class ToolManagementService:
settings: AdminSettings,
draft_repository: ToolDraftRepository | None = None,
version_repository: ToolVersionRepository | None = None,
+ metadata_repository: ToolMetadataRepository | None = None,
+ artifact_repository: ToolArtifactRepository | None = None,
):
self.settings = settings
self.draft_repository = draft_repository
self.version_repository = version_repository
+ self.metadata_repository = metadata_repository
+ self.artifact_repository = artifact_repository
def build_overview_payload(self) -> dict:
- catalog = self.list_publication_catalog()
+ catalog_payload = self.build_publications_payload()
+ catalog = catalog_payload["publications"]
persisted_draft_count = len(self.draft_repository.list_drafts()) if self.draft_repository else 0
persisted_version_count = 0
if self.version_repository is not None:
persisted_version_count = len(self.version_repository.list_versions())
elif self.draft_repository is not None:
persisted_version_count = sum(draft.version_count for draft in self.draft_repository.list_drafts())
+ persisted_metadata_count = len(self.metadata_repository.list_metadata()) if self.metadata_repository else 0
+ persisted_artifact_count = len(self.artifact_repository.list_artifacts()) if self.artifact_repository else 0
return {
"mode": "admin_tool_draft_governance",
"metrics": [
@@ -224,7 +243,7 @@ class ToolManagementService:
"key": "active_catalog",
"label": "Tools mapeadas",
"value": str(len(catalog)),
- "description": "Catalogo bootstrap refletindo a base de tools conhecida no monorepo.",
+ "description": "Catalogo governado persistido quando disponivel, com fallback bootstrap enquanto o admin ainda nao tiver metadados proprios.",
},
{
"key": "lifecycle_stages",
@@ -250,6 +269,18 @@ class ToolManagementService:
"value": str(persisted_version_count),
"description": "Historico versionado das iteracoes de cada tool governada pelo admin.",
},
+ {
+ "key": "persisted_metadata",
+ "label": "Metadados persistidos",
+ "value": str(persisted_metadata_count),
+ "description": "Snapshots canonicos por versao com nome, descricao, parametros, status e autor da tool.",
+ },
+ {
+ "key": "persisted_artifacts",
+ "label": "Artefatos auditaveis",
+ "value": str(persisted_artifact_count),
+ "description": "Manifestos de geracao e relatorios de validacao gravados por versao para trilha administrativa.",
+ },
],
"workflow": self.build_lifecycle_payload(),
"next_steps": [
@@ -315,6 +346,7 @@ class ToolManagementService:
],
"naming_rules": [
"tool_name deve usar snake_case minusculo, sem espacos, com 3 a 64 caracteres.",
+ "tool_name nao pode reutilizar nomes reservados pelo catalogo core ja publicado.",
"display_name deve explicar claramente a acao operacional que o bot vai executar.",
"Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.",
],
@@ -370,6 +402,17 @@ class ToolManagementService:
}
def build_publications_payload(self) -> dict:
+ metadata_entries = self._list_latest_metadata_entries()
+ if metadata_entries:
+ return {
+ "source": "admin_metadata_catalog",
+ "target_service": ServiceName.PRODUCT,
+ "publications": [
+ self._serialize_metadata_publication(metadata)
+ for metadata in metadata_entries
+ ],
+ }
+
return {
"source": "bootstrap_catalog",
"target_service": ServiceName.PRODUCT,
@@ -478,6 +521,33 @@ class ToolManagementService:
requires_director_approval=True,
)
+ if version is not None and self.metadata_repository is not None:
+ self.metadata_repository.upsert_version_metadata(
+ draft_id=draft.id,
+ tool_version_id=version.id,
+ tool_name=draft.tool_name,
+ display_name=draft.display_name,
+ domain=draft.domain,
+ description=draft.description,
+ parameters_json=stored_parameters,
+ version_number=version.version_number,
+ status=version.status,
+ author_staff_account_id=version.owner_staff_account_id,
+ author_display_name=version.owner_display_name,
+ )
+
+ if version is not None and self.artifact_repository is not None:
+ self._persist_initial_version_artifacts(
+ draft=draft,
+ version=version,
+ summary=summary,
+ warnings=warnings,
+ stored_parameters=stored_parameters,
+ required_parameter_count=required_parameter_count,
+ owner_staff_account_id=owner_staff_account_id,
+ owner_name=owner_name or "Autor administrativo",
+ )
+
return {
"storage_status": "admin_database",
"message": "Draft administrativo persistido com sucesso em fluxo versionado.",
@@ -560,6 +630,150 @@ class ToolManagementService:
for entry in _BOOTSTRAP_TOOL_CATALOG
]
+ def _persist_initial_version_artifacts(
+ self,
+ *,
+ draft: ToolDraft,
+ version: ToolVersion,
+ summary: str,
+ warnings: list[str],
+ stored_parameters: list[dict],
+ required_parameter_count: int,
+ owner_staff_account_id: int,
+ owner_name: str,
+ ) -> None:
+ if self.artifact_repository is None:
+ return
+
+ generation_payload = self._build_generation_artifact_payload(
+ draft=draft,
+ version=version,
+ summary=summary,
+ stored_parameters=stored_parameters,
+ )
+ validation_payload = self._build_validation_artifact_payload(
+ draft=draft,
+ version=version,
+ warnings=warnings,
+ stored_parameters=stored_parameters,
+ required_parameter_count=required_parameter_count,
+ )
+
+ self.artifact_repository.upsert_version_artifact(
+ draft_id=draft.id,
+ tool_version_id=version.id,
+ tool_name=draft.tool_name,
+ version_number=version.version_number,
+ artifact_stage=ToolArtifactStage.GENERATION,
+ artifact_kind=ToolArtifactKind.GENERATION_REQUEST,
+ artifact_status=ToolArtifactStatus.PENDING,
+ summary="Manifesto inicial de geracao persistido para auditoria da versao.",
+ payload_json=generation_payload,
+ author_staff_account_id=owner_staff_account_id,
+ author_display_name=owner_name,
+ )
+ self.artifact_repository.upsert_version_artifact(
+ draft_id=draft.id,
+ tool_version_id=version.id,
+ tool_name=draft.tool_name,
+ version_number=version.version_number,
+ artifact_stage=ToolArtifactStage.VALIDATION,
+ artifact_kind=ToolArtifactKind.VALIDATION_REPORT,
+ artifact_status=ToolArtifactStatus.SUCCEEDED,
+ summary="Relatorio de validacao do pre-cadastro persistido para auditoria da versao.",
+ payload_json=validation_payload,
+ author_staff_account_id=owner_staff_account_id,
+ author_display_name=owner_name,
+ )
+
+ @staticmethod
+ def _build_generation_artifact_payload(
+ *,
+ draft: ToolDraft,
+ version: ToolVersion,
+ summary: str,
+ stored_parameters: list[dict],
+ ) -> dict:
+ return {
+ "source": "admin_draft_intake",
+ "tool_name": draft.tool_name,
+ "display_name": draft.display_name,
+ "domain": draft.domain,
+ "version_number": version.version_number,
+ "draft_id": draft.draft_id,
+ "version_id": version.version_id,
+ "business_goal": draft.business_goal,
+ "description": draft.description,
+ "summary": summary,
+ "parameters": list(stored_parameters),
+ "requires_director_approval": draft.requires_director_approval,
+ "target_package": GENERATED_TOOLS_PACKAGE,
+ "target_module": build_generated_tool_module_name(draft.tool_name),
+ "target_file_path": build_generated_tool_module_path(draft.tool_name),
+ "target_callable": GENERATED_TOOL_ENTRYPOINT,
+ "reserved_lifecycle_target": ToolLifecycleStatus.GENERATED.value,
+ }
+
+ @staticmethod
+ def _build_validation_artifact_payload(
+ *,
+ draft: ToolDraft,
+ version: ToolVersion,
+ warnings: list[str],
+ stored_parameters: list[dict],
+ required_parameter_count: int,
+ ) -> dict:
+ return {
+ "source": "admin_draft_intake",
+ "tool_name": draft.tool_name,
+ "version_number": version.version_number,
+ "draft_id": draft.draft_id,
+ "version_id": version.version_id,
+ "validation_status": "passed",
+ "warnings": list(warnings),
+ "parameter_count": len(stored_parameters),
+ "required_parameter_count": required_parameter_count,
+ "checked_rules": [
+ "tool_name_snake_case",
+ "display_name_min_length",
+ "domain_catalog",
+ "description_min_length",
+ "business_goal_min_length",
+ "parameter_contracts",
+ ],
+ }
+
+ def _list_latest_metadata_entries(self) -> list[ToolMetadata]:
+ if self.metadata_repository is None:
+ return []
+
+ latest_by_tool_name: dict[str, ToolMetadata] = {}
+ for metadata in self.metadata_repository.list_metadata():
+ normalized_tool_name = str(metadata.tool_name or "").strip().lower()
+ if normalized_tool_name in latest_by_tool_name:
+ continue
+ latest_by_tool_name[normalized_tool_name] = metadata
+ return list(latest_by_tool_name.values())
+
+ def _serialize_metadata_publication(self, metadata: ToolMetadata) -> dict:
+ parameters = self._serialize_parameters_for_response(metadata.parameters_json)
+ return {
+ "publication_id": metadata.metadata_id,
+ "tool_name": metadata.tool_name,
+ "display_name": metadata.display_name,
+ "description": metadata.description,
+ "domain": metadata.domain,
+ "version": metadata.version_number,
+ "status": metadata.status,
+ "parameter_count": len(parameters),
+ "parameters": parameters,
+ "author_name": metadata.author_display_name,
+ "implementation_module": build_generated_tool_module_name(metadata.tool_name),
+ "implementation_callable": GENERATED_TOOL_ENTRYPOINT,
+ "published_by": metadata.author_display_name,
+ "published_at": metadata.updated_at or metadata.created_at,
+ }
+
def _serialize_draft_summary(self, draft: ToolDraft) -> dict:
return {
"draft_id": draft.draft_id,
@@ -654,6 +868,10 @@ class ToolManagementService:
tool_name = str(payload.get("tool_name") or "").strip().lower()
if not _TOOL_NAME_PATTERN.fullmatch(tool_name):
raise ValueError("tool_name deve usar snake_case minusculo com 3 a 64 caracteres.")
+ if tool_name in _RESERVED_CORE_TOOL_NAMES:
+ raise ValueError(
+ "tool_name reservado pelo catalogo core do sistema. Gere uma nova tool sem sobrescrever uma capability interna."
+ )
display_name = str(payload.get("display_name") or "").strip()
if len(display_name) < 4:
diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js
index 38e7ac0..ea6f25e 100644
--- a/admin_app/view/static/scripts/panel.js
+++ b/admin_app/view/static/scripts/panel.js
@@ -224,7 +224,7 @@ function mountToolReviewBoard(board) {
setText("[data-tool-review-publication-count]", String(items.length));
setText("[data-tool-publication-source]", payload?.source || "Catalogo web");
publicationList.innerHTML = items.length > 0
- ? items.slice(0, 9).map((item) => `
${escapeHtml(item.domain || "tool")}
${escapeHtml(item.display_name || item.tool_name || "Tool")}
${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}
${escapeHtml(item.implementation_module || "")}
`).join("")
+ ? items.slice(0, 9).map((item) => `${escapeHtml(item.domain || "tool")}
${escapeHtml(item.display_name || item.tool_name || "Tool")}
${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}
Status: ${escapeHtml(item.status || "draft")}
Parametros: ${escapeHtml(String(item.parameter_count || 0))}
Autor: ${escapeHtml(item.author_name || item.published_by || "Nao informado")}
${escapeHtml(item.implementation_module || "")}
`).join("")
: ``;
}
diff --git a/app/db/bootstrap.py b/app/db/bootstrap.py
index b25ba0a..d22cf02 100644
--- a/app/db/bootstrap.py
+++ b/app/db/bootstrap.py
@@ -3,6 +3,8 @@ Rotina dedicada de bootstrap de banco de dados.
Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
"""
+from pathlib import Path
+
from sqlalchemy import inspect, text
from app.core.settings import settings
@@ -23,6 +25,22 @@ from app.db.mock_models import (
)
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
+from shared.contracts import GENERATED_TOOLS_PACKAGE
+
+
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _ensure_generated_tools_runtime_package() -> Path:
+ package_dir = _PROJECT_ROOT / GENERATED_TOOLS_PACKAGE
+ package_dir.mkdir(parents=True, exist_ok=True)
+ init_file = package_dir / "__init__.py"
+ if not init_file.exists():
+ init_file.write_text(
+ '"""Isolated runtime package for admin-governed generated tools."""\n',
+ encoding="utf-8",
+ )
+ return package_dir
def _ensure_mock_schema_evolution() -> None:
@@ -56,6 +74,13 @@ def bootstrap_databases(
print("Inicializando bancos...")
failures: list[str] = []
+ try:
+ generated_tools_dir = _ensure_generated_tools_runtime_package()
+ print(f"Diretorio isolado de tools geradas pronto em {generated_tools_dir}.")
+ except Exception as exc:
+ print(f"Aviso: falha ao preparar diretorio isolado de tools geradas: {exc}")
+ failures.append(f"generated_tools={exc}")
+
should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
should_seed_mock = (
settings.auto_seed_mock and settings.mock_seed_enabled
diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py
index 28cef6f..fbaf951 100644
--- a/app/services/tools/tool_registry.py
+++ b/app/services/tools/tool_registry.py
@@ -2,6 +2,7 @@ import inspect
from typing import Callable, Dict, List
from fastapi import HTTPException
+from shared.contracts import GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE
from sqlalchemy.orm import Session
from app.models.tool_model import ToolDefinition
@@ -42,6 +43,10 @@ HANDLERS: Dict[str, Callable] = {
}
+class GeneratedToolCoreBoundaryViolation(RuntimeError):
+ """Raised when a generated tool attempts to reuse or point at core runtime code."""
+
+
# Registry em memoria das tools disponiveis para o orquestrador.
class ToolRegistry:
@@ -66,6 +71,26 @@ class ToolRegistry:
def register_tool(self, name, description, parameters, handler):
"""Registra uma tool em memoria para uso pelo orquestrador."""
+ if self._is_generated_handler(handler):
+ self._ensure_generated_tool_boundary(name=name, handler=handler)
+ self._append_tool_definition(
+ name=name,
+ description=description,
+ parameters=parameters,
+ handler=handler,
+ )
+
+ def register_generated_tool(self, name, description, parameters, handler):
+ """Registra uma tool gerada apenas quando ela respeita o pacote isolado do runtime."""
+ self._ensure_generated_tool_boundary(name=name, handler=handler)
+ self._append_tool_definition(
+ name=name,
+ description=description,
+ parameters=parameters,
+ handler=handler,
+ )
+
+ def _append_tool_definition(self, *, name, description, parameters, handler):
self._tools.append(
ToolDefinition(
name=name,
@@ -75,6 +100,35 @@ class ToolRegistry:
)
)
+ @staticmethod
+ def _is_generated_handler(handler: Callable) -> bool:
+ module_name = str(getattr(handler, "__module__", "") or "").strip()
+ return module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}.")
+
+ def _ensure_generated_tool_boundary(self, *, name: str, handler: Callable) -> None:
+ normalized_name = str(name or "").strip().lower()
+ if normalized_name in HANDLERS:
+ raise GeneratedToolCoreBoundaryViolation(
+ f"Tool gerada '{normalized_name}' nao pode sobrescrever um handler do catalogo core."
+ )
+
+ if any(str(tool.name or "").strip().lower() == normalized_name for tool in self._tools):
+ raise GeneratedToolCoreBoundaryViolation(
+ f"Tool gerada '{normalized_name}' nao pode sobrescrever uma tool ja registrada no runtime."
+ )
+
+ module_name = str(getattr(handler, "__module__", "") or "").strip()
+ if not module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}."):
+ raise GeneratedToolCoreBoundaryViolation(
+ f"Tools geradas so podem ser carregadas do pacote isolado '{GENERATED_TOOLS_PACKAGE}.*'."
+ )
+
+ handler_name = str(getattr(handler, "__name__", "") or "").strip()
+ if handler_name != GENERATED_TOOL_ENTRYPOINT:
+ raise GeneratedToolCoreBoundaryViolation(
+ f"Tools geradas precisam expor o entrypoint governado '{GENERATED_TOOL_ENTRYPOINT}'."
+ )
+
def get_tools(self) -> List[ToolDefinition]:
"""Retorna a lista atual de tools registradas."""
return self._tools
diff --git a/generated_tools/README.md b/generated_tools/README.md
new file mode 100644
index 0000000..138b0f6
--- /dev/null
+++ b/generated_tools/README.md
@@ -0,0 +1,3 @@
+# Generated Tools
+
+Diretorio isolado para modulos publicados pelo fluxo administrativo de tools.
diff --git a/generated_tools/__init__.py b/generated_tools/__init__.py
new file mode 100644
index 0000000..8f649bd
--- /dev/null
+++ b/generated_tools/__init__.py
@@ -0,0 +1 @@
+"""Isolated runtime package for admin-governed generated tools."""
diff --git a/shared/contracts/__init__.py b/shared/contracts/__init__.py
index 7dd3369..c9aa8df 100644
--- a/shared/contracts/__init__.py
+++ b/shared/contracts/__init__.py
@@ -50,6 +50,8 @@ from shared.contracts.system_functional_configuration import (
get_functional_configuration,
)
from shared.contracts.tool_publication import (
+ GENERATED_TOOL_ENTRYPOINT,
+ GENERATED_TOOLS_PACKAGE,
PublishedToolContract,
ServiceName,
TOOL_LIFECYCLE_STAGES,
@@ -59,12 +61,16 @@ from shared.contracts.tool_publication import (
ToolParameterContract,
ToolParameterType,
ToolPublicationEnvelope,
+ build_generated_tool_module_name,
+ build_generated_tool_module_path,
get_tool_lifecycle_stage,
)
__all__ = [
"AdminPermission",
"BOT_GOVERNED_SETTINGS",
+ "GENERATED_TOOL_ENTRYPOINT",
+ "GENERATED_TOOLS_PACKAGE",
"MODEL_RUNTIME_PROFILES",
"MODEL_RUNTIME_SEPARATION_RULES",
"PRODUCT_OPERATIONAL_DATASETS",
@@ -103,6 +109,8 @@ __all__ = [
"FunctionalConfigurationMutability",
"FunctionalConfigurationPropagation",
"FunctionalConfigurationSource",
+ "build_generated_tool_module_name",
+ "build_generated_tool_module_path",
"get_bot_governed_setting",
"get_functional_configuration",
"get_model_runtime_contract",
diff --git a/shared/contracts/tool_publication.py b/shared/contracts/tool_publication.py
index 9bb0514..1b73a93 100644
--- a/shared/contracts/tool_publication.py
+++ b/shared/contracts/tool_publication.py
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
+import re
from pydantic import BaseModel, Field
@@ -101,6 +102,28 @@ def get_tool_lifecycle_stage(
return _TOOL_LIFECYCLE_STAGE_BY_STATUS[normalized_status]
+GENERATED_TOOLS_PACKAGE = "generated_tools"
+GENERATED_TOOL_ENTRYPOINT = "run"
+_GENERATED_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$")
+
+
+def _normalize_generated_tool_name(tool_name: str) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ if not _GENERATED_TOOL_NAME_PATTERN.match(normalized):
+ raise ValueError("tool_name must use lowercase snake_case to build the generated module path.")
+ return normalized
+
+
+def build_generated_tool_module_name(tool_name: str) -> str:
+ normalized = _normalize_generated_tool_name(tool_name)
+ return f"{GENERATED_TOOLS_PACKAGE}.{normalized}"
+
+
+def build_generated_tool_module_path(tool_name: str) -> str:
+ normalized = _normalize_generated_tool_name(tool_name)
+ return f"{GENERATED_TOOLS_PACKAGE}/{normalized}.py"
+
+
class ToolParameterType(str, Enum):
STRING = "string"
INTEGER = "integer"
diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py
index 5d1d25b..24a993e 100644
--- a/tests/test_admin_panel_tools_web.py
+++ b/tests/test_admin_panel_tools_web.py
@@ -9,9 +9,15 @@ from admin_app.api.dependencies import (
)
from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
-from admin_app.db.models import ToolDraft, ToolVersion
+from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
+from admin_app.db.models.tool_artifact import ToolArtifactKind
from admin_app.services import ToolManagementService
-from shared.contracts import StaffRole, ToolLifecycleStatus
+from shared.contracts import (
+ GENERATED_TOOL_ENTRYPOINT,
+ StaffRole,
+ ToolLifecycleStatus,
+ build_generated_tool_module_name,
+)
class _FakeToolDraftRepository:
@@ -76,7 +82,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
- key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
+ key=lambda version: (
+ version.version_number,
+ version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
reverse=True,
)
if tool_name:
@@ -113,12 +122,153 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}"
+class _FakeToolMetadataRepository:
+ def __init__(self):
+ self.metadata_entries: list[ToolMetadata] = []
+ self.next_id = 1
+
+ def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]:
+ metadata_entries = sorted(
+ self.metadata_entries,
+ key=lambda metadata: (
+ metadata.version_number,
+ metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized]
+ if statuses:
+ allowed = set(statuses)
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed]
+ return metadata_entries
+
+ def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None:
+ for metadata in self.metadata_entries:
+ if metadata.tool_version_id == tool_version_id:
+ return metadata
+ return None
+
+ def create(self, **kwargs) -> ToolMetadata:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 19, version_number, tzinfo=timezone.utc)
+ metadata = ToolMetadata(
+ id=self.next_id,
+ metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number),
+ created_at=now,
+ updated_at=now,
+ **kwargs,
+ )
+ self.next_id += 1
+ self.metadata_entries.append(metadata)
+ return metadata
+
+ def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata:
+ metadata.display_name = kwargs["display_name"]
+ metadata.domain = kwargs["domain"]
+ metadata.description = kwargs["description"]
+ metadata.parameters_json = kwargs["parameters_json"]
+ metadata.status = kwargs["status"]
+ metadata.author_staff_account_id = kwargs["author_staff_account_id"]
+ metadata.author_display_name = kwargs["author_display_name"]
+ metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, tzinfo=timezone.utc)
+ return metadata
+
+ def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
+ existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_metadata(existing, **kwargs)
+
+ @staticmethod
+ def build_metadata_id(tool_name: str, version_number: int) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_metadata::{normalized}::v{int(version_number)}"
+
+
+class _FakeToolArtifactRepository:
+ def __init__(self):
+ self.artifacts: list[ToolArtifact] = []
+ self.next_id = 1
+
+ def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]:
+ artifacts = sorted(
+ self.artifacts,
+ key=lambda artifact: (
+ artifact.version_number,
+ artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized]
+ if tool_version_id is not None:
+ artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id]
+ if artifact_stage:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage]
+ if artifact_kind:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind]
+ return artifacts
+
+ def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None:
+ for artifact in self.artifacts:
+ if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind:
+ return artifact
+ return None
+
+ def create(self, **kwargs) -> ToolArtifact:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 20, version_number, self.next_id, tzinfo=timezone.utc)
+ artifact = ToolArtifact(
+ id=self.next_id,
+ artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]),
+ created_at=now,
+ updated_at=now,
+ checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}",
+ **kwargs,
+ )
+ self.next_id += 1
+ self.artifacts.append(artifact)
+ return artifact
+
+ def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact:
+ artifact.artifact_status = kwargs["artifact_status"]
+ artifact.storage_kind = kwargs["storage_kind"]
+ artifact.summary = kwargs["summary"]
+ artifact.payload_json = kwargs["payload_json"]
+ artifact.checksum = kwargs["checksum"]
+ artifact.author_staff_account_id = kwargs["author_staff_account_id"]
+ artifact.author_display_name = kwargs["author_display_name"]
+ artifact.updated_at = datetime(2026, 3, 31, 20, artifact.version_number, tzinfo=timezone.utc)
+ return artifact
+
+ def upsert_version_artifact(self, **kwargs) -> ToolArtifact:
+ existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_artifact(existing, **kwargs)
+
+ @staticmethod
+ def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}"
+
+
class AdminPanelToolsWebTests(unittest.TestCase):
def _build_client_with_role(
self,
role: StaffRole,
settings: AdminSettings | None = None,
- ) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]:
+ ) -> tuple[
+ TestClient,
+ object,
+ _FakeToolDraftRepository,
+ _FakeToolVersionRepository,
+ _FakeToolMetadataRepository,
+ _FakeToolArtifactRepository,
+ ]:
app = create_app(
settings
or AdminSettings(
@@ -128,10 +278,14 @@ class AdminPanelToolsWebTests(unittest.TestCase):
)
draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository()
+ metadata_repository = _FakeToolMetadataRepository()
+ artifact_repository = _FakeToolArtifactRepository()
service = ToolManagementService(
settings=app.state.admin_settings,
draft_repository=draft_repository,
version_repository=version_repository,
+ metadata_repository=metadata_repository,
+ artifact_repository=artifact_repository,
)
app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=21,
@@ -141,10 +295,10 @@ class AdminPanelToolsWebTests(unittest.TestCase):
is_active=True,
)
app.dependency_overrides[get_tool_management_service] = lambda: service
- return TestClient(app), app, draft_repository, version_repository
+ return TestClient(app), app, draft_repository, version_repository, metadata_repository, artifact_repository
def test_panel_tools_overview_is_available_for_colaborador_session(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/overview")
finally:
@@ -154,11 +308,37 @@ class AdminPanelToolsWebTests(unittest.TestCase):
payload = response.json()
self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]])
+ self.assertIn("persisted_metadata", [item["key"] for item in payload["metrics"]])
+ self.assertIn("persisted_artifacts", [item["key"] for item in payload["metrics"]])
self.assertIn("/admin/panel/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/panel/tools/drafts/intake", [item["href"] for item in payload["actions"]])
- def test_panel_tool_intake_persists_draft_with_version_metadata_for_colaborador(self):
- client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
+ def test_panel_tool_intake_blocks_tool_name_reserved_by_core_catalog_for_colaborador(self):
+ client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
+ try:
+ response = client.post(
+ "/admin/panel/tools/drafts/intake",
+ json={
+ "domain": "vendas",
+ "tool_name": "consultar_estoque",
+ "display_name": "Consultar estoque paralelo",
+ "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.",
+ "business_goal": "Validar que o painel bloqueia nomes reservados do runtime core.",
+ "parameters": [],
+ },
+ )
+ finally:
+ app.dependency_overrides.clear()
+
+ self.assertEqual(response.status_code, 422)
+ self.assertIn("catalogo core do sistema", response.json()["detail"])
+ self.assertEqual(len(draft_repository.drafts), 0)
+ self.assertEqual(len(version_repository.versions), 0)
+ self.assertEqual(len(metadata_repository.metadata_entries), 0)
+ self.assertEqual(len(artifact_repository.artifacts), 0)
+
+ def test_panel_tool_intake_persists_draft_with_version_metadata_and_artifacts_for_colaborador(self):
+ client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.post(
"/admin/panel/tools/drafts/intake",
@@ -199,9 +379,13 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(len(payload["draft_preview"]["parameters"]), 2)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 1)
+ self.assertEqual(len(metadata_repository.metadata_entries), 1)
+ self.assertEqual(len(artifact_repository.artifacts), 2)
+ artifact_kinds = {artifact.artifact_kind for artifact in artifact_repository.artifacts}
+ self.assertEqual(artifact_kinds, {ToolArtifactKind.GENERATION_REQUEST, ToolArtifactKind.VALIDATION_REPORT})
def test_panel_drafts_list_returns_single_root_item_after_new_version(self):
- client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
client.post(
"/admin/panel/tools/drafts/intake",
@@ -238,9 +422,11 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 2)
+ self.assertEqual(len(metadata_repository.metadata_entries), 2)
+ self.assertEqual(len(artifact_repository.artifacts), 4)
def test_panel_tools_review_queue_requires_director_session(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/review-queue")
finally:
@@ -253,7 +439,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
)
def test_panel_tools_review_queue_is_available_for_diretor_session(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/panel/tools/review-queue")
finally:
@@ -263,7 +449,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state")
def test_panel_tools_publications_require_director_publication_permission(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/publications")
finally:
@@ -276,7 +462,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
)
def test_panel_tools_publications_return_catalog_for_diretor_session(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/panel/tools/publications")
finally:
@@ -284,10 +470,51 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
+ self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertEqual(payload["target_service"], "product")
self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]])
+ def test_panel_tools_publications_prefer_persisted_metadata_for_diretor_session(self):
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ try:
+ intake_response = client.post(
+ "/admin/panel/tools/drafts/intake",
+ json={
+ "domain": "locacao",
+ "tool_name": "emitir_resumo_locacao",
+ "display_name": "Emitir resumo de locacao",
+ "description": "Resume contratos de locacao com filtros operacionais para o time interno.",
+ "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
+ "parameters": [
+ {
+ "name": "contrato_id",
+ "parameter_type": "string",
+ "description": "Identificador do contrato consultado.",
+ "required": True,
+ }
+ ],
+ },
+ )
+ response = client.get("/admin/panel/tools/publications")
+ finally:
+ app.dependency_overrides.clear()
+
+ self.assertEqual(intake_response.status_code, 200)
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(payload["source"], "admin_metadata_catalog")
+ self.assertEqual(len(payload["publications"]), 1)
+ publication = payload["publications"][0]
+ self.assertEqual(publication["publication_id"], "tool_metadata::emitir_resumo_locacao::v1")
+ self.assertEqual(publication["tool_name"], "emitir_resumo_locacao")
+ self.assertEqual(publication["status"], "draft")
+ self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("emitir_resumo_locacao"))
+ self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
+ self.assertEqual(publication["parameter_count"], 1)
+ self.assertEqual(publication["author_name"], "Equipe Web")
+ self.assertEqual(publication["parameters"][0]["name"], "contrato_id")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_admin_tool_artifact_model.py b/tests/test_admin_tool_artifact_model.py
new file mode 100644
index 0000000..324a19c
--- /dev/null
+++ b/tests/test_admin_tool_artifact_model.py
@@ -0,0 +1,61 @@
+import unittest
+
+from admin_app.db.models import ToolArtifact
+from admin_app.db.models.tool_artifact import (
+ ToolArtifactKind,
+ ToolArtifactStage,
+ ToolArtifactStatus,
+ ToolArtifactStorageKind,
+)
+
+
+class ToolArtifactModelTests(unittest.TestCase):
+ def test_tool_artifact_declares_expected_table_and_columns(self):
+ self.assertEqual(ToolArtifact.__tablename__, "tool_artifacts")
+ self.assertIn("artifact_id", ToolArtifact.__table__.columns)
+ self.assertIn("draft_id", ToolArtifact.__table__.columns)
+ self.assertIn("tool_version_id", ToolArtifact.__table__.columns)
+ self.assertIn("tool_name", ToolArtifact.__table__.columns)
+ self.assertIn("version_number", ToolArtifact.__table__.columns)
+ self.assertIn("artifact_stage", ToolArtifact.__table__.columns)
+ self.assertIn("artifact_kind", ToolArtifact.__table__.columns)
+ self.assertIn("artifact_status", ToolArtifact.__table__.columns)
+ self.assertIn("storage_kind", ToolArtifact.__table__.columns)
+ self.assertIn("summary", ToolArtifact.__table__.columns)
+ self.assertIn("payload_json", ToolArtifact.__table__.columns)
+ self.assertIn("checksum", ToolArtifact.__table__.columns)
+ self.assertIn("author_staff_account_id", ToolArtifact.__table__.columns)
+ self.assertIn("author_display_name", ToolArtifact.__table__.columns)
+
+ def test_tool_artifact_uses_expected_constraints_and_defaults(self):
+ draft_foreign_keys = list(ToolArtifact.__table__.columns["draft_id"].foreign_keys)
+ self.assertEqual(len(draft_foreign_keys), 1)
+ self.assertEqual(str(draft_foreign_keys[0].target_fullname), "tool_drafts.id")
+
+ version_foreign_keys = list(ToolArtifact.__table__.columns["tool_version_id"].foreign_keys)
+ self.assertEqual(len(version_foreign_keys), 1)
+ self.assertEqual(str(version_foreign_keys[0].target_fullname), "tool_versions.id")
+
+ author_foreign_keys = list(ToolArtifact.__table__.columns["author_staff_account_id"].foreign_keys)
+ self.assertEqual(len(author_foreign_keys), 1)
+ self.assertEqual(str(author_foreign_keys[0].target_fullname), "staff_accounts.id")
+
+ status_column = ToolArtifact.__table__.columns["artifact_status"]
+ self.assertEqual(status_column.default.arg, ToolArtifactStatus.PENDING)
+ self.assertEqual(status_column.type.process_bind_param("succeeded", None), "succeeded")
+ self.assertEqual(status_column.type.process_result_value("pending", None), ToolArtifactStatus.PENDING)
+
+ stage_column = ToolArtifact.__table__.columns["artifact_stage"]
+ kind_column = ToolArtifact.__table__.columns["artifact_kind"]
+ storage_column = ToolArtifact.__table__.columns["storage_kind"]
+ self.assertEqual(stage_column.type.process_bind_param("validation", None), "validation")
+ self.assertEqual(kind_column.type.process_result_value("generation_request", None), ToolArtifactKind.GENERATION_REQUEST)
+ self.assertEqual(storage_column.default.arg, ToolArtifactStorageKind.INLINE_JSON)
+ self.assertEqual(storage_column.type.process_result_value("inline_json", None), ToolArtifactStorageKind.INLINE_JSON)
+
+ unique_constraints = {constraint.name for constraint in ToolArtifact.__table__.constraints}
+ self.assertIn("uq_tool_artifacts_tool_version_kind", unique_constraints)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py
index 0273d91..4b968f0 100644
--- a/tests/test_admin_tool_management_service.py
+++ b/tests/test_admin_tool_management_service.py
@@ -2,9 +2,17 @@ import unittest
from datetime import datetime, timezone
from admin_app.core import AdminSettings
-from admin_app.db.models import ToolDraft, ToolVersion
+from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
+from admin_app.db.models.tool_artifact import ToolArtifactKind
from admin_app.services.tool_management_service import ToolManagementService
-from shared.contracts import ToolLifecycleStatus
+from shared.contracts import (
+ GENERATED_TOOL_ENTRYPOINT,
+ GENERATED_TOOLS_PACKAGE,
+ ToolLifecycleStatus,
+ ToolParameterType,
+ build_generated_tool_module_name,
+ build_generated_tool_module_path,
+)
class _FakeToolDraftRepository:
@@ -116,7 +124,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
- key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
+ key=lambda version: (
+ version.version_number,
+ version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
reverse=True,
)
if tool_name:
@@ -179,17 +190,155 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}"
+class _FakeToolMetadataRepository:
+ def __init__(self):
+ self.metadata_entries: list[ToolMetadata] = []
+ self.next_id = 1
+
+ def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]:
+ metadata_entries = sorted(
+ self.metadata_entries,
+ key=lambda metadata: (
+ metadata.version_number,
+ metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized]
+ if statuses:
+ allowed = set(statuses)
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed]
+ return metadata_entries
+
+ def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None:
+ for metadata in self.metadata_entries:
+ if metadata.tool_version_id == tool_version_id:
+ return metadata
+ return None
+
+ def create(self, **kwargs) -> ToolMetadata:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc)
+ metadata = ToolMetadata(
+ id=self.next_id,
+ metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number),
+ created_at=now,
+ updated_at=now,
+ **kwargs,
+ )
+ self.next_id += 1
+ self.metadata_entries.append(metadata)
+ return metadata
+
+ def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata:
+ metadata.display_name = kwargs["display_name"]
+ metadata.domain = kwargs["domain"]
+ metadata.description = kwargs["description"]
+ metadata.parameters_json = kwargs["parameters_json"]
+ metadata.status = kwargs["status"]
+ metadata.author_staff_account_id = kwargs["author_staff_account_id"]
+ metadata.author_display_name = kwargs["author_display_name"]
+ metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, tzinfo=timezone.utc)
+ return metadata
+
+ def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
+ existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_metadata(existing, **kwargs)
+
+ @staticmethod
+ def build_metadata_id(tool_name: str, version_number: int) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_metadata::{normalized}::v{int(version_number)}"
+
+
+class _FakeToolArtifactRepository:
+ def __init__(self):
+ self.artifacts: list[ToolArtifact] = []
+ self.next_id = 1
+
+ def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]:
+ artifacts = sorted(
+ self.artifacts,
+ key=lambda artifact: (
+ artifact.version_number,
+ artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized]
+ if tool_version_id is not None:
+ artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id]
+ if artifact_stage:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage]
+ if artifact_kind:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind]
+ return artifacts
+
+ def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None:
+ for artifact in self.artifacts:
+ if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind:
+ return artifact
+ return None
+
+ def create(self, **kwargs) -> ToolArtifact:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 18, version_number, self.next_id, tzinfo=timezone.utc)
+ artifact = ToolArtifact(
+ id=self.next_id,
+ artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]),
+ created_at=now,
+ updated_at=now,
+ checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}",
+ **kwargs,
+ )
+ self.next_id += 1
+ self.artifacts.append(artifact)
+ return artifact
+
+ def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact:
+ artifact.artifact_status = kwargs["artifact_status"]
+ artifact.storage_kind = kwargs["storage_kind"]
+ artifact.summary = kwargs["summary"]
+ artifact.payload_json = kwargs["payload_json"]
+ artifact.checksum = kwargs["checksum"]
+ artifact.author_staff_account_id = kwargs["author_staff_account_id"]
+ artifact.author_display_name = kwargs["author_display_name"]
+ artifact.updated_at = datetime(2026, 3, 31, 18, artifact.version_number, tzinfo=timezone.utc)
+ return artifact
+
+ def upsert_version_artifact(self, **kwargs) -> ToolArtifact:
+ existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_artifact(existing, **kwargs)
+
+ @staticmethod
+ def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}"
+
+
class AdminToolManagementServiceTests(unittest.TestCase):
def setUp(self):
self.draft_repository = _FakeToolDraftRepository()
self.version_repository = _FakeToolVersionRepository()
+ self.metadata_repository = _FakeToolMetadataRepository()
+ self.artifact_repository = _FakeToolArtifactRepository()
self.service = ToolManagementService(
settings=AdminSettings(admin_api_prefix="/admin"),
draft_repository=self.draft_repository,
version_repository=self.version_repository,
+ metadata_repository=self.metadata_repository,
+ artifact_repository=self.artifact_repository,
)
- def test_create_draft_submission_persists_initial_tool_version(self):
+ def test_create_draft_submission_persists_initial_tool_version_metadata_and_artifacts(self):
payload = self.service.create_draft_submission(
{
"domain": "vendas",
@@ -225,6 +374,56 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna")
self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 1)
+ self.assertEqual(len(self.metadata_repository.metadata_entries), 1)
+ self.assertEqual(self.metadata_repository.metadata_entries[0].author_display_name, "Equipe Interna")
+ self.assertEqual(self.metadata_repository.metadata_entries[0].version_number, 1)
+ self.assertEqual(len(self.artifact_repository.artifacts), 2)
+
+ artifacts_by_kind = {
+ artifact.artifact_kind: artifact
+ for artifact in self.artifact_repository.artifacts
+ }
+ generation_artifact = artifacts_by_kind[ToolArtifactKind.GENERATION_REQUEST]
+ validation_artifact = artifacts_by_kind[ToolArtifactKind.VALIDATION_REPORT]
+
+ self.assertEqual(generation_artifact.tool_name, "consultar_vendas_periodo")
+ self.assertEqual(generation_artifact.payload_json["target_package"], GENERATED_TOOLS_PACKAGE)
+ self.assertEqual(
+ generation_artifact.payload_json["target_module"],
+ build_generated_tool_module_name("consultar_vendas_periodo"),
+ )
+ self.assertEqual(
+ generation_artifact.payload_json["target_file_path"],
+ build_generated_tool_module_path("consultar_vendas_periodo"),
+ )
+ self.assertEqual(generation_artifact.payload_json["target_callable"], GENERATED_TOOL_ENTRYPOINT)
+ self.assertEqual(generation_artifact.payload_json["reserved_lifecycle_target"], "generated")
+ self.assertEqual(generation_artifact.payload_json["parameters"][0]["name"], "periodo_inicio")
+ self.assertEqual(validation_artifact.payload_json["validation_status"], "passed")
+ self.assertEqual(validation_artifact.payload_json["parameter_count"], 2)
+ self.assertEqual(validation_artifact.author_display_name, "Equipe Interna")
+ self.assertTrue(generation_artifact.checksum)
+ self.assertTrue(validation_artifact.checksum)
+
+ def test_create_draft_submission_blocks_tool_name_reserved_by_core_catalog(self):
+ with self.assertRaisesRegex(ValueError, "catalogo core do sistema"):
+ self.service.create_draft_submission(
+ {
+ "domain": "vendas",
+ "tool_name": "consultar_estoque",
+ "display_name": "Consultar estoque paralelo",
+ "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.",
+ "business_goal": "Validar que o admin nao permite reutilizar nomes reservados do runtime core.",
+ "parameters": [],
+ },
+ owner_staff_account_id=11,
+ owner_name="Equipe Interna",
+ )
+
+ self.assertEqual(len(self.draft_repository.drafts), 0)
+ self.assertEqual(len(self.version_repository.versions), 0)
+ self.assertEqual(len(self.metadata_repository.metadata_entries), 0)
+ self.assertEqual(len(self.artifact_repository.artifacts), 0)
def test_create_draft_submission_reuses_root_draft_and_increments_version(self):
self.service.create_draft_submission(
@@ -265,6 +464,8 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["draft_preview"]["version_count"], 2)
self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 2)
+ self.assertEqual(len(self.metadata_repository.metadata_entries), 2)
+ self.assertEqual(len(self.artifact_repository.artifacts), 4)
self.assertEqual(self.draft_repository.drafts[0].current_version_number, 2)
self.assertEqual(self.draft_repository.drafts[0].version_count, 2)
self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao")
@@ -305,6 +506,43 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["owner_name"], "Diretoria Comercial")
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT])
+ def test_build_publications_payload_prefers_persisted_metadata_catalog(self):
+ self.service.create_draft_submission(
+ {
+ "domain": "revisao",
+ "tool_name": "consultar_revisao_aberta",
+ "display_name": "Consultar revisao aberta",
+ "description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
+ "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
+ "parameters": [
+ {
+ "name": "placa",
+ "parameter_type": "string",
+ "description": "Placa usada na busca da revisao.",
+ "required": True,
+ }
+ ],
+ },
+ owner_staff_account_id=8,
+ owner_name="Operacao de Oficina",
+ )
+
+ payload = self.service.build_publications_payload()
+
+ self.assertEqual(payload["source"], "admin_metadata_catalog")
+ self.assertEqual(payload["target_service"], "product")
+ self.assertEqual(len(payload["publications"]), 1)
+ publication = payload["publications"][0]
+ self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
+ self.assertEqual(publication["tool_name"], "consultar_revisao_aberta")
+ self.assertEqual(publication["version"], 1)
+ self.assertEqual(publication["status"], ToolLifecycleStatus.DRAFT)
+ self.assertEqual(publication["author_name"], "Operacao de Oficina")
+ self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))
+ self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
+ self.assertEqual(publication["parameter_count"], 1)
+ self.assertEqual(publication["parameters"][0]["parameter_type"], ToolParameterType.STRING)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_admin_tool_metadata_model.py b/tests/test_admin_tool_metadata_model.py
new file mode 100644
index 0000000..64015a9
--- /dev/null
+++ b/tests/test_admin_tool_metadata_model.py
@@ -0,0 +1,49 @@
+import unittest
+
+from admin_app.db.models import ToolMetadata
+from shared.contracts import ToolLifecycleStatus
+
+
+class ToolMetadataModelTests(unittest.TestCase):
+ def test_tool_metadata_declares_expected_table_and_columns(self):
+ self.assertEqual(ToolMetadata.__tablename__, "tool_metadata")
+ self.assertIn("metadata_id", ToolMetadata.__table__.columns)
+ self.assertIn("draft_id", ToolMetadata.__table__.columns)
+ self.assertIn("tool_version_id", ToolMetadata.__table__.columns)
+ self.assertIn("tool_name", ToolMetadata.__table__.columns)
+ self.assertIn("display_name", ToolMetadata.__table__.columns)
+ self.assertIn("domain", ToolMetadata.__table__.columns)
+ self.assertIn("description", ToolMetadata.__table__.columns)
+ self.assertIn("parameters_json", ToolMetadata.__table__.columns)
+ self.assertIn("version_number", ToolMetadata.__table__.columns)
+ self.assertIn("status", ToolMetadata.__table__.columns)
+ self.assertIn("author_staff_account_id", ToolMetadata.__table__.columns)
+ self.assertIn("author_display_name", ToolMetadata.__table__.columns)
+ self.assertIn("created_at", ToolMetadata.__table__.columns)
+ self.assertIn("updated_at", ToolMetadata.__table__.columns)
+
+ def test_tool_metadata_uses_expected_constraints_and_defaults(self):
+ draft_foreign_keys = list(ToolMetadata.__table__.columns["draft_id"].foreign_keys)
+ self.assertEqual(len(draft_foreign_keys), 1)
+ self.assertEqual(str(draft_foreign_keys[0].target_fullname), "tool_drafts.id")
+
+ version_foreign_keys = list(ToolMetadata.__table__.columns["tool_version_id"].foreign_keys)
+ self.assertEqual(len(version_foreign_keys), 1)
+ self.assertEqual(str(version_foreign_keys[0].target_fullname), "tool_versions.id")
+
+ author_foreign_keys = list(ToolMetadata.__table__.columns["author_staff_account_id"].foreign_keys)
+ self.assertEqual(len(author_foreign_keys), 1)
+ self.assertEqual(str(author_foreign_keys[0].target_fullname), "staff_accounts.id")
+
+ status_column = ToolMetadata.__table__.columns["status"]
+ self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT)
+ self.assertEqual(status_column.type.process_bind_param("archived", None), "archived")
+ self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT)
+
+ unique_constraints = {constraint.name for constraint in ToolMetadata.__table__.constraints}
+ self.assertIn("uq_tool_metadata_tool_name_version_number", unique_constraints)
+ self.assertTrue(ToolMetadata.__table__.columns["tool_version_id"].unique)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py
index f738f13..a2f63fd 100644
--- a/tests/test_admin_tools_web.py
+++ b/tests/test_admin_tools_web.py
@@ -6,9 +6,15 @@ from fastapi.testclient import TestClient
from admin_app.api.dependencies import get_current_staff_principal, get_tool_management_service
from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
-from admin_app.db.models import ToolDraft, ToolVersion
+from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
+from admin_app.db.models.tool_artifact import ToolArtifactKind
from admin_app.services import ToolManagementService
-from shared.contracts import StaffRole, ToolLifecycleStatus
+from shared.contracts import (
+ GENERATED_TOOL_ENTRYPOINT,
+ StaffRole,
+ ToolLifecycleStatus,
+ build_generated_tool_module_name,
+)
class _FakeToolDraftRepository:
@@ -73,7 +79,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
- key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
+ key=lambda version: (
+ version.version_number,
+ version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
reverse=True,
)
if tool_name:
@@ -110,12 +119,153 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}"
+class _FakeToolMetadataRepository:
+ def __init__(self):
+ self.metadata_entries: list[ToolMetadata] = []
+ self.next_id = 1
+
+ def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]:
+ metadata_entries = sorted(
+ self.metadata_entries,
+ key=lambda metadata: (
+ metadata.version_number,
+ metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized]
+ if statuses:
+ allowed = set(statuses)
+ metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed]
+ return metadata_entries
+
+ def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None:
+ for metadata in self.metadata_entries:
+ if metadata.tool_version_id == tool_version_id:
+ return metadata
+ return None
+
+ def create(self, **kwargs) -> ToolMetadata:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc)
+ metadata = ToolMetadata(
+ id=self.next_id,
+ metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number),
+ created_at=now,
+ updated_at=now,
+ **kwargs,
+ )
+ self.next_id += 1
+ self.metadata_entries.append(metadata)
+ return metadata
+
+ def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata:
+ metadata.display_name = kwargs["display_name"]
+ metadata.domain = kwargs["domain"]
+ metadata.description = kwargs["description"]
+ metadata.parameters_json = kwargs["parameters_json"]
+ metadata.status = kwargs["status"]
+ metadata.author_staff_account_id = kwargs["author_staff_account_id"]
+ metadata.author_display_name = kwargs["author_display_name"]
+ metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, tzinfo=timezone.utc)
+ return metadata
+
+ def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
+ existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_metadata(existing, **kwargs)
+
+ @staticmethod
+ def build_metadata_id(tool_name: str, version_number: int) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_metadata::{normalized}::v{int(version_number)}"
+
+
+class _FakeToolArtifactRepository:
+ def __init__(self):
+ self.artifacts: list[ToolArtifact] = []
+ self.next_id = 1
+
+ def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]:
+ artifacts = sorted(
+ self.artifacts,
+ key=lambda artifact: (
+ artifact.version_number,
+ artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc),
+ ),
+ reverse=True,
+ )
+ if tool_name:
+ normalized = str(tool_name).strip().lower()
+ artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized]
+ if tool_version_id is not None:
+ artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id]
+ if artifact_stage:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage]
+ if artifact_kind:
+ artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind]
+ return artifacts
+
+ def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None:
+ for artifact in self.artifacts:
+ if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind:
+ return artifact
+ return None
+
+ def create(self, **kwargs) -> ToolArtifact:
+ version_number = kwargs["version_number"]
+ now = datetime(2026, 3, 31, 19, version_number, self.next_id, tzinfo=timezone.utc)
+ artifact = ToolArtifact(
+ id=self.next_id,
+ artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]),
+ created_at=now,
+ updated_at=now,
+ checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}",
+ **kwargs,
+ )
+ self.next_id += 1
+ self.artifacts.append(artifact)
+ return artifact
+
+ def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact:
+ artifact.artifact_status = kwargs["artifact_status"]
+ artifact.storage_kind = kwargs["storage_kind"]
+ artifact.summary = kwargs["summary"]
+ artifact.payload_json = kwargs["payload_json"]
+ artifact.checksum = kwargs["checksum"]
+ artifact.author_staff_account_id = kwargs["author_staff_account_id"]
+ artifact.author_display_name = kwargs["author_display_name"]
+ artifact.updated_at = datetime(2026, 3, 31, 19, artifact.version_number, tzinfo=timezone.utc)
+ return artifact
+
+ def upsert_version_artifact(self, **kwargs) -> ToolArtifact:
+ existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"])
+ if existing is None:
+ return self.create(**kwargs)
+ return self.update_artifact(existing, **kwargs)
+
+ @staticmethod
+ def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str:
+ normalized = str(tool_name or "").strip().lower()
+ return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}"
+
+
class AdminToolsWebTests(unittest.TestCase):
def _build_client_with_role(
self,
role: StaffRole,
settings: AdminSettings | None = None,
- ) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]:
+ ) -> tuple[
+ TestClient,
+ object,
+ _FakeToolDraftRepository,
+ _FakeToolVersionRepository,
+ _FakeToolMetadataRepository,
+ _FakeToolArtifactRepository,
+ ]:
app = create_app(
settings
or AdminSettings(
@@ -125,10 +275,14 @@ class AdminToolsWebTests(unittest.TestCase):
)
draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository()
+ metadata_repository = _FakeToolMetadataRepository()
+ artifact_repository = _FakeToolArtifactRepository()
service = ToolManagementService(
settings=app.state.admin_settings,
draft_repository=draft_repository,
version_repository=version_repository,
+ metadata_repository=metadata_repository,
+ artifact_repository=artifact_repository,
)
app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=11,
@@ -138,10 +292,10 @@ class AdminToolsWebTests(unittest.TestCase):
is_active=True,
)
app.dependency_overrides[get_tool_management_service] = lambda: service
- return TestClient(app), app, draft_repository, version_repository
+ return TestClient(app), app, draft_repository, version_repository, metadata_repository, artifact_repository
def test_tools_overview_returns_metrics_workflow_and_actions_for_colaborador(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"})
finally:
@@ -153,13 +307,15 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertEqual(payload["metrics"][0]["value"], "18")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]])
+ self.assertIn("persisted_metadata", [item["key"] for item in payload["metrics"]])
+ self.assertIn("persisted_artifacts", [item["key"] for item in payload["metrics"]])
self.assertIn("active", [item["code"] for item in payload["workflow"]])
self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]])
self.assertIn("artefatos", payload["next_steps"][0].lower())
def test_tools_contracts_return_shared_contract_snapshot(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"})
finally:
@@ -176,8 +332,33 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("string", [item["code"] for item in payload["parameter_types"]])
self.assertIn("published_tool", payload["publication_fields"])
+ def test_tools_draft_intake_blocks_tool_name_reserved_by_core_catalog(self):
+ client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
+ try:
+ response = client.post(
+ "/admin/tools/drafts/intake",
+ headers={"Authorization": "Bearer token"},
+ json={
+ "domain": "vendas",
+ "tool_name": "consultar_estoque",
+ "display_name": "Consultar estoque paralelo",
+ "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.",
+ "business_goal": "Validar que a API bloqueia nomes reservados do runtime core.",
+ "parameters": [],
+ },
+ )
+ finally:
+ app.dependency_overrides.clear()
+
+ self.assertEqual(response.status_code, 422)
+ self.assertIn("catalogo core do sistema", response.json()["detail"])
+ self.assertEqual(len(draft_repository.drafts), 0)
+ self.assertEqual(len(version_repository.versions), 0)
+ self.assertEqual(len(metadata_repository.metadata_entries), 0)
+ self.assertEqual(len(artifact_repository.artifacts), 0)
+
def test_tools_drafts_return_single_root_draft_with_current_version_after_reintake(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
first_response = client.post(
"/admin/tools/drafts/intake",
@@ -218,8 +399,8 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(payload["supported_statuses"], ["draft"])
- def test_tools_draft_intake_persists_admin_draft_with_version_metadata(self):
- client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
+ def test_tools_draft_intake_persists_admin_draft_with_version_metadata_and_artifacts(self):
+ client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.post(
"/admin/tools/drafts/intake",
@@ -256,9 +437,13 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertGreaterEqual(len(payload["warnings"]), 1)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 1)
+ self.assertEqual(len(metadata_repository.metadata_entries), 1)
+ self.assertEqual(len(artifact_repository.artifacts), 2)
+ artifact_kinds = {artifact.artifact_kind for artifact in artifact_repository.artifacts}
+ self.assertEqual(artifact_kinds, {ToolArtifactKind.GENERATION_REQUEST, ToolArtifactKind.VALIDATION_REPORT})
def test_tools_review_queue_requires_director_review_permission(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally:
@@ -271,7 +456,7 @@ class AdminToolsWebTests(unittest.TestCase):
)
def test_tools_review_queue_is_available_for_diretor(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally:
@@ -284,7 +469,7 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("validated", payload["supported_statuses"])
def test_tools_publications_require_director_publication_permission(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally:
@@ -297,7 +482,7 @@ class AdminToolsWebTests(unittest.TestCase):
)
def test_tools_publications_return_bootstrap_catalog_for_diretor(self):
- client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally:
@@ -313,6 +498,48 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(first["status"], "active")
self.assertEqual(first["implementation_module"], "app.services.tools.handlers")
+ def test_tools_publications_prefer_persisted_metadata_after_intake(self):
+ client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
+ try:
+ intake_response = client.post(
+ "/admin/tools/drafts/intake",
+ headers={"Authorization": "Bearer token"},
+ json={
+ "domain": "revisao",
+ "tool_name": "consultar_revisao_aberta",
+ "display_name": "Consultar revisao aberta",
+ "description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
+ "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
+ "parameters": [
+ {
+ "name": "placa",
+ "parameter_type": "string",
+ "description": "Placa usada na busca da revisao.",
+ "required": True,
+ }
+ ],
+ },
+ )
+ response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
+ finally:
+ app.dependency_overrides.clear()
+
+ self.assertEqual(intake_response.status_code, 200)
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(payload["source"], "admin_metadata_catalog")
+ self.assertEqual(len(payload["publications"]), 1)
+ publication = payload["publications"][0]
+ self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
+ self.assertEqual(publication["tool_name"], "consultar_revisao_aberta")
+ self.assertEqual(publication["status"], "draft")
+ self.assertEqual(publication["parameter_count"], 1)
+ self.assertEqual(publication["author_name"], "Equipe de Tools")
+ self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))
+ self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
+ self.assertEqual(publication["parameters"][0]["name"], "placa")
+ self.assertEqual(publication["parameters"][0]["parameter_type"], "string")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_admin_write_governance.py b/tests/test_admin_write_governance.py
index cd74d6a..0c9b78d 100644
--- a/tests/test_admin_write_governance.py
+++ b/tests/test_admin_write_governance.py
@@ -20,7 +20,15 @@ class AdminWriteGovernanceTests(unittest.TestCase):
self.assertEqual(payload["mode"], "admin_internal_tables_only")
self.assertEqual(
payload["allowed_direct_write_tables"],
- ["admin_audit_logs", "staff_accounts", "staff_sessions", "tool_drafts", "tool_versions"],
+ [
+ "admin_audit_logs",
+ "staff_accounts",
+ "staff_sessions",
+ "tool_drafts",
+ "tool_versions",
+ "tool_metadata",
+ "tool_artifacts",
+ ],
)
self.assertIn("sales_orders", payload["blocked_operational_dataset_keys"])
self.assertIn("orders", payload["blocked_product_source_tables"])
@@ -34,6 +42,8 @@ class AdminWriteGovernanceTests(unittest.TestCase):
ensure_direct_admin_write_allowed("admin_audit_logs")
ensure_direct_admin_write_allowed("tool_drafts")
ensure_direct_admin_write_allowed("tool_versions")
+ ensure_direct_admin_write_allowed("tool_metadata")
+ ensure_direct_admin_write_allowed("tool_artifacts")
def test_unknown_or_product_tables_raise_governance_violation(self):
with self.assertRaises(AdminWriteGovernanceViolation):
@@ -44,7 +54,12 @@ class AdminWriteGovernanceTests(unittest.TestCase):
def test_session_guard_accepts_only_internal_admin_tables(self):
enforce_admin_session_write_governance(
- new=(_FakeTabledObject("staff_accounts"), _FakeTabledObject("tool_versions")),
+ new=(
+ _FakeTabledObject("staff_accounts"),
+ _FakeTabledObject("tool_versions"),
+ _FakeTabledObject("tool_metadata"),
+ _FakeTabledObject("tool_artifacts"),
+ ),
dirty=(_FakeTabledObject("staff_sessions"),),
deleted=(
_FakeTabledObject("admin_audit_logs"),
diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py
index bf62cb2..59c48c5 100644
--- a/tests/test_conversation_adjustments.py
+++ b/tests/test_conversation_adjustments.py
@@ -16,7 +16,7 @@ from app.models.tool_model import ToolDefinition
from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.response_formatter import fallback_format_tool_result
-from app.services.tools.tool_registry import ToolRegistry
+from app.services.tools.tool_registry import GeneratedToolCoreBoundaryViolation, ToolRegistry
from app.services.tools.handlers import _parse_data_hora_revisao
@@ -3648,5 +3648,59 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase):
)
+ def test_register_generated_tool_accepts_isolated_runtime_package(self):
+ async def run(**kwargs):
+ return kwargs
+
+ run.__module__ = "generated_tools.emitir_resumo_locacao"
+
+ registry = ToolRegistry.__new__(ToolRegistry)
+ registry._tools = []
+
+ registry.register_generated_tool(
+ name="emitir_resumo_locacao",
+ description="",
+ parameters={},
+ handler=run,
+ )
+
+ self.assertEqual(len(registry.get_tools()), 1)
+ self.assertEqual(registry.get_tools()[0].name, "emitir_resumo_locacao")
+
+ def test_register_generated_tool_blocks_core_name_collision(self):
+ async def run(**kwargs):
+ return kwargs
+
+ run.__module__ = "generated_tools.consultar_estoque"
+
+ registry = ToolRegistry.__new__(ToolRegistry)
+ registry._tools = []
+
+ with self.assertRaisesRegex(GeneratedToolCoreBoundaryViolation, "catalogo core"):
+ registry.register_generated_tool(
+ name="consultar_estoque",
+ description="",
+ parameters={},
+ handler=run,
+ )
+
+ def test_register_generated_tool_blocks_core_module_target(self):
+ async def run(**kwargs):
+ return kwargs
+
+ run.__module__ = "app.services.tools.handlers"
+
+ registry = ToolRegistry.__new__(ToolRegistry)
+ registry._tools = []
+
+ with self.assertRaisesRegex(GeneratedToolCoreBoundaryViolation, "generated_tools"):
+ registry.register_generated_tool(
+ name="emitir_resumo_locacao",
+ description="",
+ parameters={},
+ handler=run,
+ )
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_runtime_bootstrap.py b/tests/test_runtime_bootstrap.py
index d96d38d..30630de 100644
--- a/tests/test_runtime_bootstrap.py
+++ b/tests/test_runtime_bootstrap.py
@@ -1,4 +1,6 @@
import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
from unittest.mock import AsyncMock, patch
from app import main as main_module
@@ -28,6 +30,37 @@ class SettingsParsingTests(unittest.TestCase):
class BootstrapRuntimeTests(unittest.TestCase):
+ @patch.object(bootstrap_module, "seed_tools")
+ @patch.object(bootstrap_module, "seed_mock_data")
+ @patch.object(bootstrap_module, "_ensure_mock_schema_evolution")
+ @patch.object(bootstrap_module.MockBase.metadata, "create_all")
+ @patch.object(bootstrap_module.Base.metadata, "create_all")
+ @patch.object(bootstrap_module, "_ensure_generated_tools_runtime_package")
+ def test_bootstrap_databases_ensures_generated_tools_package(
+ self,
+ ensure_generated_tools_runtime_package,
+ tools_create_all,
+ mock_create_all,
+ ensure_mock_schema_evolution,
+ seed_mock_data,
+ seed_tools,
+ ):
+ ensure_generated_tools_runtime_package.return_value = Path("generated_tools")
+
+ bootstrap_module.bootstrap_databases()
+
+ ensure_generated_tools_runtime_package.assert_called_once_with()
+ tools_create_all.assert_called_once()
+ mock_create_all.assert_called_once()
+
+ def test_ensure_generated_tools_runtime_package_creates_package_files(self):
+ with TemporaryDirectory() as temp_dir:
+ with patch.object(bootstrap_module, "_PROJECT_ROOT", Path(temp_dir)):
+ package_dir = bootstrap_module._ensure_generated_tools_runtime_package()
+ self.assertEqual(package_dir.name, "generated_tools")
+ self.assertTrue(package_dir.exists())
+ self.assertTrue((package_dir / "__init__.py").exists())
+
@patch.object(bootstrap_module, "seed_tools")
@patch.object(bootstrap_module, "seed_mock_data")
@patch.object(bootstrap_module, "_ensure_mock_schema_evolution")
diff --git a/tests/test_shared_contracts.py b/tests/test_shared_contracts.py
index 7a8364a..1ee7b01 100644
--- a/tests/test_shared_contracts.py
+++ b/tests/test_shared_contracts.py
@@ -4,6 +4,8 @@ from datetime import datetime, timezone
from shared.contracts import (
AdminPermission,
BOT_GOVERNED_SETTINGS,
+ GENERATED_TOOL_ENTRYPOINT,
+ GENERATED_TOOLS_PACKAGE,
MODEL_RUNTIME_PROFILES,
MODEL_RUNTIME_SEPARATION_RULES,
BotGovernanceArea,
@@ -35,6 +37,8 @@ from shared.contracts import (
ToolParameterContract,
ToolParameterType,
ToolPublicationEnvelope,
+ build_generated_tool_module_name,
+ build_generated_tool_module_path,
get_bot_governed_setting,
get_tool_lifecycle_stage,
get_functional_configuration,
@@ -121,6 +125,18 @@ class ToolPublicationContractTests(unittest.TestCase):
ToolParameterType.NUMBER,
)
+ def test_generated_tool_namespace_uses_isolated_package(self):
+ self.assertEqual(GENERATED_TOOLS_PACKAGE, "generated_tools")
+ self.assertEqual(GENERATED_TOOL_ENTRYPOINT, "run")
+ self.assertEqual(
+ build_generated_tool_module_name("consultar_financiamento"),
+ "generated_tools.consultar_financiamento",
+ )
+ self.assertEqual(
+ build_generated_tool_module_path("consultar_financiamento"),
+ "generated_tools/consultar_financiamento.py",
+ )
+
def test_lifecycle_catalog_exposes_expected_states_in_order(self):
self.assertEqual(
TOOL_LIFECYCLE_STATUS_SEQUENCE,