From 640e42249819e29774bbd2f4b02f14b2b754eb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 1 Apr 2026 14:13:34 -0300 Subject: [PATCH] :construction: construct(admin): automatizar pipeline governada de tools na fase 6 --- admin_app/api/routes/panel_tools.py | 45 + admin_app/api/routes/tools.py | 45 + admin_app/api/schemas.py | 31 + admin_app/services/tool_management_service.py | 1000 ++++++++++++++++- admin_app/view/router.py | 24 +- tests/test_admin_panel_tools_web.py | 53 +- tests/test_admin_tool_management_service.py | 306 ++++- tests/test_admin_tools_web.py | 65 +- 8 files changed, 1540 insertions(+), 29 deletions(-) diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index 84205e1..33a6f96 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGenerationPipelineResponse, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, @@ -122,6 +123,34 @@ def panel_tool_draft_intake( ) +@router.post( + "/pipeline/{version_id}/run", + response_model=AdminToolGenerationPipelineResponse, +) +def panel_tool_pipeline_run( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) + ), +): + try: + payload = service.run_generation_pipeline( + version_id, + runner_staff_account_id=current_staff.id, + runner_name=current_staff.display_name, + runner_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_pipeline_response(payload) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -246,6 +275,22 @@ def panel_tool_publications_publish( +def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: + return AdminToolGenerationPipelineResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + current_step=payload["current_step"], + steps=payload["steps"], + queue_entry=payload["queue_entry"], + automated_validations=payload.get("automated_validations", []), + next_steps=payload["next_steps"], + ) + + def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: return AdminToolGovernanceTransitionResponse( service="orquestrador-admin", diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index f543f12..f57e69c 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGenerationPipelineResponse, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, @@ -122,6 +123,34 @@ def tool_draft_intake( ) +@router.post( + "/pipeline/{version_id}/run", + response_model=AdminToolGenerationPipelineResponse, +) +def tool_pipeline_run( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) + ), +): + try: + payload = service.run_generation_pipeline( + version_id, + runner_staff_account_id=current_staff.id, + runner_name=current_staff.display_name, + runner_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_pipeline_response(payload) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -246,6 +275,22 @@ def tool_publications_publish( +def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: + return AdminToolGenerationPipelineResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + current_step=payload["current_step"], + steps=payload["steps"], + queue_entry=payload["queue_entry"], + automated_validations=payload.get("automated_validations", []), + next_steps=payload["next_steps"], + ) + + def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: return AdminToolGovernanceTransitionResponse( service="orquestrador-admin", diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index fd27baa..3b262bf 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -763,6 +763,8 @@ class AdminToolReviewQueueEntryResponse(BaseModel): gate: str summary: str owner_name: str | None = None + automated_validation_status: str | None = None + automated_validation_summary: str | None = None queued_at: datetime | None = None @@ -817,6 +819,35 @@ class AdminToolGovernanceTransitionResponse(BaseModel): next_steps: list[str] +class AdminToolAutomatedValidationResponse(BaseModel): + key: str + label: str + status: str + summary: str + blocking_issues: list[str] = Field(default_factory=list) + + +class AdminToolPipelineStepResponse(BaseModel): + key: str + label: str + state: str + description: str + + +class AdminToolGenerationPipelineResponse(BaseModel): + service: str + message: str + version_id: str + tool_name: str + version_number: int = Field(ge=1) + status: ToolLifecycleStatus + current_step: str + steps: list[AdminToolPipelineStepResponse] + queue_entry: AdminToolReviewQueueEntryResponse + automated_validations: list[AdminToolAutomatedValidationResponse] = Field(default_factory=list) + next_steps: list[str] + + class AdminToolDraftIntakeParameterRequest(BaseModel): name: str = Field(min_length=1, max_length=64) parameter_type: ToolParameterType diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index da181b9..aa658c8 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -1,8 +1,14 @@ -from __future__ import annotations +from __future__ import annotations +import asyncio +import inspect +import json import re +import sys +import types from datetime import UTC, datetime +from pydantic import ValidationError from sqlalchemy.orm import Session from admin_app.catalogs import BOOTSTRAP_TOOL_CATALOG, INTAKE_DOMAIN_OPTIONS @@ -17,15 +23,19 @@ from admin_app.repositories.tool_artifact_repository import ToolArtifactReposito 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 app.services.tools.tool_registry import GeneratedToolCoreBoundaryViolation, ToolRegistry from shared.contracts import ( AdminPermission, GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE, + PublishedToolContract, ServiceName, StaffRole, TOOL_LIFECYCLE_STAGES, ToolLifecycleStatus, + ToolParameterContract, ToolParameterType, + ToolPublicationEnvelope, build_generated_tool_module_name, build_generated_tool_module_path, normalize_staff_role, @@ -46,6 +56,40 @@ _TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") _PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") _RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in BOOTSTRAP_TOOL_CATALOG) _PUBLISHED_TOOL_STATUSES = (ToolLifecycleStatus.ACTIVE,) +_AUTOMATED_CONTRACT_VALIDATION_RULES = ( + "publication_envelope_contract", + "published_tool_contract", + "generated_namespace_contract", + "generated_entrypoint_contract", + "metadata_identifier_contract", + "parameter_contract_rules", +) +_AUTOMATED_SIGNATURE_SCHEMA_VALIDATION_RULES = ( + "generated_entrypoint_signature", + "reserved_runtime_parameter_names", + "parameter_schema_projection", + "required_parameter_alignment", +) +_AUTOMATED_IMPORT_LOADING_VALIDATION_RULES = ( + "generated_module_render", + "generated_module_import", + "generated_entrypoint_load", + "generated_runtime_registry_boundary", +) +_AUTOMATED_SMOKE_TEST_RULES = ( + "generated_entrypoint_execution", + "generated_runtime_dispatch_execution", + "generated_result_json_serialization", +) +_PARAMETER_SCHEMA_TYPE_MAPPING = { + ToolParameterType.STRING: "string", + ToolParameterType.INTEGER: "integer", + ToolParameterType.NUMBER: "number", + ToolParameterType.BOOLEAN: "boolean", + ToolParameterType.OBJECT: "object", + ToolParameterType.ARRAY: "array", +} +_SIGNATURE_RESERVED_PARAMETER_NAMES = frozenset({"user_id"}) _REVIEW_QUEUE_STATUSES = ( ToolLifecycleStatus.DRAFT, ToolLifecycleStatus.GENERATED, @@ -184,8 +228,8 @@ class ToolManagementService: ], "workflow": self.build_lifecycle_payload(), "next_steps": [ - "Persistir artefatos de geracao e validacao por versao sem perder o historico administrativo.", - "Abrir filas de revisao, aprovacao e ativacao com auditoria ponta a ponta.", + "Executar a pipeline de geracao entre o cadastro manual e a validacao da versao.", + "Usar a fila de revisao para acompanhar geracao, validacao, aprovacao e ativacao de cada tool.", "Conectar publicacoes versionadas ao runtime de produto com rollback controlado.", ], } @@ -301,9 +345,9 @@ class ToolManagementService: def build_review_queue_payload(self) -> dict: queued_versions = self._list_latest_versions(statuses=_REVIEW_QUEUE_STATUSES) message = ( - "Nenhuma versao aguardando revisao, aprovacao ou publicacao de diretor." + "Nenhuma versao aguardando execucao do pipeline, validacao, aprovacao ou publicacao." if not queued_versions - else f"{len(queued_versions)} versao(oes) aguardando atuacao de diretor antes da ativacao." + else f"{len(queued_versions)} versao(oes) em alguma etapa do pipeline antes da ativacao." ) return { "queue_mode": "governed_admin_queue", @@ -337,6 +381,135 @@ class ToolManagementService: "publications": list(publications_by_tool_name.values()), } + def run_generation_pipeline( + self, + version_id: str, + *, + runner_staff_account_id: int, + runner_name: str, + runner_role: StaffRole | str, + ) -> dict: + normalized_role = normalize_staff_role(runner_role) + if not role_has_permission(normalized_role, AdminPermission.MANAGE_TOOL_DRAFTS): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{AdminPermission.MANAGE_TOOL_DRAFTS.value}'." + ) + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Pipeline de geracao ainda nao esta completamente conectado ao armazenamento administrativo." + ) + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + raise LookupError("Versao administrativa nao encontrada.") + + latest_versions_for_tool = self.version_repository.list_versions(tool_name=version.tool_name) + if latest_versions_for_tool and latest_versions_for_tool[0].version_id != version.version_id: + raise ValueError( + "Somente a versao mais recente da tool pode seguir pelo pipeline de geracao." + ) + if version.status not in {ToolLifecycleStatus.DRAFT, ToolLifecycleStatus.FAILED}: + raise ValueError( + f"A pipeline de geracao exige status em (draft, failed), mas a versao esta em '{version.status.value}'." + ) + + draft = self.draft_repository.get_by_tool_name(version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para a pipeline de geracao.") + metadata = self.metadata_repository.get_by_tool_version_id(version.id) + if metadata is None: + raise RuntimeError("Metadados persistidos da versao nao encontrados para a pipeline de geracao.") + + repository_session = self._resolve_repository_session() + atomic_write_options = {"commit": False} if repository_session is not None else {} + artifact_commit = False if repository_session is not None else None + automated_validation_result: dict | None = None + + try: + self._persist_generation_pipeline_artifact( + draft=draft, + version=version, + actor_staff_account_id=runner_staff_account_id, + actor_name=runner_name, + actor_role=normalized_role, + commit=artifact_commit, + ) + automated_validation_result = self._execute_automated_contract_validation( + draft=draft, + version=version, + metadata=metadata, + actor_staff_account_id=runner_staff_account_id, + actor_name=runner_name, + commit=artifact_commit, + ) + pipeline_status = ( + ToolLifecycleStatus.GENERATED + if automated_validation_result["passed"] + else ToolLifecycleStatus.FAILED + ) + self.version_repository.update_status( + version, + status=pipeline_status, + **atomic_write_options, + ) + self.metadata_repository.update_status( + metadata, + status=pipeline_status, + **atomic_write_options, + ) + self.draft_repository.update_status( + draft, + status=pipeline_status, + **atomic_write_options, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + pipeline_snapshot = self._build_pipeline_snapshot(version.status) + if automated_validation_result and automated_validation_result["passed"]: + message = ( + "Pipeline de geracao executado com sucesso e as validacoes automaticas de contrato, assinatura e schema passaram. " + "A versao agora segue para a proxima etapa de validacao governada." + ) + next_steps = [ + "Usar a fila de revisao para concluir a validacao governada antes da aprovacao da diretoria.", + "Apenas versoes validadas podem seguir para aprovacao e ativacao no catalogo governado.", + ] + else: + message = ( + "Pipeline de geracao executado, mas alguma validacao automatica de contrato, assinatura ou schema falhou. " + "A versao foi marcada como failed para ajuste e nova tentativa." + ) + next_steps = [ + "Ajustar metadados, assinatura esperada e schema dos parametros antes de rodar o pipeline novamente.", + "Enquanto alguma validacao automatica falhar, a versao nao pode seguir para aprovacao e ativacao.", + ] + return { + "message": message, + "version_id": version.version_id, + "tool_name": version.tool_name, + "version_number": version.version_number, + "status": version.status, + "current_step": pipeline_snapshot["current_step"], + "steps": pipeline_snapshot["steps"], + "queue_entry": self._serialize_review_queue_entry(version), + "automated_validations": list((automated_validation_result or {}).get("automated_checks") or []), + "next_steps": next_steps, + } + def review_version( self, version_id: str, @@ -348,10 +521,7 @@ class ToolManagementService: return self._transition_version_status( version_id, target_status=ToolLifecycleStatus.VALIDATED, - allowed_current_statuses=( - ToolLifecycleStatus.DRAFT, - ToolLifecycleStatus.GENERATED, - ), + allowed_current_statuses=(ToolLifecycleStatus.GENERATED,), actor_staff_account_id=reviewer_staff_account_id, actor_name=reviewer_name, actor_role=reviewer_role, @@ -697,8 +867,8 @@ class ToolManagementService: "draft_preview": self._serialize_draft_preview(draft, version), "warnings": warnings, "next_steps": [ - f"Encaminhar a versao v{draft.current_version_number} para revisao e aprovacao de um diretor.", - "Conectar a versao persistida ao pipeline de geracao e validacao automatica da tool.", + f"Executar a pipeline de geracao para a versao v{draft.current_version_number} antes da validacao.", + "Depois da geracao, validar a versao e encaminhar para aprovacao de diretor.", "Persistir artefatos e publicacoes associados a cada versao governada.", ], } @@ -996,6 +1166,771 @@ class ToolManagementService: ], } + def _execute_automated_contract_validation( + self, + *, + draft: ToolDraft, + version: ToolVersion, + metadata: ToolMetadata, + actor_staff_account_id: int, + actor_name: str, + commit: bool | None = None, + ) -> dict: + previous_validation_payload = {} + if self.artifact_repository is not None: + existing_validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if existing_validation_artifact is not None: + previous_validation_payload = dict(existing_validation_artifact.payload_json or {}) + + contract_validation_issues = self._collect_tool_contract_validation_issues( + version=version, + metadata=metadata, + ) + signature_schema_blueprint = self._build_generated_signature_and_parameter_schema( + metadata=metadata, + ) + signature_schema_issues = list(signature_schema_blueprint["issues"]) + import_loading_result = self._validate_generated_tool_import_loading( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + smoke_test_result = self._run_generated_tool_minimal_smoke_tests( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + import_loading_result=import_loading_result, + ) + automated_checks = [ + { + "key": "tool_contract", + "label": "Contrato da tool", + "status": "passed" if not contract_validation_issues else "failed", + "summary": ( + "O contrato compartilhado da tool foi validado automaticamente com sucesso." + if not contract_validation_issues + else "A validacao automatica do contrato encontrou inconsistencias bloqueantes." + ), + "blocking_issues": list(contract_validation_issues), + }, + { + "key": "tool_signature_schema", + "label": "Assinatura e schema de parametros", + "status": "passed" if not signature_schema_issues else "failed", + "summary": ( + "A assinatura esperada do entrypoint run e o schema dos parametros foram validados automaticamente." + if not signature_schema_issues + else "A validacao automatica da assinatura esperada e do schema dos parametros encontrou inconsistencias bloqueantes." + ), + "blocking_issues": list(signature_schema_issues), + }, + { + "key": "tool_import_loading", + "label": "Importacao e carregamento da tool", + "status": "passed" if import_loading_result["passed"] else "failed", + "summary": ( + "O modulo gerado pode ser importado e o runtime conseguiu carregar o entrypoint run." + if import_loading_result["passed"] + else "A validacao automatica de importacao e carregamento da tool encontrou inconsistencias bloqueantes." + ), + "blocking_issues": list(import_loading_result["issues"]), + }, + { + "key": "tool_smoke_tests", + "label": "Testes minimos automaticos", + "status": "passed" if smoke_test_result["passed"] else "failed", + "summary": ( + "Os testes minimos automaticos executaram o entrypoint gerado e o runtime sandboxado com sucesso." + if smoke_test_result["passed"] + else "Os testes minimos automaticos da tool encontraram inconsistencias bloqueantes." + ), + "blocking_issues": list(smoke_test_result["issues"]), + }, + ] + all_validation_issues = [ + *contract_validation_issues, + *signature_schema_issues, + *import_loading_result["issues"], + *smoke_test_result["issues"], + ] + passed = all(check["status"] == "passed" for check in automated_checks) + validation_payload = self._build_automated_validation_artifact_payload( + draft=draft, + version=version, + metadata=metadata, + intake_validation=previous_validation_payload, + automated_checks=automated_checks, + validation_issues=all_validation_issues, + signature_schema_blueprint=signature_schema_blueprint, + import_loading_result=import_loading_result, + smoke_test_result=smoke_test_result, + ) + + if self.artifact_repository is not None: + artifact_write_options = {"commit": commit} if commit is not None else {} + 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 if passed else ToolArtifactStatus.FAILED + ), + summary=( + "Validacoes automaticas de contrato, assinatura, importacao e testes minimos concluidas para a versao governada." + if passed + else "Validacoes automaticas de contrato, assinatura, importacao e testes minimos falharam para a versao governada." + ), + payload_json=validation_payload, + author_staff_account_id=actor_staff_account_id, + author_display_name=actor_name, + **artifact_write_options, + ) + + return { + "passed": passed, + "automated_checks": automated_checks, + "validation_payload": validation_payload, + } + + def _build_automated_validation_artifact_payload( + self, + *, + draft: ToolDraft, + version: ToolVersion, + metadata: ToolMetadata, + intake_validation: dict, + automated_checks: list[dict], + validation_issues: list[str], + signature_schema_blueprint: dict, + import_loading_result: dict, + smoke_test_result: dict, + ) -> dict: + publication_envelope = None + if not validation_issues: + publication_envelope = self._build_generated_publication_envelope( + version=version, + metadata=metadata, + ).model_dump(mode="json") + + return { + "source": "admin_generation_pipeline", + "tool_name": draft.tool_name, + "version_number": version.version_number, + "draft_id": draft.draft_id, + "version_id": version.version_id, + "validation_status": "passed" if not validation_issues else "failed", + "validation_scope": "tool_contract", + "warnings": list((intake_validation or {}).get("warnings") or []), + "blocking_issues": list(validation_issues), + "parameter_count": len(version.parameters_json or []), + "required_parameter_count": version.required_parameter_count, + "checked_rules": list( + _AUTOMATED_CONTRACT_VALIDATION_RULES + + _AUTOMATED_SIGNATURE_SCHEMA_VALIDATION_RULES + + _AUTOMATED_IMPORT_LOADING_VALIDATION_RULES + + _AUTOMATED_SMOKE_TEST_RULES + ), + "intake_validation": dict(intake_validation or {}), + "automated_checks": list(automated_checks), + "signature_schema": dict(signature_schema_blueprint), + "import_loading": dict(import_loading_result), + "smoke_tests": dict(smoke_test_result), + "publication_envelope": publication_envelope, + } + + def _build_generated_signature_and_parameter_schema( + self, + *, + metadata: ToolMetadata, + ) -> dict: + serialized_parameters = self._serialize_parameters_for_response(metadata.parameters_json) + signature_parameters: list[inspect.Parameter] = [] + required_parameters: list[str] = [] + optional_parameters: list[str] = [] + parameter_schema_properties: dict[str, dict] = {} + issues: list[str] = [] + + for parameter in serialized_parameters: + parameter_name = parameter["name"] + parameter_type = parameter["parameter_type"] + parameter_schema_properties[parameter_name] = { + "type": _PARAMETER_SCHEMA_TYPE_MAPPING[parameter_type], + "description": parameter["description"], + } + if parameter_type == ToolParameterType.OBJECT: + parameter_schema_properties[parameter_name]["additionalProperties"] = True + if parameter_name in _SIGNATURE_RESERVED_PARAMETER_NAMES: + issues.append( + f"parameter '{parameter_name}' is reserved for runtime-injected context and cannot be declared in the generated tool signature." + ) + if parameter["required"]: + required_parameters.append(parameter_name) + else: + optional_parameters.append(parameter_name) + try: + signature_parameters.append( + inspect.Parameter( + parameter_name, + inspect.Parameter.KEYWORD_ONLY, + default=( + inspect.Parameter.empty + if parameter["required"] + else None + ), + ) + ) + except ValueError as exc: + issues.append( + f"parameter '{parameter_name}' cannot be represented in the generated entrypoint signature: {exc}" + ) + + try: + generated_signature = inspect.Signature(parameters=signature_parameters) + signature_text = f"{GENERATED_TOOL_ENTRYPOINT}{generated_signature}" + except ValueError as exc: + signature_text = None + issues.append(f"generated entrypoint signature is invalid: {exc}") + + return { + "callable_name": GENERATED_TOOL_ENTRYPOINT, + "signature": signature_text, + "parameter_mode": "keyword_only", + "runtime_injected_arguments": ["user_id"], + "required_parameters": required_parameters, + "optional_parameters": optional_parameters, + "parameter_schema": { + "type": "object", + "properties": parameter_schema_properties, + "required": required_parameters, + "additionalProperties": False, + }, + "issues": issues, + } + + def _load_generated_tool_handler_in_memory( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + signature_schema_blueprint: dict, + ) -> dict: + module_name = build_generated_tool_module_name(version.tool_name) + module_path = build_generated_tool_module_path(version.tool_name) + package_name = GENERATED_TOOLS_PACKAGE + rendered_source = self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + issues: list[str] = [] + handler = None + loaded_signature = None + sandbox_package_root = f"in_memory::{package_name}" + previous_package_module = sys.modules.pop(package_name, None) + previous_tool_module = sys.modules.pop(module_name, None) + + try: + package_module = types.ModuleType(package_name) + package_module.__file__ = f"{package_name}/__init__.py" + package_module.__package__ = package_name + package_module.__path__ = [sandbox_package_root] + sys.modules[package_name] = package_module + + module = types.ModuleType(module_name) + module.__file__ = module_path + module.__package__ = package_name + sys.modules[module_name] = module + + compiled_module = compile(rendered_source, module_path, "exec") + exec(compiled_module, module.__dict__) + + handler = getattr(module, GENERATED_TOOL_ENTRYPOINT, None) + if handler is None: + issues.append( + f"generated module '{module_name}' does not expose the governed entrypoint '{GENERATED_TOOL_ENTRYPOINT}'." + ) + else: + loaded_signature = f"{handler.__name__}{inspect.signature(handler)}" + if not inspect.iscoroutinefunction(handler): + issues.append( + f"generated module '{module_name}' must expose an async '{GENERATED_TOOL_ENTRYPOINT}' callable." + ) + except Exception as exc: + issues.append( + f"generated module import failed: {exc.__class__.__name__}: {exc}" + ) + finally: + sys.modules.pop(module_name, None) + sys.modules.pop(package_name, None) + if previous_package_module is not None: + sys.modules[package_name] = previous_package_module + if previous_tool_module is not None: + sys.modules[module_name] = previous_tool_module + + return { + "module_name": module_name, + "module_path": module_path, + "loaded_callable": GENERATED_TOOL_ENTRYPOINT, + "loaded_signature": loaded_signature, + "sandbox_package_root": sandbox_package_root, + "rendered_source": rendered_source, + "handler": handler, + "issues": issues, + } + + def _validate_generated_tool_import_loading( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + signature_schema_blueprint: dict, + ) -> dict: + if signature_schema_blueprint["issues"]: + return { + "passed": False, + "module_name": build_generated_tool_module_name(version.tool_name), + "module_path": build_generated_tool_module_path(version.tool_name), + "loaded_callable": GENERATED_TOOL_ENTRYPOINT, + "loaded_signature": None, + "sandbox_package_root": None, + "issues": [ + "generated import/loading validation skipped because the signature/schema blueprint is invalid." + ], + } + + load_result = self._load_generated_tool_handler_in_memory( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + issues = list(load_result["issues"]) + handler = load_result["handler"] + loaded_signature = load_result["loaded_signature"] + + if handler is not None and loaded_signature != signature_schema_blueprint["signature"]: + issues.append( + "loaded entrypoint signature differs from the validated signature/schema blueprint." + ) + + if handler is not None and not issues: + try: + registry = ToolRegistry.__new__(ToolRegistry) + registry._tools = [] + registry.register_generated_tool( + name=version.tool_name, + description=metadata.description, + parameters=list(metadata.parameters_json or []), + handler=handler, + ) + except GeneratedToolCoreBoundaryViolation as exc: + issues.append(str(exc)) + + return { + "passed": not issues, + "module_name": load_result["module_name"], + "module_path": load_result["module_path"], + "loaded_callable": load_result["loaded_callable"], + "loaded_signature": loaded_signature, + "sandbox_package_root": load_result["sandbox_package_root"], + "issues": issues, + } + + def _run_generated_tool_minimal_smoke_tests( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + signature_schema_blueprint: dict, + import_loading_result: dict, + ) -> dict: + if signature_schema_blueprint["issues"]: + return { + "passed": False, + "module_name": build_generated_tool_module_name(version.tool_name), + "module_path": build_generated_tool_module_path(version.tool_name), + "sandbox_package_root": None, + "invocation_arguments": {}, + "direct_result_type": None, + "runtime_result_type": None, + "issues": [ + "generated smoke tests skipped because the signature/schema blueprint is invalid." + ], + } + if not import_loading_result["passed"]: + return { + "passed": False, + "module_name": build_generated_tool_module_name(version.tool_name), + "module_path": build_generated_tool_module_path(version.tool_name), + "sandbox_package_root": import_loading_result.get("sandbox_package_root"), + "invocation_arguments": {}, + "direct_result_type": None, + "runtime_result_type": None, + "issues": [ + "generated smoke tests skipped because import/loading validation did not pass." + ], + } + + load_result = self._load_generated_tool_handler_in_memory( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + issues = list(load_result["issues"]) + handler = load_result["handler"] + invocation_arguments = self._build_generated_tool_smoke_test_arguments(metadata.parameters_json) + direct_result_type = None + runtime_result_type = None + + if handler is not None and not issues: + try: + direct_result = asyncio.run(handler(**invocation_arguments)) + direct_result_type = type(direct_result).__name__ + if direct_result is None: + issues.append("generated entrypoint smoke test returned no payload.") + else: + json.dumps(direct_result) + except TypeError as exc: + issues.append( + f"generated entrypoint smoke test returned a non-JSON-serializable payload: {exc}" + ) + except Exception as exc: + issues.append( + f"generated entrypoint smoke test failed: {exc.__class__.__name__}: {exc}" + ) + + if handler is not None and not issues: + try: + registry = ToolRegistry.__new__(ToolRegistry) + registry._tools = [] + registry.register_generated_tool( + name=version.tool_name, + description=metadata.description, + parameters=list(metadata.parameters_json or []), + handler=handler, + ) + runtime_result = asyncio.run( + registry.execute(version.tool_name, invocation_arguments) + ) + runtime_result_type = type(runtime_result).__name__ + if runtime_result is None: + issues.append("generated runtime smoke test returned no payload.") + else: + json.dumps(runtime_result) + except TypeError as exc: + issues.append( + f"generated runtime smoke test returned a non-JSON-serializable payload: {exc}" + ) + except Exception as exc: + issues.append( + f"generated runtime smoke test failed: {exc.__class__.__name__}: {exc}" + ) + + return { + "passed": not issues, + "module_name": load_result["module_name"], + "module_path": load_result["module_path"], + "sandbox_package_root": load_result["sandbox_package_root"], + "invocation_arguments": dict(invocation_arguments), + "direct_result_type": direct_result_type, + "runtime_result_type": runtime_result_type, + "issues": issues, + } + + def _build_generated_tool_smoke_test_arguments( + self, + parameters_json: list[dict] | None, + ) -> dict[str, object]: + serialized_parameters = self._serialize_parameters_for_response(parameters_json) + return { + parameter["name"]: self._build_generated_tool_smoke_test_argument_value(parameter) + for parameter in serialized_parameters + } + + @staticmethod + def _build_generated_tool_smoke_test_argument_value(parameter: dict) -> object: + parameter_name = str(parameter.get("name") or "value").strip().lower() or "value" + parameter_type = parameter.get("parameter_type", ToolParameterType.STRING) + if parameter_type == ToolParameterType.INTEGER: + return 1 + if parameter_type == ToolParameterType.NUMBER: + return 1.5 + if parameter_type == ToolParameterType.BOOLEAN: + return True + if parameter_type == ToolParameterType.OBJECT: + return {"sample": parameter_name} + if parameter_type == ToolParameterType.ARRAY: + return [f"sample_{parameter_name}"] + return f"sample_{parameter_name}" + + def _render_generated_tool_module_source( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + signature_schema_blueprint: dict, + ) -> str: + serialized_parameters = self._serialize_parameters_for_response(metadata.parameters_json) + if serialized_parameters: + signature_tokens = [] + response_argument_lines = [] + for parameter in serialized_parameters: + parameter_name = parameter["name"] + if parameter["required"]: + signature_tokens.append(parameter_name) + else: + signature_tokens.append(f"{parameter_name}=None") + response_argument_lines.append(f' "{parameter_name}": {parameter_name},') + function_signature = f"*, {', '.join(signature_tokens)}" + response_arguments = "\n".join(response_argument_lines) + response_payload = ( + ' "received_arguments": {\n' + f"{response_arguments}\n" + ' },\n' + ) + else: + function_signature = "" + response_payload = ' "received_arguments": {},\n' + + return ( + f'"""Admin-governed generated tool scaffold for {version.tool_name} v{version.version_number}."""\n\n' + f"async def {GENERATED_TOOL_ENTRYPOINT}({function_signature}):\n" + " return {\n" + f' "tool_name": "{version.tool_name}",\n' + f' "version": {version.version_number},\n' + ' "runtime_status": "generated_validation_stub",\n' + f"{response_payload}" + " }\n" + ) + + def _collect_tool_contract_validation_issues( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + ) -> list[str]: + issues: list[str] = [] + tool_name = str(metadata.tool_name or "").strip().lower() + display_name = str(metadata.display_name or "").strip() + description = str(metadata.description or "").strip() + expected_metadata_id = f"tool_metadata::{tool_name}::v{int(metadata.version_number)}" + + if not _TOOL_NAME_PATTERN.fullmatch(tool_name): + issues.append("tool_name persisted is invalid for the shared publication contract.") + if len(display_name) < 4: + issues.append("display_name persisted must have at least 4 characters for publication.") + if len(description) < 16: + issues.append("description persisted must have at least 16 characters for publication.") + if str(metadata.metadata_id or "").strip().lower() != expected_metadata_id: + issues.append("metadata_id persisted is inconsistent with the governed version identifier.") + + seen_parameter_names: set[str] = set() + for raw_parameter in metadata.parameters_json or []: + parameter_name = str((raw_parameter or {}).get("name") or "").strip().lower() + parameter_description = str((raw_parameter or {}).get("description") or "").strip() + parameter_type = str((raw_parameter or {}).get("parameter_type") or "").strip().lower() + if not _PARAMETER_NAME_PATTERN.fullmatch(parameter_name): + issues.append(f"parameter '{parameter_name or ''}' violates the shared naming contract.") + if parameter_name in seen_parameter_names: + issues.append(f"parameter '{parameter_name}' is duplicated in the persisted contract.") + seen_parameter_names.add(parameter_name) + if parameter_type not in {item.value for item in ToolParameterType}: + issues.append(f"parameter '{parameter_name or ''}' uses an unsupported parameter_type.") + if len(parameter_description) < 8: + issues.append(f"parameter '{parameter_name or ''}' must describe its contract with at least 8 characters.") + + try: + self._build_generated_publication_envelope(version=version, metadata=metadata) + except (ValidationError, ValueError) as exc: + issues.extend(self._format_contract_validation_errors(exc)) + return issues + + def _build_generated_publication_envelope( + self, + *, + version: ToolVersion, + metadata: ToolMetadata, + ) -> ToolPublicationEnvelope: + parameters = tuple( + ToolParameterContract( + name=parameter["name"], + parameter_type=parameter["parameter_type"], + description=parameter["description"], + required=parameter["required"], + ) + for parameter in self._serialize_parameters_for_response(metadata.parameters_json) + ) + published_tool = PublishedToolContract( + tool_name=metadata.tool_name, + display_name=metadata.display_name, + description=metadata.description, + version=metadata.version_number, + status=ToolLifecycleStatus.GENERATED, + parameters=parameters, + implementation_module=build_generated_tool_module_name(version.tool_name), + implementation_callable=GENERATED_TOOL_ENTRYPOINT, + ) + return ToolPublicationEnvelope( + source_service=ServiceName.ADMIN, + target_service=ServiceName.PRODUCT, + publication_id=metadata.metadata_id, + published_tool=published_tool, + emitted_at=datetime.now(UTC), + ) + + @staticmethod + def _format_contract_validation_errors(error: ValidationError | ValueError) -> list[str]: + if isinstance(error, ValidationError): + return [ + f"{'.'.join(str(item) for item in issue['loc'])}: {issue['msg']}" + for issue in error.errors() + ] + return [str(error)] + + def _persist_generation_pipeline_artifact( + self, + *, + draft: ToolDraft, + version: ToolVersion, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole, + commit: bool | None = None, + ) -> None: + if self.artifact_repository is None: + return + + artifact_write_options = {"commit": commit} if commit is not None else {} + generation_payload = self._build_generation_artifact_payload( + draft=draft, + version=version, + summary=version.summary, + stored_parameters=list(version.parameters_json or []), + ) + generation_payload.update( + { + "source": "admin_generation_pipeline", + "pipeline_status": "completed", + "triggered_by": actor_name, + "triggered_by_role": actor_role.value, + "generated_at": datetime.now(UTC).isoformat(), + } + ) + self.artifact_repository.upsert_version_artifact( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=version.tool_name, + version_number=version.version_number, + artifact_stage=ToolArtifactStage.GENERATION, + artifact_kind=ToolArtifactKind.GENERATION_REQUEST, + artifact_status=ToolArtifactStatus.SUCCEEDED, + summary="Pipeline de geracao concluido para a versao administrativa.", + payload_json=generation_payload, + author_staff_account_id=actor_staff_account_id, + author_display_name=actor_name, + **artifact_write_options, + ) + + def _build_pipeline_snapshot(self, status: ToolLifecycleStatus) -> dict: + normalized_status = ( + status + if isinstance(status, ToolLifecycleStatus) + else ToolLifecycleStatus(str(status or "").strip().lower()) + ) + + current_step_by_status = { + ToolLifecycleStatus.DRAFT: "generation", + ToolLifecycleStatus.GENERATED: "validation", + ToolLifecycleStatus.VALIDATED: "approval", + ToolLifecycleStatus.APPROVED: "activation", + ToolLifecycleStatus.ACTIVE: "activation", + ToolLifecycleStatus.FAILED: "generation", + ToolLifecycleStatus.ARCHIVED: "activation", + } + step_states_by_status = { + ToolLifecycleStatus.DRAFT: { + "manual_intake": "completed", + "generation": "current", + "validation": "pending", + "approval": "pending", + "activation": "pending", + }, + ToolLifecycleStatus.GENERATED: { + "manual_intake": "completed", + "generation": "completed", + "validation": "current", + "approval": "pending", + "activation": "pending", + }, + ToolLifecycleStatus.VALIDATED: { + "manual_intake": "completed", + "generation": "completed", + "validation": "completed", + "approval": "current", + "activation": "pending", + }, + ToolLifecycleStatus.APPROVED: { + "manual_intake": "completed", + "generation": "completed", + "validation": "completed", + "approval": "completed", + "activation": "current", + }, + ToolLifecycleStatus.ACTIVE: { + "manual_intake": "completed", + "generation": "completed", + "validation": "completed", + "approval": "completed", + "activation": "completed", + }, + ToolLifecycleStatus.FAILED: { + "manual_intake": "completed", + "generation": "failed", + "validation": "pending", + "approval": "pending", + "activation": "pending", + }, + ToolLifecycleStatus.ARCHIVED: { + "manual_intake": "completed", + "generation": "completed", + "validation": "completed", + "approval": "completed", + "activation": "completed", + }, + } + descriptions = { + "manual_intake": "Cadastro manual consolidado no admin e pronto para seguir no pipeline.", + "generation": "Geracao da implementacao isolada da tool dentro do namespace governado.", + "validation": "Validacao da versao gerada antes da aprovacao humana e da ativacao.", + "approval": "Aprovacao humana da diretoria antes da publicacao controlada.", + "activation": "Ativacao da versao aprovada no catalogo governado do produto.", + } + labels = { + "manual_intake": "Cadastro manual", + "generation": "Geracao", + "validation": "Validacao", + "approval": "Aprovacao", + "activation": "Ativacao", + } + step_states = step_states_by_status[normalized_status] + return { + "current_step": current_step_by_status[normalized_status], + "steps": [ + { + "key": step_key, + "label": labels[step_key], + "state": step_states[step_key], + "description": descriptions[step_key], + } + for step_key in ("manual_intake", "generation", "validation", "approval", "activation") + ], + } + def _list_latest_versions( self, *, @@ -1022,6 +1957,7 @@ class ToolManagementService: else None ) display_name = metadata.display_name if metadata is not None else version.tool_name.replace("_", " ").title() + automated_validation = self._extract_latest_automated_validation(version.id) return { "entry_id": version.version_id, "version_id": version.version_id, @@ -1032,20 +1968,55 @@ class ToolManagementService: "gate": self._build_review_gate(version.status), "summary": version.summary, "owner_name": version.owner_display_name, + "automated_validation_status": automated_validation.get("status"), + "automated_validation_summary": automated_validation.get("summary"), "queued_at": version.updated_at or version.created_at, } @staticmethod def _build_review_gate(status: ToolLifecycleStatus) -> str: gate_by_status = { - ToolLifecycleStatus.DRAFT: "director_review_required", - ToolLifecycleStatus.GENERATED: "validation_confirmation_required", + ToolLifecycleStatus.DRAFT: "generation_pipeline_required", + ToolLifecycleStatus.GENERATED: "validation_required", ToolLifecycleStatus.VALIDATED: "director_approval_required", ToolLifecycleStatus.APPROVED: "director_publication_required", - ToolLifecycleStatus.FAILED: "revision_required", + ToolLifecycleStatus.FAILED: "pipeline_retry_required", } return gate_by_status.get(status, "governance_required") + def _extract_latest_automated_validation(self, tool_version_id: int) -> dict: + if self.artifact_repository is None: + return {} + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + tool_version_id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if validation_artifact is None: + return {} + automated_checks = list((validation_artifact.payload_json or {}).get("automated_checks") or []) + if not automated_checks: + return {} + passed_count = sum( + 1 + for check in automated_checks + if str((check or {}).get("status") or "").strip().lower() == "passed" + ) + total_checks = len(automated_checks) + overall_status = "passed" if passed_count == total_checks else "failed" + if overall_status == "passed": + summary = f"{passed_count}/{total_checks} validacoes automaticas passaram antes da revisao humana." + else: + failed_labels = [ + str((check or {}).get("label") or "validacao automatica").strip().lower() + for check in automated_checks + if str((check or {}).get("status") or "").strip().lower() != "passed" + ] + summary = f"{passed_count}/{total_checks} validacoes automaticas passaram; revisar {', '.join(failed_labels)}." + return { + "status": overall_status, + "summary": summary, + } + def _list_latest_metadata_entries( self, *, @@ -1257,3 +2228,4 @@ class ToolManagementService: warnings.append("Tools de orquestracao precisam confirmar claramente como afetam o fluxo do bot antes da ativacao.") return warnings + diff --git a/admin_app/view/router.py b/admin_app/view/router.py index b376f16..4d7282f 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -724,16 +724,30 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT publications_endpoint=publications_endpoint, workflow=( AdminToolReviewWorkflowStep( - eyebrow="Leitura inicial", - title="Revisar fila", - description="Carregar a fila de geracao e entender em que gate cada item se encontra.", - status_label="Revisao", + eyebrow="Cadastro manual", + title="Persistir o draft", + description="Receber o cadastro manual da tool e consolidar a versao administrativa inicial.", + status_label="Draft", + status_variant="info", + ), + AdminToolReviewWorkflowStep( + eyebrow="Pipeline", + title="Executar geracao", + description="Rodar a etapa de geracao da implementacao isolada antes da validacao da versao.", + status_label="Geracao", + status_variant="warning", + ), + AdminToolReviewWorkflowStep( + eyebrow="Validacao", + title="Conferir a versao gerada", + description="Validar a versao produzida pelo pipeline antes da aprovacao humana da diretoria.", + status_label="Validacao", status_variant="info", ), AdminToolReviewWorkflowStep( eyebrow="Decisao humana", title="Aprovar com criterio", - description="Conferir contrato, parametros e prontidao tecnica antes de liberar a proxima etapa.", + description="A diretoria revisa a versao validada e decide se ela pode seguir para publicacao controlada.", status_label="Aprovacao", status_variant="warning", ), diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index 4840dbb..c6482b5 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -257,10 +257,10 @@ class _FakeToolArtifactRepository: def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: artifact.artifact_status = kwargs["artifact_status"] - artifact.storage_kind = kwargs["storage_kind"] + artifact.storage_kind = kwargs.get("storage_kind", artifact.storage_kind) artifact.summary = kwargs["summary"] artifact.payload_json = kwargs["payload_json"] - artifact.checksum = kwargs["checksum"] + artifact.checksum = kwargs.get("checksum", artifact.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) @@ -518,9 +518,45 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(payload["queue_mode"], "governed_admin_queue") self.assertEqual(len(payload["items"]), 1) self.assertEqual(payload["items"][0]["status"], "draft") - self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertEqual(payload["items"][0]["gate"], "generation_pipeline_required") self.assertEqual(payload["items"][0]["version_number"], 1) + def test_panel_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["status"], "generated") + self.assertEqual(payload["current_step"], "validation") + self.assertEqual(payload["queue_entry"]["gate"], "validation_required") + self.assertEqual(payload["queue_entry"]["automated_validation_status"], "passed") + self.assertEqual(payload["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") + steps_by_key = {step["key"]: step for step in payload["steps"]} + self.assertEqual(steps_by_key["generation"]["state"], "completed") + self.assertEqual(steps_by_key["validation"]["state"], "current") + def test_panel_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: @@ -629,6 +665,8 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) version_id = intake_response.json()["draft_preview"]["version_id"] publish_before_approval = client.post(f"/admin/panel/tools/publications/{version_id}/publish") + review_before_pipeline = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") + pipeline_response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") review_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") approve_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/approve") pre_publications = client.get("/admin/panel/tools/publications") @@ -640,6 +678,15 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(intake_response.status_code, 200) self.assertEqual(publish_before_approval.status_code, 409) self.assertIn("approved", publish_before_approval.json()["detail"]) + self.assertEqual(review_before_pipeline.status_code, 409) + self.assertIn("generated", review_before_pipeline.json()["detail"]) + self.assertEqual(pipeline_response.status_code, 200) + self.assertEqual(pipeline_response.json()["status"], "generated") + self.assertEqual(pipeline_response.json()["queue_entry"]["gate"], "validation_required") + self.assertEqual(pipeline_response.json()["queue_entry"]["automated_validation_status"], "passed") + self.assertEqual(pipeline_response.json()["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") + self.assertEqual(len(pipeline_response.json()["automated_validations"]), 4) + self.assertTrue(all(check["status"] == "passed" for check in pipeline_response.json()["automated_validations"])) self.assertEqual(review_response.status_code, 200) self.assertEqual(review_response.json()["status"], "validated") self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required") diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py index f3f1a2c..433ea52 100644 --- a/tests/test_admin_tool_management_service.py +++ b/tests/test_admin_tool_management_service.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import patch from datetime import datetime, timezone from sqlalchemy import create_engine @@ -335,10 +336,10 @@ class _FakeToolArtifactRepository: def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: artifact.artifact_status = kwargs["artifact_status"] - artifact.storage_kind = kwargs["storage_kind"] + artifact.storage_kind = kwargs.get("storage_kind", artifact.storage_kind) artifact.summary = kwargs["summary"] artifact.payload_json = kwargs["payload_json"] - artifact.checksum = kwargs["checksum"] + artifact.checksum = kwargs.get("checksum", artifact.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) @@ -654,9 +655,297 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["items"][0]["version_id"], intake_payload["draft_preview"]["version_id"]) self.assertEqual(payload["items"][0]["version_number"], 1) self.assertEqual(payload["items"][0]["status"], ToolLifecycleStatus.DRAFT) - self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertEqual(payload["items"][0]["gate"], "generation_pipeline_required") self.assertIn(ToolLifecycleStatus.APPROVED, payload["supported_statuses"]) + def test_run_generation_pipeline_promotes_version_to_generated(self): + intake_payload = 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", + owner_role=StaffRole.COLABORADOR, + ) + + payload = self.service.run_generation_pipeline( + intake_payload["draft_preview"]["version_id"], + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.GENERATED) + self.assertEqual(payload["current_step"], "validation") + self.assertEqual(payload["queue_entry"]["gate"], "validation_required") + self.assertEqual(payload["queue_entry"]["automated_validation_status"], "passed") + self.assertEqual(payload["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") + self.assertEqual(len(payload["automated_validations"]), 4) + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") + steps_by_key = {step["key"]: step for step in payload["steps"]} + self.assertEqual(steps_by_key["manual_intake"]["state"], "completed") + self.assertEqual(steps_by_key["generation"]["state"], "completed") + self.assertEqual(steps_by_key["validation"]["state"], "current") + self.assertEqual(self.draft_repository.drafts[0].status, ToolLifecycleStatus.GENERATED) + self.assertEqual(self.version_repository.versions[0].status, ToolLifecycleStatus.GENERATED) + self.assertEqual(self.metadata_repository.metadata_entries[0].status, ToolLifecycleStatus.GENERATED) + generation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.GENERATION_REQUEST + ) + self.assertEqual(generation_artifact.payload_json["pipeline_status"], "completed") + self.assertEqual(generation_artifact.payload_json["triggered_by_role"], StaffRole.COLABORADOR.value) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.artifact_status.value, "succeeded") + self.assertEqual(validation_artifact.payload_json["validation_scope"], "tool_contract") + automated_checks = {check["key"]: check for check in validation_artifact.payload_json["automated_checks"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") + self.assertEqual(validation_artifact.payload_json["signature_schema"]["callable_name"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(validation_artifact.payload_json["signature_schema"]["signature"], "run(*, placa)") + self.assertEqual(validation_artifact.payload_json["import_loading"]["loaded_callable"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(validation_artifact.payload_json["import_loading"]["loaded_signature"], "run(*, placa)") + self.assertEqual(validation_artifact.payload_json["smoke_tests"]["direct_result_type"], "dict") + self.assertEqual(validation_artifact.payload_json["smoke_tests"]["runtime_result_type"], "dict") + self.assertEqual(validation_artifact.payload_json["smoke_tests"]["invocation_arguments"]["placa"], "sample_placa") + self.assertEqual(validation_artifact.payload_json["publication_envelope"]["target_service"], "product") + + def test_run_generation_pipeline_marks_version_failed_when_contract_validation_fails(self): + intake_payload = 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": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + owner_role=StaffRole.COLABORADOR, + ) + self.metadata_repository.metadata_entries[0].display_name = "No" + + payload = self.service.run_generation_pipeline( + intake_payload["draft_preview"]["version_id"], + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) + self.assertEqual(payload["current_step"], "generation") + self.assertEqual(payload["queue_entry"]["gate"], "pipeline_retry_required") + self.assertEqual(payload["queue_entry"]["automated_validation_status"], "failed") + self.assertIn("revisar contrato da tool", payload["queue_entry"]["automated_validation_summary"].lower()) + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "failed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") + self.assertTrue(automated_checks["tool_contract"]["blocking_issues"]) + self.assertEqual(self.draft_repository.drafts[0].status, ToolLifecycleStatus.FAILED) + self.assertEqual(self.version_repository.versions[0].status, ToolLifecycleStatus.FAILED) + self.assertEqual(self.metadata_repository.metadata_entries[0].status, ToolLifecycleStatus.FAILED) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.artifact_status.value, "failed") + self.assertIn("display_name", validation_artifact.payload_json["blocking_issues"][0]) + + def test_run_generation_pipeline_marks_version_failed_when_signature_schema_validation_fails(self): + intake_payload = 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": "user_id", + "parameter_type": "string", + "description": "Identificador do usuario usado no filtro administrativo.", + "required": True, + } + ], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + owner_role=StaffRole.COLABORADOR, + ) + + payload = self.service.run_generation_pipeline( + intake_payload["draft_preview"]["version_id"], + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) + self.assertEqual(payload["queue_entry"]["gate"], "pipeline_retry_required") + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "failed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "failed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "failed") + self.assertIn("user_id", automated_checks["tool_signature_schema"]["blocking_issues"][0]) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.artifact_status.value, "failed") + self.assertIn("user_id", validation_artifact.payload_json["signature_schema"]["issues"][0]) + + def test_run_generation_pipeline_marks_version_failed_when_import_loading_validation_fails(self): + intake_payload = 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", + owner_role=StaffRole.COLABORADOR, + ) + + with patch.object( + self.service, + "_render_generated_tool_module_source", + return_value="async def run(:\n pass\n", + ): + payload = self.service.run_generation_pipeline( + intake_payload["draft_preview"]["version_id"], + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) + self.assertEqual(payload["queue_entry"]["gate"], "pipeline_retry_required") + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "failed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "failed") + self.assertIn("SyntaxError", automated_checks["tool_import_loading"]["blocking_issues"][0]) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.artifact_status.value, "failed") + self.assertIn("SyntaxError", validation_artifact.payload_json["import_loading"]["issues"][0]) + self.assertIn("import/loading validation did not pass", validation_artifact.payload_json["smoke_tests"]["issues"][0]) + + def test_run_generation_pipeline_marks_version_failed_when_smoke_tests_fail(self): + intake_payload = 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", + owner_role=StaffRole.COLABORADOR, + ) + + with patch.object( + self.service, + "_render_generated_tool_module_source", + return_value='async def run(*, placa):\n raise RuntimeError("smoke test boom")\n', + ): + payload = self.service.run_generation_pipeline( + intake_payload["draft_preview"]["version_id"], + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.FAILED) + self.assertEqual(payload["queue_entry"]["gate"], "pipeline_retry_required") + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "failed") + self.assertIn("RuntimeError", automated_checks["tool_smoke_tests"]["blocking_issues"][0]) + validation_artifact = next( + artifact + for artifact in self.artifact_repository.artifacts + if artifact.artifact_kind == ToolArtifactKind.VALIDATION_REPORT + ) + self.assertEqual(validation_artifact.artifact_status.value, "failed") + self.assertIn("RuntimeError", validation_artifact.payload_json["smoke_tests"]["issues"][0]) + + def test_review_requires_generation_pipeline_before_validation(self): + intake_payload = self.service.create_draft_submission( + { + "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": [], + }, + owner_staff_account_id=3, + owner_name="Equipe Interna", + ) + + with self.assertRaisesRegex(ValueError, "generated"): + self.service.review_version( + intake_payload["draft_preview"]["version_id"], + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + ) + def test_director_must_review_approve_and_publish_before_activation(self): intake_payload = self.service.create_draft_submission( { @@ -679,6 +968,12 @@ class AdminToolManagementServiceTests(unittest.TestCase): ) version_id = intake_payload["draft_preview"]["version_id"] + pipeline_payload = self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=3, + runner_name="Equipe Interna", + runner_role=StaffRole.COLABORADOR, + ) review_payload = self.service.review_version( version_id, reviewer_staff_account_id=99, @@ -698,6 +993,9 @@ class AdminToolManagementServiceTests(unittest.TestCase): publisher_role=StaffRole.DIRETOR, ) + self.assertEqual(pipeline_payload["status"], ToolLifecycleStatus.GENERATED) + self.assertEqual(len(pipeline_payload["automated_validations"]), 4) + self.assertTrue(all(check["status"] == "passed" for check in pipeline_payload["automated_validations"])) self.assertEqual(review_payload["status"], ToolLifecycleStatus.VALIDATED) self.assertEqual(review_payload["queue_entry"]["gate"], "director_approval_required") self.assertEqual(approve_payload["status"], ToolLifecycleStatus.APPROVED) @@ -749,6 +1047,7 @@ class AdminToolManagementServiceTests(unittest.TestCase): owner_name="Equipe Interna", ) first_version_id = first_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(first_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) self.service.review_version(first_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) self.service.approve_version(first_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) @@ -766,6 +1065,7 @@ class AdminToolManagementServiceTests(unittest.TestCase): owner_name="Equipe Interna", ) second_version_id = second_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(second_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) self.service.review_version(second_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) self.service.approve_version(second_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index f61e553..dd56aa4 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -254,10 +254,10 @@ class _FakeToolArtifactRepository: def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: artifact.artifact_status = kwargs["artifact_status"] - artifact.storage_kind = kwargs["storage_kind"] + artifact.storage_kind = kwargs.get("storage_kind", artifact.storage_kind) artifact.summary = kwargs["summary"] artifact.payload_json = kwargs["payload_json"] - artifact.checksum = kwargs["checksum"] + artifact.checksum = kwargs.get("checksum", artifact.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) @@ -336,7 +336,7 @@ class AdminToolsWebTests(unittest.TestCase): self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]]) self.assertNotIn("/admin/tools/review-queue", [item["href"] for item in payload["actions"]]) self.assertNotIn("/admin/tools/publications", [item["href"] for item in payload["actions"]]) - self.assertIn("artefatos", payload["next_steps"][0].lower()) + self.assertIn("pipeline de geracao", payload["next_steps"][0].lower()) def test_tools_contracts_return_shared_contract_snapshot(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) @@ -540,10 +540,50 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["queue_mode"], "governed_admin_queue") self.assertEqual(len(payload["items"]), 1) self.assertEqual(payload["items"][0]["status"], "draft") - self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertEqual(payload["items"][0]["gate"], "generation_pipeline_required") self.assertEqual(payload["items"][0]["version_number"], 1) self.assertIn("approved", payload["supported_statuses"]) + def test_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post( + f"/admin/tools/pipeline/{version_id}/run", + 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["status"], "generated") + self.assertEqual(payload["current_step"], "validation") + self.assertEqual(payload["queue_entry"]["gate"], "validation_required") + self.assertEqual(payload["queue_entry"]["automated_validation_status"], "passed") + self.assertEqual(payload["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") + automated_checks = {check["key"]: check for check in payload["automated_validations"]} + self.assertEqual(automated_checks["tool_contract"]["status"], "passed") + self.assertEqual(automated_checks["tool_signature_schema"]["status"], "passed") + self.assertEqual(automated_checks["tool_import_loading"]["status"], "passed") + self.assertEqual(automated_checks["tool_smoke_tests"]["status"], "passed") + steps_by_key = {step["key"]: step for step in payload["steps"]} + self.assertEqual(steps_by_key["generation"]["state"], "completed") + self.assertEqual(steps_by_key["validation"]["state"], "current") + def test_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: @@ -664,6 +704,14 @@ class AdminToolsWebTests(unittest.TestCase): f"/admin/tools/publications/{version_id}/publish", headers={"Authorization": "Bearer token"}, ) + review_before_pipeline = client.post( + f"/admin/tools/review-queue/{version_id}/review", + headers={"Authorization": "Bearer token"}, + ) + pipeline_response = client.post( + f"/admin/tools/pipeline/{version_id}/run", + headers={"Authorization": "Bearer token"}, + ) review_response = client.post( f"/admin/tools/review-queue/{version_id}/review", headers={"Authorization": "Bearer token"}, @@ -684,6 +732,15 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(intake_response.status_code, 200) self.assertEqual(publish_before_approval.status_code, 409) self.assertIn("approved", publish_before_approval.json()["detail"]) + self.assertEqual(review_before_pipeline.status_code, 409) + self.assertIn("generated", review_before_pipeline.json()["detail"]) + self.assertEqual(pipeline_response.status_code, 200) + self.assertEqual(pipeline_response.json()["status"], "generated") + self.assertEqual(pipeline_response.json()["queue_entry"]["gate"], "validation_required") + self.assertEqual(pipeline_response.json()["queue_entry"]["automated_validation_status"], "passed") + self.assertEqual(pipeline_response.json()["queue_entry"]["automated_validation_summary"], "4/4 validacoes automaticas passaram antes da revisao humana.") + self.assertEqual(len(pipeline_response.json()["automated_validations"]), 4) + self.assertTrue(all(check["status"] == "passed" for check in pipeline_response.json()["automated_validations"])) self.assertEqual(review_response.status_code, 200) self.assertEqual(review_response.json()["status"], "validated") self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required")