feat(admin): consolidar governanca segura de tools na fase 5

feat/self-evolving-tools-foundation
parent b3662906bc
commit 3dcf80eaaa

@ -15,7 +15,9 @@ from admin_app.repositories import (
AuditLogRepository, AuditLogRepository,
StaffAccountRepository, StaffAccountRepository,
StaffSessionRepository, StaffSessionRepository,
ToolArtifactRepository,
ToolDraftRepository, ToolDraftRepository,
ToolMetadataRepository,
ToolVersionRepository, ToolVersionRepository,
) )
from admin_app.services import ( from admin_app.services import (
@ -64,6 +66,14 @@ def get_tool_version_repository(db: Session = Depends(get_admin_db)) -> ToolVers
return ToolVersionRepository(db) 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( def get_audit_service(
repository: AuditLogRepository = Depends(get_audit_log_repository), repository: AuditLogRepository = Depends(get_audit_log_repository),
) -> AuditService: ) -> AuditService:
@ -100,11 +110,15 @@ def get_tool_management_service(
settings: AdminSettings = Depends(get_settings), settings: AdminSettings = Depends(get_settings),
draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository), draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository),
version_repository: ToolVersionRepository = Depends(get_tool_version_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: ) -> ToolManagementService:
return ToolManagementService( return ToolManagementService(
settings=settings, settings=settings,
draft_repository=draft_repository, draft_repository=draft_repository,
version_repository=version_repository, version_repository=version_repository,
metadata_repository=metadata_repository,
artifact_repository=artifact_repository,
) )

@ -772,6 +772,13 @@ class AdminToolReviewQueueResponse(BaseModel):
supported_statuses: list[ToolLifecycleStatus] supported_statuses: list[ToolLifecycleStatus]
class AdminToolPublicationParameterResponse(BaseModel):
name: str
parameter_type: ToolParameterType
description: str
required: bool
class AdminToolPublicationSummaryResponse(BaseModel): class AdminToolPublicationSummaryResponse(BaseModel):
publication_id: str publication_id: str
tool_name: str tool_name: str
@ -781,6 +788,8 @@ class AdminToolPublicationSummaryResponse(BaseModel):
version: int version: int
status: ToolLifecycleStatus status: ToolLifecycleStatus
parameter_count: int parameter_count: int
parameters: list[AdminToolPublicationParameterResponse] = Field(default_factory=list)
author_name: str | None = None
implementation_module: str implementation_module: str
implementation_callable: str implementation_callable: str
published_by: str | None = None published_by: str | None = None

@ -6,9 +6,9 @@ Cria tabelas do dominio administrativo de forma explicita, fora do startup do ap
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from admin_app.db.database import AdminBase, admin_engine 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: def _ensure_admin_schema_evolution() -> None:

@ -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.base import AdminTimestampedModel
from admin_app.db.models.staff_account import StaffAccount from admin_app.db.models.staff_account import StaffAccount
from admin_app.db.models.staff_session import StaffSession 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_draft import ToolDraft
from admin_app.db.models.tool_metadata import ToolMetadata
from admin_app.db.models.tool_version import ToolVersion from admin_app.db.models.tool_version import ToolVersion
__all__ = [ __all__ = [
@ -10,6 +12,8 @@ __all__ = [
"AuditLog", "AuditLog",
"StaffAccount", "StaffAccount",
"StaffSession", "StaffSession",
"ToolArtifact",
"ToolDraft", "ToolDraft",
"ToolMetadata",
"ToolVersion", "ToolVersion",
] ]

@ -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)

@ -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)

@ -14,6 +14,8 @@ ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = (
"staff_sessions", "staff_sessions",
"tool_drafts", "tool_drafts",
"tool_versions", "tool_versions",
"tool_metadata",
"tool_artifacts",
) )

@ -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.base_repository import BaseRepository
from admin_app.repositories.staff_account_repository import StaffAccountRepository from admin_app.repositories.staff_account_repository import StaffAccountRepository
from admin_app.repositories.staff_session_repository import StaffSessionRepository 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_draft_repository import ToolDraftRepository
from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository
__all__ = [ __all__ = [
@ -10,6 +12,8 @@ __all__ = [
"BaseRepository", "BaseRepository",
"StaffAccountRepository", "StaffAccountRepository",
"StaffSessionRepository", "StaffSessionRepository",
"ToolArtifactRepository",
"ToolDraftRepository", "ToolDraftRepository",
"ToolMetadataRepository",
"ToolVersionRepository", "ToolVersionRepository",
] ]

@ -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())

@ -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)}"

@ -5,14 +5,25 @@ from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from admin_app.core.settings import AdminSettings 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_draft_repository import ToolDraftRepository
from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository
from shared.contracts import ( from shared.contracts import (
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
ServiceName, ServiceName,
TOOL_LIFECYCLE_STAGES, TOOL_LIFECYCLE_STAGES,
ToolLifecycleStatus, ToolLifecycleStatus,
ToolParameterType, 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}$") _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}$") _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: class ToolManagementService:
@ -204,19 +216,26 @@ class ToolManagementService:
settings: AdminSettings, settings: AdminSettings,
draft_repository: ToolDraftRepository | None = None, draft_repository: ToolDraftRepository | None = None,
version_repository: ToolVersionRepository | None = None, version_repository: ToolVersionRepository | None = None,
metadata_repository: ToolMetadataRepository | None = None,
artifact_repository: ToolArtifactRepository | None = None,
): ):
self.settings = settings self.settings = settings
self.draft_repository = draft_repository self.draft_repository = draft_repository
self.version_repository = version_repository self.version_repository = version_repository
self.metadata_repository = metadata_repository
self.artifact_repository = artifact_repository
def build_overview_payload(self) -> dict: 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_draft_count = len(self.draft_repository.list_drafts()) if self.draft_repository else 0
persisted_version_count = 0 persisted_version_count = 0
if self.version_repository is not None: if self.version_repository is not None:
persisted_version_count = len(self.version_repository.list_versions()) persisted_version_count = len(self.version_repository.list_versions())
elif self.draft_repository is not None: elif self.draft_repository is not None:
persisted_version_count = sum(draft.version_count for draft in self.draft_repository.list_drafts()) 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 { return {
"mode": "admin_tool_draft_governance", "mode": "admin_tool_draft_governance",
"metrics": [ "metrics": [
@ -224,7 +243,7 @@ class ToolManagementService:
"key": "active_catalog", "key": "active_catalog",
"label": "Tools mapeadas", "label": "Tools mapeadas",
"value": str(len(catalog)), "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", "key": "lifecycle_stages",
@ -250,6 +269,18 @@ class ToolManagementService:
"value": str(persisted_version_count), "value": str(persisted_version_count),
"description": "Historico versionado das iteracoes de cada tool governada pelo admin.", "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(), "workflow": self.build_lifecycle_payload(),
"next_steps": [ "next_steps": [
@ -315,6 +346,7 @@ class ToolManagementService:
], ],
"naming_rules": [ "naming_rules": [
"tool_name deve usar snake_case minusculo, sem espacos, com 3 a 64 caracteres.", "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.", "display_name deve explicar claramente a acao operacional que o bot vai executar.",
"Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.", "Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.",
], ],
@ -370,6 +402,17 @@ class ToolManagementService:
} }
def build_publications_payload(self) -> dict: 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 { return {
"source": "bootstrap_catalog", "source": "bootstrap_catalog",
"target_service": ServiceName.PRODUCT, "target_service": ServiceName.PRODUCT,
@ -478,6 +521,33 @@ class ToolManagementService:
requires_director_approval=True, 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 { return {
"storage_status": "admin_database", "storage_status": "admin_database",
"message": "Draft administrativo persistido com sucesso em fluxo versionado.", "message": "Draft administrativo persistido com sucesso em fluxo versionado.",
@ -560,6 +630,150 @@ class ToolManagementService:
for entry in _BOOTSTRAP_TOOL_CATALOG 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: def _serialize_draft_summary(self, draft: ToolDraft) -> dict:
return { return {
"draft_id": draft.draft_id, "draft_id": draft.draft_id,
@ -654,6 +868,10 @@ class ToolManagementService:
tool_name = str(payload.get("tool_name") or "").strip().lower() tool_name = str(payload.get("tool_name") or "").strip().lower()
if not _TOOL_NAME_PATTERN.fullmatch(tool_name): if not _TOOL_NAME_PATTERN.fullmatch(tool_name):
raise ValueError("tool_name deve usar snake_case minusculo com 3 a 64 caracteres.") 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() display_name = str(payload.get("display_name") or "").strip()
if len(display_name) < 4: if len(display_name) < 4:

@ -224,7 +224,7 @@ function mountToolReviewBoard(board) {
setText("[data-tool-review-publication-count]", String(items.length)); setText("[data-tool-review-publication-count]", String(items.length));
setText("[data-tool-publication-source]", payload?.source || "Catalogo web"); setText("[data-tool-publication-source]", payload?.source || "Catalogo web");
publicationList.innerHTML = items.length > 0 publicationList.innerHTML = items.length > 0
? items.slice(0, 9).map((item) => `<div class="col-12 col-md-6 col-xxl-4"><article class="admin-tool-publication-card rounded-4 p-4 h-100"><div class="d-flex justify-content-between align-items-start gap-3 mb-3"><div><div class="small text-uppercase fw-semibold text-secondary mb-2">${escapeHtml(item.domain || "tool")}</div><h4 class="h5 fw-semibold mb-1">${escapeHtml(item.display_name || item.tool_name || "Tool")}</h4><div class="small text-secondary">${escapeHtml(item.tool_name || "")}</div></div><span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle">v${escapeHtml(String(item.version || 1))}</span></div><p class="text-secondary mb-3">${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}</p><div class="small text-secondary">${escapeHtml(item.implementation_module || "")}</div></article></div>`).join("") ? items.slice(0, 9).map((item) => `<div class="col-12 col-md-6 col-xxl-4"><article class="admin-tool-publication-card rounded-4 p-4 h-100"><div class="d-flex justify-content-between align-items-start gap-3 mb-3"><div><div class="small text-uppercase fw-semibold text-secondary mb-2">${escapeHtml(item.domain || "tool")}</div><h4 class="h5 fw-semibold mb-1">${escapeHtml(item.display_name || item.tool_name || "Tool")}</h4><div class="small text-secondary">${escapeHtml(item.tool_name || "")}</div></div><span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle">v${escapeHtml(String(item.version || 1))}</span></div><p class="text-secondary mb-3">${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}</p><div class="small text-secondary mb-1"><strong>Status:</strong> ${escapeHtml(item.status || "draft")}</div><div class="small text-secondary mb-1"><strong>Parametros:</strong> ${escapeHtml(String(item.parameter_count || 0))}</div><div class="small text-secondary mb-3"><strong>Autor:</strong> ${escapeHtml(item.author_name || item.published_by || "Nao informado")}</div><div class="small text-secondary">${escapeHtml(item.implementation_module || "")}</div></article></div>`).join("")
: `<div class="col-12"><div class="admin-tool-empty-state rounded-4 p-4"><h4 class="h5 fw-semibold mb-2">Catalogo ativo vazio</h4><p class="text-secondary mb-0">Nenhuma publicacao ativa retornada pela sessao web.</p></div></div>`; : `<div class="col-12"><div class="admin-tool-empty-state rounded-4 p-4"><h4 class="h5 fw-semibold mb-2">Catalogo ativo vazio</h4><p class="text-secondary mb-0">Nenhuma publicacao ativa retornada pela sessao web.</p></div></div>`;
} }

@ -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. Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
""" """
from pathlib import Path
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from app.core.settings import settings 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.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools 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: def _ensure_mock_schema_evolution() -> None:
@ -56,6 +74,13 @@ def bootstrap_databases(
print("Inicializando bancos...") print("Inicializando bancos...")
failures: list[str] = [] 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_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
should_seed_mock = ( should_seed_mock = (
settings.auto_seed_mock and settings.mock_seed_enabled settings.auto_seed_mock and settings.mock_seed_enabled

@ -2,6 +2,7 @@ import inspect
from typing import Callable, Dict, List from typing import Callable, Dict, List
from fastapi import HTTPException from fastapi import HTTPException
from shared.contracts import GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.tool_model import ToolDefinition 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. # Registry em memoria das tools disponiveis para o orquestrador.
class ToolRegistry: class ToolRegistry:
@ -66,6 +71,26 @@ class ToolRegistry:
def register_tool(self, name, description, parameters, handler): def register_tool(self, name, description, parameters, handler):
"""Registra uma tool em memoria para uso pelo orquestrador.""" """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( self._tools.append(
ToolDefinition( ToolDefinition(
name=name, 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]: def get_tools(self) -> List[ToolDefinition]:
"""Retorna a lista atual de tools registradas.""" """Retorna a lista atual de tools registradas."""
return self._tools return self._tools

@ -0,0 +1,3 @@
# Generated Tools
Diretorio isolado para modulos publicados pelo fluxo administrativo de tools.

@ -0,0 +1 @@
"""Isolated runtime package for admin-governed generated tools."""

@ -50,6 +50,8 @@ from shared.contracts.system_functional_configuration import (
get_functional_configuration, get_functional_configuration,
) )
from shared.contracts.tool_publication import ( from shared.contracts.tool_publication import (
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
PublishedToolContract, PublishedToolContract,
ServiceName, ServiceName,
TOOL_LIFECYCLE_STAGES, TOOL_LIFECYCLE_STAGES,
@ -59,12 +61,16 @@ from shared.contracts.tool_publication import (
ToolParameterContract, ToolParameterContract,
ToolParameterType, ToolParameterType,
ToolPublicationEnvelope, ToolPublicationEnvelope,
build_generated_tool_module_name,
build_generated_tool_module_path,
get_tool_lifecycle_stage, get_tool_lifecycle_stage,
) )
__all__ = [ __all__ = [
"AdminPermission", "AdminPermission",
"BOT_GOVERNED_SETTINGS", "BOT_GOVERNED_SETTINGS",
"GENERATED_TOOL_ENTRYPOINT",
"GENERATED_TOOLS_PACKAGE",
"MODEL_RUNTIME_PROFILES", "MODEL_RUNTIME_PROFILES",
"MODEL_RUNTIME_SEPARATION_RULES", "MODEL_RUNTIME_SEPARATION_RULES",
"PRODUCT_OPERATIONAL_DATASETS", "PRODUCT_OPERATIONAL_DATASETS",
@ -103,6 +109,8 @@ __all__ = [
"FunctionalConfigurationMutability", "FunctionalConfigurationMutability",
"FunctionalConfigurationPropagation", "FunctionalConfigurationPropagation",
"FunctionalConfigurationSource", "FunctionalConfigurationSource",
"build_generated_tool_module_name",
"build_generated_tool_module_path",
"get_bot_governed_setting", "get_bot_governed_setting",
"get_functional_configuration", "get_functional_configuration",
"get_model_runtime_contract", "get_model_runtime_contract",

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
import re
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -101,6 +102,28 @@ def get_tool_lifecycle_stage(
return _TOOL_LIFECYCLE_STAGE_BY_STATUS[normalized_status] 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): class ToolParameterType(str, Enum):
STRING = "string" STRING = "string"
INTEGER = "integer" INTEGER = "integer"

@ -9,9 +9,15 @@ from admin_app.api.dependencies import (
) )
from admin_app.app_factory import create_app from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal 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 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: class _FakeToolDraftRepository:
@ -76,7 +82,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted( versions = sorted(
self.versions, 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, reverse=True,
) )
if tool_name: if tool_name:
@ -113,12 +122,153 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}" 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): class AdminPanelToolsWebTests(unittest.TestCase):
def _build_client_with_role( def _build_client_with_role(
self, self,
role: StaffRole, role: StaffRole,
settings: AdminSettings | None = None, settings: AdminSettings | None = None,
) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]: ) -> tuple[
TestClient,
object,
_FakeToolDraftRepository,
_FakeToolVersionRepository,
_FakeToolMetadataRepository,
_FakeToolArtifactRepository,
]:
app = create_app( app = create_app(
settings settings
or AdminSettings( or AdminSettings(
@ -128,10 +278,14 @@ class AdminPanelToolsWebTests(unittest.TestCase):
) )
draft_repository = _FakeToolDraftRepository() draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository() version_repository = _FakeToolVersionRepository()
metadata_repository = _FakeToolMetadataRepository()
artifact_repository = _FakeToolArtifactRepository()
service = ToolManagementService( service = ToolManagementService(
settings=app.state.admin_settings, settings=app.state.admin_settings,
draft_repository=draft_repository, draft_repository=draft_repository,
version_repository=version_repository, version_repository=version_repository,
metadata_repository=metadata_repository,
artifact_repository=artifact_repository,
) )
app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal( app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=21, id=21,
@ -141,10 +295,10 @@ class AdminPanelToolsWebTests(unittest.TestCase):
is_active=True, is_active=True,
) )
app.dependency_overrides[get_tool_management_service] = lambda: service 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): 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: try:
response = client.get("/admin/panel/tools/overview") response = client.get("/admin/panel/tools/overview")
finally: finally:
@ -154,11 +308,37 @@ class AdminPanelToolsWebTests(unittest.TestCase):
payload = response.json() payload = response.json()
self.assertEqual(payload["mode"], "admin_tool_draft_governance") self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]]) 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/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/panel/tools/drafts/intake", [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): def test_panel_tool_intake_blocks_tool_name_reserved_by_core_catalog_for_colaborador(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:
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: try:
response = client.post( response = client.post(
"/admin/panel/tools/drafts/intake", "/admin/panel/tools/drafts/intake",
@ -199,9 +379,13 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(len(payload["draft_preview"]["parameters"]), 2) self.assertEqual(len(payload["draft_preview"]["parameters"]), 2)
self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 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): 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: try:
client.post( client.post(
"/admin/panel/tools/drafts/intake", "/admin/panel/tools/drafts/intake",
@ -238,9 +422,11 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["version_count"], 2) self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 2) 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): 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: try:
response = client.get("/admin/panel/tools/review-queue") response = client.get("/admin/panel/tools/review-queue")
finally: finally:
@ -253,7 +439,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
) )
def test_panel_tools_review_queue_is_available_for_diretor_session(self): 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: try:
response = client.get("/admin/panel/tools/review-queue") response = client.get("/admin/panel/tools/review-queue")
finally: finally:
@ -263,7 +449,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state") self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state")
def test_panel_tools_publications_require_director_publication_permission(self): 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: try:
response = client.get("/admin/panel/tools/publications") response = client.get("/admin/panel/tools/publications")
finally: finally:
@ -276,7 +462,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
) )
def test_panel_tools_publications_return_catalog_for_diretor_session(self): 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: try:
response = client.get("/admin/panel/tools/publications") response = client.get("/admin/panel/tools/publications")
finally: finally:
@ -284,10 +470,51 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertEqual(payload["target_service"], "product") self.assertEqual(payload["target_service"], "product")
self.assertGreaterEqual(len(payload["publications"]), 10) self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -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()

@ -2,9 +2,17 @@ import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from admin_app.core import AdminSettings 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 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: class _FakeToolDraftRepository:
@ -116,7 +124,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted( versions = sorted(
self.versions, 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, reverse=True,
) )
if tool_name: if tool_name:
@ -179,17 +190,155 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}" 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): class AdminToolManagementServiceTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.draft_repository = _FakeToolDraftRepository() self.draft_repository = _FakeToolDraftRepository()
self.version_repository = _FakeToolVersionRepository() self.version_repository = _FakeToolVersionRepository()
self.metadata_repository = _FakeToolMetadataRepository()
self.artifact_repository = _FakeToolArtifactRepository()
self.service = ToolManagementService( self.service = ToolManagementService(
settings=AdminSettings(admin_api_prefix="/admin"), settings=AdminSettings(admin_api_prefix="/admin"),
draft_repository=self.draft_repository, draft_repository=self.draft_repository,
version_repository=self.version_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( payload = self.service.create_draft_submission(
{ {
"domain": "vendas", "domain": "vendas",
@ -225,6 +374,56 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna") self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna")
self.assertEqual(len(self.draft_repository.drafts), 1) self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 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): def test_create_draft_submission_reuses_root_draft_and_increments_version(self):
self.service.create_draft_submission( self.service.create_draft_submission(
@ -265,6 +464,8 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["draft_preview"]["version_count"], 2) self.assertEqual(payload["draft_preview"]["version_count"], 2)
self.assertEqual(len(self.draft_repository.drafts), 1) self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 2) 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].current_version_number, 2)
self.assertEqual(self.draft_repository.drafts[0].version_count, 2) self.assertEqual(self.draft_repository.drafts[0].version_count, 2)
self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao") 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["drafts"][0]["owner_name"], "Diretoria Comercial")
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -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()

@ -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.api.dependencies import get_current_staff_principal, get_tool_management_service
from admin_app.app_factory import create_app from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal 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 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: class _FakeToolDraftRepository:
@ -73,7 +79,10 @@ class _FakeToolVersionRepository:
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted( versions = sorted(
self.versions, 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, reverse=True,
) )
if tool_name: if tool_name:
@ -110,12 +119,153 @@ class _FakeToolVersionRepository:
return f"tool_version::{normalized}::v{int(version_number)}" 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): class AdminToolsWebTests(unittest.TestCase):
def _build_client_with_role( def _build_client_with_role(
self, self,
role: StaffRole, role: StaffRole,
settings: AdminSettings | None = None, settings: AdminSettings | None = None,
) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]: ) -> tuple[
TestClient,
object,
_FakeToolDraftRepository,
_FakeToolVersionRepository,
_FakeToolMetadataRepository,
_FakeToolArtifactRepository,
]:
app = create_app( app = create_app(
settings settings
or AdminSettings( or AdminSettings(
@ -125,10 +275,14 @@ class AdminToolsWebTests(unittest.TestCase):
) )
draft_repository = _FakeToolDraftRepository() draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository() version_repository = _FakeToolVersionRepository()
metadata_repository = _FakeToolMetadataRepository()
artifact_repository = _FakeToolArtifactRepository()
service = ToolManagementService( service = ToolManagementService(
settings=app.state.admin_settings, settings=app.state.admin_settings,
draft_repository=draft_repository, draft_repository=draft_repository,
version_repository=version_repository, version_repository=version_repository,
metadata_repository=metadata_repository,
artifact_repository=artifact_repository,
) )
app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=11, id=11,
@ -138,10 +292,10 @@ class AdminToolsWebTests(unittest.TestCase):
is_active=True, is_active=True,
) )
app.dependency_overrides[get_tool_management_service] = lambda: service 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): 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: try:
response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"})
finally: finally:
@ -153,13 +307,15 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(payload["mode"], "admin_tool_draft_governance") self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertEqual(payload["metrics"][0]["value"], "18") self.assertEqual(payload["metrics"][0]["value"], "18")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]]) 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("active", [item["code"] for item in payload["workflow"]])
self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]]) 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("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]])
self.assertIn("artefatos", payload["next_steps"][0].lower()) self.assertIn("artefatos", payload["next_steps"][0].lower())
def test_tools_contracts_return_shared_contract_snapshot(self): 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: try:
response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"})
finally: finally:
@ -176,8 +332,33 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("string", [item["code"] for item in payload["parameter_types"]]) self.assertIn("string", [item["code"] for item in payload["parameter_types"]])
self.assertIn("published_tool", payload["publication_fields"]) 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): 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: try:
first_response = client.post( first_response = client.post(
"/admin/tools/drafts/intake", "/admin/tools/drafts/intake",
@ -218,8 +399,8 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["version_count"], 2) self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(payload["supported_statuses"], ["draft"]) self.assertEqual(payload["supported_statuses"], ["draft"])
def test_tools_draft_intake_persists_admin_draft_with_version_metadata(self): def test_tools_draft_intake_persists_admin_draft_with_version_metadata_and_artifacts(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: try:
response = client.post( response = client.post(
"/admin/tools/drafts/intake", "/admin/tools/drafts/intake",
@ -256,9 +437,13 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertGreaterEqual(len(payload["warnings"]), 1) self.assertGreaterEqual(len(payload["warnings"]), 1)
self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 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): 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: try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally: finally:
@ -271,7 +456,7 @@ class AdminToolsWebTests(unittest.TestCase):
) )
def test_tools_review_queue_is_available_for_diretor(self): 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: try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally: finally:
@ -284,7 +469,7 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("validated", payload["supported_statuses"]) self.assertIn("validated", payload["supported_statuses"])
def test_tools_publications_require_director_publication_permission(self): 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: try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally: finally:
@ -297,7 +482,7 @@ class AdminToolsWebTests(unittest.TestCase):
) )
def test_tools_publications_return_bootstrap_catalog_for_diretor(self): 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: try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally: finally:
@ -313,6 +498,48 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(first["status"], "active") self.assertEqual(first["status"], "active")
self.assertEqual(first["implementation_module"], "app.services.tools.handlers") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -20,7 +20,15 @@ class AdminWriteGovernanceTests(unittest.TestCase):
self.assertEqual(payload["mode"], "admin_internal_tables_only") self.assertEqual(payload["mode"], "admin_internal_tables_only")
self.assertEqual( self.assertEqual(
payload["allowed_direct_write_tables"], 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("sales_orders", payload["blocked_operational_dataset_keys"])
self.assertIn("orders", payload["blocked_product_source_tables"]) 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("admin_audit_logs")
ensure_direct_admin_write_allowed("tool_drafts") ensure_direct_admin_write_allowed("tool_drafts")
ensure_direct_admin_write_allowed("tool_versions") 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): def test_unknown_or_product_tables_raise_governance_violation(self):
with self.assertRaises(AdminWriteGovernanceViolation): with self.assertRaises(AdminWriteGovernanceViolation):
@ -44,7 +54,12 @@ class AdminWriteGovernanceTests(unittest.TestCase):
def test_session_guard_accepts_only_internal_admin_tables(self): def test_session_guard_accepts_only_internal_admin_tables(self):
enforce_admin_session_write_governance( 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"),), dirty=(_FakeTabledObject("staff_sessions"),),
deleted=( deleted=(
_FakeTabledObject("admin_audit_logs"), _FakeTabledObject("admin_audit_logs"),

@ -16,7 +16,7 @@ from app.models.tool_model import ToolDefinition
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.response_formatter import fallback_format_tool_result 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 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -1,4 +1,6 @@
import unittest import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from app import main as main_module from app import main as main_module
@ -28,6 +30,37 @@ class SettingsParsingTests(unittest.TestCase):
class BootstrapRuntimeTests(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_tools")
@patch.object(bootstrap_module, "seed_mock_data") @patch.object(bootstrap_module, "seed_mock_data")
@patch.object(bootstrap_module, "_ensure_mock_schema_evolution") @patch.object(bootstrap_module, "_ensure_mock_schema_evolution")

@ -4,6 +4,8 @@ from datetime import datetime, timezone
from shared.contracts import ( from shared.contracts import (
AdminPermission, AdminPermission,
BOT_GOVERNED_SETTINGS, BOT_GOVERNED_SETTINGS,
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
MODEL_RUNTIME_PROFILES, MODEL_RUNTIME_PROFILES,
MODEL_RUNTIME_SEPARATION_RULES, MODEL_RUNTIME_SEPARATION_RULES,
BotGovernanceArea, BotGovernanceArea,
@ -35,6 +37,8 @@ from shared.contracts import (
ToolParameterContract, ToolParameterContract,
ToolParameterType, ToolParameterType,
ToolPublicationEnvelope, ToolPublicationEnvelope,
build_generated_tool_module_name,
build_generated_tool_module_path,
get_bot_governed_setting, get_bot_governed_setting,
get_tool_lifecycle_stage, get_tool_lifecycle_stage,
get_functional_configuration, get_functional_configuration,
@ -121,6 +125,18 @@ class ToolPublicationContractTests(unittest.TestCase):
ToolParameterType.NUMBER, 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): def test_lifecycle_catalog_exposes_expected_states_in_order(self):
self.assertEqual( self.assertEqual(
TOOL_LIFECYCLE_STATUS_SEQUENCE, TOOL_LIFECYCLE_STATUS_SEQUENCE,

Loading…
Cancel
Save