${escapeHtml(item.display_name || item.tool_name || "Tool")}
${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index 33a6f96..dd46bad 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -11,10 +11,13 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolGenerationPipelineResponse, + AdminToolGovernanceDecisionRequest, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, + AdminToolReviewDecisionRequest, + AdminToolReviewDetailResponse, AdminToolReviewQueueResponse, ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal @@ -171,12 +174,32 @@ def panel_tool_review_queue( ) +@router.get( + "/review-queue/{version_id}", + response_model=AdminToolReviewDetailResponse, +) +def panel_tool_review_queue_detail( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + _current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.build_review_detail_payload(version_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return _build_review_detail_response(payload) + + @router.post( "/review-queue/{version_id}/review", response_model=AdminToolGovernanceTransitionResponse, ) def panel_tool_review_queue_review( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -188,6 +211,8 @@ def panel_tool_review_queue_review( reviewer_staff_account_id=current_staff.id, reviewer_name=current_staff.display_name, reviewer_role=current_staff.role, + decision_notes=decision.decision_notes, + reviewed_generated_code=decision.reviewed_generated_code, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -205,6 +230,7 @@ def panel_tool_review_queue_review( ) def panel_tool_review_queue_approve( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -216,6 +242,7 @@ def panel_tool_review_queue_approve( approver_staff_account_id=current_staff.id, approver_name=current_staff.display_name, approver_role=current_staff.role, + decision_notes=decision.decision_notes, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -274,6 +301,66 @@ def panel_tool_publications_publish( return _build_governance_transition_response(payload) +@router.post( + "/publications/{version_id}/deactivate", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_deactivate( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.deactivate_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_governance_transition_response(payload) + + +@router.post( + "/publications/{version_id}/rollback", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_rollback( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.rollback_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_governance_transition_response(payload) + + def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: return AdminToolGenerationPipelineResponse( @@ -305,6 +392,32 @@ def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceT ) +def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse: + return AdminToolReviewDetailResponse( + service="orquestrador-admin", + version_id=payload["version_id"], + tool_name=payload["tool_name"], + display_name=payload["display_name"], + domain=payload["domain"], + version_number=payload["version_number"], + status=payload["status"], + summary=payload["summary"], + description=payload["description"], + business_goal=payload["business_goal"], + owner_name=payload["owner_name"], + parameters=payload["parameters"], + queue_entry=payload["queue_entry"], + automated_validations=payload["automated_validations"], + automated_validation_summary=payload["automated_validation_summary"], + generated_module=payload["generated_module"], + generated_callable=payload["generated_callable"], + generated_source_code=payload["generated_source_code"], + human_gate=payload["human_gate"], + decision_history=payload["decision_history"], + next_steps=payload["next_steps"], + ) + + def _build_panel_actions( settings: AdminSettings, current_role: StaffRole | str | None = None, diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index f57e69c..95fa74b 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -11,10 +11,13 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolGenerationPipelineResponse, + AdminToolGovernanceDecisionRequest, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, + AdminToolReviewDecisionRequest, + AdminToolReviewDetailResponse, AdminToolReviewQueueResponse, ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal @@ -171,12 +174,32 @@ def tool_review_queue( ) +@router.get( + "/review-queue/{version_id}", + response_model=AdminToolReviewDetailResponse, +) +def tool_review_queue_detail( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + _current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.build_review_detail_payload(version_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return _build_review_detail_response(payload) + + @router.post( "/review-queue/{version_id}/review", response_model=AdminToolGovernanceTransitionResponse, ) def tool_review_queue_review( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -188,6 +211,8 @@ def tool_review_queue_review( reviewer_staff_account_id=current_staff.id, reviewer_name=current_staff.display_name, reviewer_role=current_staff.role, + decision_notes=decision.decision_notes, + reviewed_generated_code=decision.reviewed_generated_code, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -205,6 +230,7 @@ def tool_review_queue_review( ) def tool_review_queue_approve( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -216,6 +242,7 @@ def tool_review_queue_approve( approver_staff_account_id=current_staff.id, approver_name=current_staff.display_name, approver_role=current_staff.role, + decision_notes=decision.decision_notes, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -274,6 +301,66 @@ def tool_publications_publish( return _build_governance_transition_response(payload) +@router.post( + "/publications/{version_id}/deactivate", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_deactivate( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.deactivate_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_governance_transition_response(payload) + + +@router.post( + "/publications/{version_id}/rollback", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_rollback( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.rollback_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_governance_transition_response(payload) + + def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: return AdminToolGenerationPipelineResponse( @@ -305,6 +392,32 @@ def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceT ) +def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse: + return AdminToolReviewDetailResponse( + service="orquestrador-admin", + version_id=payload["version_id"], + tool_name=payload["tool_name"], + display_name=payload["display_name"], + domain=payload["domain"], + version_number=payload["version_number"], + status=payload["status"], + summary=payload["summary"], + description=payload["description"], + business_goal=payload["business_goal"], + owner_name=payload["owner_name"], + parameters=payload["parameters"], + queue_entry=payload["queue_entry"], + automated_validations=payload["automated_validations"], + automated_validation_summary=payload["automated_validation_summary"], + generated_module=payload["generated_module"], + generated_callable=payload["generated_callable"], + generated_source_code=payload["generated_source_code"], + human_gate=payload["human_gate"], + decision_history=payload["decision_history"], + next_steps=payload["next_steps"], + ) + + def _build_actions( settings: AdminSettings, current_role: StaffRole | str | None = None, diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 3b262bf..0d4f00f 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -776,6 +776,75 @@ class AdminToolReviewQueueResponse(BaseModel): supported_statuses: list[ToolLifecycleStatus] +class AdminToolReviewDecisionRequest(BaseModel): + decision_notes: str = Field(min_length=12, max_length=2000) + reviewed_generated_code: bool = False + + @field_validator("decision_notes") + @classmethod + def normalize_decision_notes(cls, value: str) -> str: + return value.strip() + + +class AdminToolGovernanceDecisionRequest(BaseModel): + decision_notes: str = Field(min_length=12, max_length=2000) + + @field_validator("decision_notes") + @classmethod + def normalize_decision_notes(cls, value: str) -> str: + return value.strip() + + +class AdminToolReviewHumanGateResponse(BaseModel): + current_gate: str + review_action_available: bool + approval_action_available: bool + publication_action_available: bool + deactivation_action_available: bool + rollback_action_available: bool + rollback_target_version_id: str | None = None + rollback_target_version_number: int | None = None + requires_decision_notes: bool + requires_code_review_confirmation: bool + + +class AdminToolReviewHistoryEntryResponse(BaseModel): + action_key: str + label: str + summary: str + previous_status: str | None = None + current_status: str | None = None + actor_name: str | None = None + actor_role: str | None = None + decision_notes: str | None = None + reviewed_generated_code: bool | None = None + recorded_at: datetime | None = None + + +class AdminToolReviewDetailResponse(BaseModel): + service: str + version_id: str + tool_name: str + display_name: str + domain: str + version_number: int = Field(ge=1) + status: ToolLifecycleStatus + summary: str + description: str + business_goal: str + owner_name: str | None = None + parameters: list["AdminToolPublicationParameterResponse"] = Field(default_factory=list) + queue_entry: AdminToolReviewQueueEntryResponse + automated_validations: list["AdminToolAutomatedValidationResponse"] = Field(default_factory=list) + automated_validation_summary: str | None = None + generated_module: str + generated_callable: str + generated_source_code: str + human_gate: AdminToolReviewHumanGateResponse + decision_history: list[AdminToolReviewHistoryEntryResponse] = Field(default_factory=list) + next_steps: list[str] = Field(default_factory=list) + + class AdminToolPublicationParameterResponse(BaseModel): name: str parameter_type: ToolParameterType @@ -791,6 +860,7 @@ class AdminToolPublicationSummaryResponse(BaseModel): domain: str version: int status: ToolLifecycleStatus + version_id: str | None = None parameter_count: int parameters: list[AdminToolPublicationParameterResponse] = Field(default_factory=list) author_name: str | None = None @@ -798,6 +868,10 @@ class AdminToolPublicationSummaryResponse(BaseModel): implementation_callable: str published_by: str | None = None published_at: datetime | None = None + deactivation_action_available: bool = False + rollback_action_available: bool = False + rollback_target_version_id: str | None = None + rollback_target_version_number: int | None = None class AdminToolPublicationListResponse(BaseModel): diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py index 0d8c1b0..465a427 100644 --- a/admin_app/db/models/tool_artifact.py +++ b/admin_app/db/models/tool_artifact.py @@ -21,6 +21,8 @@ class ToolArtifactKind(str, Enum): DIRECTOR_REVIEW = "director_review" DIRECTOR_APPROVAL = "director_approval" PUBLICATION_RELEASE = "publication_release" + PUBLICATION_DEACTIVATION = "publication_deactivation" + PUBLICATION_ROLLBACK = "publication_rollback" class ToolArtifactStorageKind(str, Enum): diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index aa658c8..189f285 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -97,6 +97,7 @@ _REVIEW_QUEUE_STATUSES = ( ToolLifecycleStatus.APPROVED, ToolLifecycleStatus.FAILED, ) +_HUMAN_DECISION_NOTES_MIN_LENGTH = 12 class ToolManagementService: @@ -356,6 +357,63 @@ class ToolManagementService: "supported_statuses": list(_REVIEW_QUEUE_STATUSES), } + def build_review_detail_payload(self, version_id: str) -> dict: + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Fluxo de governanca de tools 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.") + + 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 versao governada.") + 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 governanca administrativa.") + + validation_payload = {} + if self.artifact_repository is not None: + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if validation_artifact is not None: + validation_payload = dict(validation_artifact.payload_json or {}) + + automated_validation = self._extract_latest_automated_validation(version.id) + generated_source_code = str(validation_payload.get("generated_source_code") or "").strip() + + return { + "version_id": version.version_id, + "tool_name": version.tool_name, + "display_name": metadata.display_name, + "domain": metadata.domain, + "version_number": version.version_number, + "status": version.status, + "summary": version.summary, + "description": metadata.description, + "business_goal": version.business_goal, + "owner_name": version.owner_display_name, + "parameters": self._serialize_parameters_for_response(metadata.parameters_json), + "queue_entry": self._serialize_review_queue_entry(version), + "automated_validations": list(validation_payload.get("automated_checks") or []), + "automated_validation_summary": automated_validation.get("summary"), + "generated_module": build_generated_tool_module_name(version.tool_name), + "generated_callable": GENERATED_TOOL_ENTRYPOINT, + "generated_source_code": generated_source_code, + "human_gate": self._build_human_review_gate(version), + "decision_history": self._list_governance_history_entries(version.id), + "next_steps": self._build_review_detail_next_steps(version, bool(generated_source_code)), + } + def build_publications_payload(self) -> dict: publications_by_tool_name = { publication["tool_name"]: publication @@ -481,7 +539,7 @@ class ToolManagementService: 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. " + "Pipeline de geracao executado com sucesso e as validacoes automaticas de contrato, assinatura, importacao e smoke tests passaram. " "A versao agora segue para a proxima etapa de validacao governada." ) next_steps = [ @@ -490,11 +548,11 @@ class ToolManagementService: ] else: message = ( - "Pipeline de geracao executado, mas alguma validacao automatica de contrato, assinatura ou schema falhou. " + "Pipeline de geracao executado, mas alguma validacao automatica de contrato, assinatura, importacao ou smoke test 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.", + "Ajustar metadados, assinatura esperada, importacao do modulo e smoke tests antes de rodar o pipeline novamente.", "Enquanto alguma validacao automatica falhar, a versao nao pode seguir para aprovacao e ativacao.", ] return { @@ -517,9 +575,27 @@ class ToolManagementService: reviewer_staff_account_id: int, reviewer_name: str, reviewer_role: StaffRole | str, + decision_notes: str, + reviewed_generated_code: bool, ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) + normalized_version_id = str(version_id or "").strip().lower() + version = ( + self.version_repository.get_by_version_id(normalized_version_id) + if self.version_repository is not None + else None + ) + if version is not None and version.status == ToolLifecycleStatus.GENERATED: + if not reviewed_generated_code: + raise ValueError( + "A revisao humana exige confirmar que o codigo gerado foi analisado antes da validacao." + ) + if not self._version_has_generated_source(normalized_version_id): + raise ValueError( + "A revisao humana exige que a pipeline tenha registrado o codigo completo gerado para esta versao." + ) return self._transition_version_status( - version_id, + normalized_version_id, target_status=ToolLifecycleStatus.VALIDATED, allowed_current_statuses=(ToolLifecycleStatus.GENERATED,), actor_staff_account_id=reviewer_staff_account_id, @@ -529,6 +605,8 @@ class ToolManagementService: artifact_kind=ToolArtifactKind.DIRECTOR_REVIEW, artifact_summary="Revisao inicial de diretor registrada para a versao governada.", success_message="Versao revisada por diretor com sucesso e pronta para aprovacao.", + decision_notes=normalized_notes, + reviewed_generated_code=reviewed_generated_code, next_steps=[ "A diretoria ainda precisa aprovar formalmente a versao antes da publicacao.", "Depois da aprovacao, a publicacao ativa a tool no catalogo governado do produto.", @@ -542,7 +620,9 @@ class ToolManagementService: approver_staff_account_id: int, approver_name: str, approver_role: StaffRole | str, + decision_notes: str, ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) return self._transition_version_status( version_id, target_status=ToolLifecycleStatus.APPROVED, @@ -554,6 +634,7 @@ class ToolManagementService: artifact_kind=ToolArtifactKind.DIRECTOR_APPROVAL, artifact_summary="Aprovacao de diretor registrada para a versao governada.", success_message="Versao aprovada por diretor com sucesso e pronta para publicacao.", + decision_notes=normalized_notes, next_steps=[ "A publicacao administrativa ainda precisa ser executada antes da ativacao.", "Enquanto a versao estiver apenas aprovada, ela permanece fora do catalogo ativo do produto.", @@ -585,6 +666,176 @@ class ToolManagementService: ], ) + def deactivate_version( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + payload = self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.ARCHIVED, + allowed_current_statuses=(ToolLifecycleStatus.ACTIVE,), + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + required_permission=AdminPermission.PUBLISH_TOOLS, + artifact_kind=ToolArtifactKind.PUBLICATION_DEACTIVATION, + artifact_summary="Publicacao ativa desativada pela diretoria.", + success_message="Versao ativa desativada com sucesso e retirada do catalogo governado.", + decision_notes=self._normalize_human_decision_notes(decision_notes), + next_steps=[ + "A versao saiu do catalogo ativo e agora permanece apenas para historico e auditoria.", + "Se houver uma versao arquivada anterior da mesma tool, a diretoria pode executar rollback controlado quando necessario.", + ], + ) + payload["queue_entry"] = None + return payload + + def rollback_version( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + normalized_role = normalize_staff_role(actor_role) + if not role_has_permission(normalized_role, AdminPermission.PUBLISH_TOOLS): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{AdminPermission.PUBLISH_TOOLS.value}'." + ) + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Fluxo de governanca de tools ainda nao esta completamente conectado ao armazenamento administrativo." + ) + + normalized_version_id = str(version_id or "").strip().lower() + current_version = self.version_repository.get_by_version_id(normalized_version_id) + if current_version is None: + raise LookupError("Versao administrativa nao encontrada.") + if current_version.status != ToolLifecycleStatus.ACTIVE: + raise ValueError( + f"O rollback exige uma versao atualmente active, mas a versao esta em '{current_version.status.value}'." + ) + + rollback_version = self._find_latest_archived_version( + tool_name=current_version.tool_name, + excluding_version_id=current_version.id, + ) + if rollback_version is None: + raise ValueError("Nenhuma versao arquivada disponivel para rollback controlado desta tool.") + + draft = self.draft_repository.get_by_tool_name(current_version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para o rollback governado.") + current_metadata = self.metadata_repository.get_by_tool_version_id(current_version.id) + rollback_metadata = self.metadata_repository.get_by_tool_version_id(rollback_version.id) + if current_metadata is None or rollback_metadata is None: + raise RuntimeError("Metadados persistidos nao encontrados para executar o rollback governado.") + + normalized_notes = self._normalize_human_decision_notes(decision_notes) + 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 + + try: + self._ensure_human_governance_ready_for_activation(rollback_version.id) + self.version_repository.update_status( + current_version, + status=ToolLifecycleStatus.ARCHIVED, + **atomic_write_options, + ) + self.metadata_repository.update_status( + current_metadata, + status=ToolLifecycleStatus.ARCHIVED, + **atomic_write_options, + ) + self.version_repository.update_status( + rollback_version, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self.metadata_repository.update_status( + rollback_metadata, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self.draft_repository.update_status( + draft, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self._persist_governance_artifact( + draft=draft, + version=current_version, + artifact_kind=ToolArtifactKind.PUBLICATION_DEACTIVATION, + summary="Versao ativa desativada para permitir rollback controlado.", + previous_status=ToolLifecycleStatus.ACTIVE, + current_status=ToolLifecycleStatus.ARCHIVED, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + decision_notes=normalized_notes, + extra_payload={ + "deactivated_for_rollback": True, + "rollback_target_version_id": rollback_version.version_id, + "rollback_target_version_number": rollback_version.version_number, + }, + commit=artifact_commit, + ) + self._persist_governance_artifact( + draft=draft, + version=rollback_version, + artifact_kind=ToolArtifactKind.PUBLICATION_ROLLBACK, + summary="Rollback controlado executado para restaurar a versao arquivada no catalogo ativo.", + previous_status=ToolLifecycleStatus.ARCHIVED, + current_status=ToolLifecycleStatus.ACTIVE, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + decision_notes=normalized_notes, + extra_payload={ + "rollback_from_version_id": current_version.version_id, + "rollback_from_version_number": current_version.version_number, + }, + commit=artifact_commit, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=rollback_version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + return { + "message": ( + "Rollback executado com sucesso e a versao arquivada voltou ao catalogo governado como ativa." + ), + "version_id": rollback_version.version_id, + "tool_name": rollback_version.tool_name, + "version_number": rollback_version.version_number, + "status": rollback_version.status, + "queue_entry": None, + "publication": self._serialize_metadata_publication(rollback_metadata), + "next_steps": [ + "A versao restaurada voltou ao catalogo ativo do produto sob governanca da diretoria.", + "A versao que estava ativa foi arquivada para manter trilha auditavel do rollback controlado.", + ], + } + def _transition_version_status( self, version_id: str, @@ -598,6 +849,8 @@ class ToolManagementService: artifact_kind: ToolArtifactKind, artifact_summary: str, success_message: str, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, next_steps: list[str], ) -> dict: normalized_role = normalize_staff_role(actor_role) @@ -644,6 +897,7 @@ class ToolManagementService: try: if target_status == ToolLifecycleStatus.ACTIVE: + self._ensure_human_governance_ready_for_activation(version.id) self._archive_active_publications( tool_name=version.tool_name, excluding_version_id=version.id, @@ -675,6 +929,8 @@ class ToolManagementService: actor_staff_account_id=actor_staff_account_id, actor_name=actor_name, actor_role=normalized_role, + decision_notes=decision_notes, + reviewed_generated_code=reviewed_generated_code, commit=artifact_commit, ) if repository_session is not None: @@ -995,6 +1251,9 @@ class ToolManagementService: actor_staff_account_id: int, actor_name: str, actor_role: StaffRole, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, + extra_payload: dict | None = None, commit: bool | None = None, ) -> None: if self.artifact_repository is None: @@ -1018,6 +1277,9 @@ class ToolManagementService: actor_staff_account_id=actor_staff_account_id, actor_name=actor_name, actor_role=actor_role, + decision_notes=decision_notes, + reviewed_generated_code=reviewed_generated_code, + extra_payload=extra_payload, ), author_staff_account_id=actor_staff_account_id, author_display_name=actor_name, @@ -1034,8 +1296,11 @@ class ToolManagementService: actor_staff_account_id: int, actor_name: str, actor_role: StaffRole, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, + extra_payload: dict | None = None, ) -> dict: - return { + payload = { "source": "director_governance", "action": artifact_kind.value, "tool_name": version.tool_name, @@ -1046,7 +1311,12 @@ class ToolManagementService: "actor_staff_account_id": actor_staff_account_id, "actor_display_name": actor_name, "actor_role": actor_role.value, + "decision_notes": str(decision_notes or "").strip() or None, + "reviewed_generated_code": reviewed_generated_code, } + if extra_payload: + payload.update(extra_payload) + return payload def _persist_initial_version_artifacts( self, @@ -1341,6 +1611,14 @@ class ToolManagementService: "signature_schema": dict(signature_schema_blueprint), "import_loading": dict(import_loading_result), "smoke_tests": dict(smoke_test_result), + "generated_source_code": ( + str(import_loading_result.get("rendered_source") or "").strip() + or self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + ), "publication_envelope": publication_envelope, } @@ -1499,6 +1777,11 @@ class ToolManagementService: "loaded_callable": GENERATED_TOOL_ENTRYPOINT, "loaded_signature": None, "sandbox_package_root": None, + "rendered_source": self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ), "issues": [ "generated import/loading validation skipped because the signature/schema blueprint is invalid." ], @@ -1538,6 +1821,7 @@ class ToolManagementService: "loaded_callable": load_result["loaded_callable"], "loaded_signature": loaded_signature, "sandbox_package_root": load_result["sandbox_package_root"], + "rendered_source": load_result["rendered_source"], "issues": issues, } @@ -1973,6 +2257,189 @@ class ToolManagementService: "queued_at": version.updated_at or version.created_at, } + def _version_has_generated_source(self, version_id: str) -> bool: + if self.version_repository is None or self.artifact_repository is None: + return False + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + return False + + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if validation_artifact is None: + return False + generated_source_code = str((validation_artifact.payload_json or {}).get("generated_source_code") or "").strip() + return bool(generated_source_code) + + def _find_latest_archived_version( + self, + *, + tool_name: str, + excluding_version_id: int | None = None, + ) -> ToolVersion | None: + if self.version_repository is None: + return None + + for archived_version in self.version_repository.list_versions( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ARCHIVED,), + ): + if excluding_version_id is not None and archived_version.id == excluding_version_id: + continue + return archived_version + return None + + @staticmethod + def _normalize_human_decision_notes(decision_notes: str) -> str: + normalized_notes = str(decision_notes or "").strip() + if len(normalized_notes) < _HUMAN_DECISION_NOTES_MIN_LENGTH: + raise ValueError( + "A decisao humana precisa registrar um parecer com pelo menos " + f"{_HUMAN_DECISION_NOTES_MIN_LENGTH} caracteres." + ) + return normalized_notes + + def _ensure_human_governance_ready_for_activation(self, tool_version_id: int) -> None: + if self.artifact_repository is None: + raise RuntimeError( + "A ativacao governada exige trilha de auditoria habilitada para validar a aprovacao humana." + ) + + review_artifact = self.artifact_repository.get_by_tool_version_and_kind( + tool_version_id, + ToolArtifactKind.DIRECTOR_REVIEW, + ) + approval_artifact = self.artifact_repository.get_by_tool_version_and_kind( + tool_version_id, + ToolArtifactKind.DIRECTOR_APPROVAL, + ) + + review_payload = dict(review_artifact.payload_json or {}) if review_artifact is not None else {} + approval_payload = dict(approval_artifact.payload_json or {}) if approval_artifact is not None else {} + + if not review_payload.get("decision_notes") or not bool(review_payload.get("reviewed_generated_code")): + raise ValueError( + "A ativacao exige uma revisao humana registrada com parecer e confirmacao de leitura do codigo gerado." + ) + if not approval_payload.get("decision_notes"): + raise ValueError( + "A ativacao exige uma aprovacao humana registrada com parecer explicito da diretoria." + ) + + def _build_human_review_gate(self, version: ToolVersion) -> dict: + rollback_candidate = None + if version.status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=version.tool_name, + excluding_version_id=version.id, + ) + return { + "current_gate": ToolManagementService._build_review_gate(version.status), + "review_action_available": version.status == ToolLifecycleStatus.GENERATED, + "approval_action_available": version.status == ToolLifecycleStatus.VALIDATED, + "publication_action_available": version.status == ToolLifecycleStatus.APPROVED, + "deactivation_action_available": version.status == ToolLifecycleStatus.ACTIVE, + "rollback_action_available": version.status == ToolLifecycleStatus.ACTIVE and rollback_candidate is not None, + "rollback_target_version_id": rollback_candidate.version_id if rollback_candidate is not None else None, + "rollback_target_version_number": rollback_candidate.version_number if rollback_candidate is not None else None, + "requires_decision_notes": version.status in { + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.VALIDATED, + ToolLifecycleStatus.ACTIVE, + }, + "requires_code_review_confirmation": version.status == ToolLifecycleStatus.GENERATED, + } + + def _build_review_detail_next_steps( + self, + version: ToolVersion, + generated_source_available: bool, + ) -> list[str]: + status = version.status + next_steps_by_status = { + ToolLifecycleStatus.DRAFT: [ + "Execute a pipeline de geracao para produzir o modulo governado antes da revisao humana.", + "Enquanto a versao estiver em draft, ela permanece fora da aprovacao e da ativacao.", + ], + ToolLifecycleStatus.GENERATED: [ + "Analise o codigo completo gerado, confirme a leitura manual e registre a revisao da diretoria.", + "Somente depois da revisao humana a versao pode seguir para aprovacao formal.", + ], + ToolLifecycleStatus.VALIDATED: [ + "Registre o parecer final de aprovacao da diretoria antes da publicacao.", + "A ativacao continua bloqueada ate existir aprovacao humana explicita.", + ], + ToolLifecycleStatus.APPROVED: [ + "A versao ja foi aprovada pela diretoria e agora pode seguir para publicacao controlada.", + "A ativacao vai validar novamente a trilha de revisao e aprovacao humana antes de entrar no catalogo.", + ], + ToolLifecycleStatus.ACTIVE: [ + "A versao esta ativa no catalogo governado e pode ser desativada com parecer explicito da diretoria.", + "Quando houver uma versao arquivada anterior, o rollback controlado pode restaurar rapidamente a publicacao anterior.", + ], + ToolLifecycleStatus.ARCHIVED: [ + "Esta versao foi retirada do catalogo ativo e permanece arquivada para historico e auditoria.", + "A diretoria pode restaurar uma versao arquivada por rollback controlado a partir da publicacao ativa correspondente.", + ], + ToolLifecycleStatus.FAILED: [ + "Corrija os bloqueios da pipeline e execute uma nova geracao antes de voltar para a revisao humana.", + "Enquanto a versao estiver em failed, a aprovacao e a ativacao permanecem indisponiveis.", + ], + } + next_steps = list(next_steps_by_status.get(status, ["Acompanhe a governanca da versao pela trilha administrativa."])) + if status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=version.tool_name, + excluding_version_id=version.id, + ) + if rollback_candidate is not None: + next_steps.append( + f"Ha uma versao arquivada disponivel para rollback: v{rollback_candidate.version_number}." + ) + if not generated_source_available: + next_steps.append("O codigo completo aparece aqui assim que a pipeline gerar e registrar a funcao governada.") + return next_steps + + def _list_governance_history_entries(self, tool_version_id: int) -> list[dict]: + if self.artifact_repository is None: + return [] + + history_entries = self.artifact_repository.list_artifacts( + tool_version_id=tool_version_id, + artifact_stage=ToolArtifactStage.GOVERNANCE, + ) + return [ + self._serialize_governance_history_entry(artifact) + for artifact in reversed(history_entries) + ] + + @staticmethod + def _serialize_governance_history_entry(artifact) -> dict: + payload = dict(artifact.payload_json or {}) + label_by_kind = { + ToolArtifactKind.DIRECTOR_REVIEW: "Revisao humana registrada", + ToolArtifactKind.DIRECTOR_APPROVAL: "Aprovacao humana registrada", + ToolArtifactKind.PUBLICATION_RELEASE: "Publicacao administrativa registrada", + ToolArtifactKind.PUBLICATION_DEACTIVATION: "Desativacao registrada", + ToolArtifactKind.PUBLICATION_ROLLBACK: "Rollback registrado", + } + return { + "action_key": artifact.artifact_kind.value, + "label": label_by_kind.get(artifact.artifact_kind, "Governanca registrada"), + "summary": artifact.summary, + "previous_status": payload.get("previous_status"), + "current_status": payload.get("current_status"), + "actor_name": payload.get("actor_display_name"), + "actor_role": payload.get("actor_role"), + "decision_notes": payload.get("decision_notes"), + "reviewed_generated_code": payload.get("reviewed_generated_code"), + "recorded_at": artifact.updated_at or artifact.created_at, + } + @staticmethod def _build_review_gate(status: ToolLifecycleStatus) -> str: gate_by_status = { @@ -1980,6 +2447,8 @@ class ToolManagementService: ToolLifecycleStatus.GENERATED: "validation_required", ToolLifecycleStatus.VALIDATED: "director_approval_required", ToolLifecycleStatus.APPROVED: "director_publication_required", + ToolLifecycleStatus.ACTIVE: "publication_active", + ToolLifecycleStatus.ARCHIVED: "archived_history", ToolLifecycleStatus.FAILED: "pipeline_retry_required", } return gate_by_status.get(status, "governance_required") @@ -2035,6 +2504,18 @@ class ToolManagementService: def _serialize_metadata_publication(self, metadata: ToolMetadata) -> dict: parameters = self._serialize_parameters_for_response(metadata.parameters_json) + version_record = None + if self.version_repository is not None: + for candidate in self.version_repository.list_versions(tool_name=metadata.tool_name): + if candidate.id == metadata.tool_version_id: + version_record = candidate + break + rollback_candidate = None + if metadata.status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=metadata.tool_name, + excluding_version_id=metadata.tool_version_id, + ) return { "publication_id": metadata.metadata_id, "tool_name": metadata.tool_name, @@ -2043,6 +2524,7 @@ class ToolManagementService: "domain": metadata.domain, "version": metadata.version_number, "status": metadata.status, + "version_id": version_record.version_id if version_record is not None else None, "parameter_count": len(parameters), "parameters": parameters, "author_name": metadata.author_display_name, @@ -2050,6 +2532,10 @@ class ToolManagementService: "implementation_callable": GENERATED_TOOL_ENTRYPOINT, "published_by": metadata.author_display_name, "published_at": metadata.updated_at or metadata.created_at, + "deactivation_action_available": metadata.status == ToolLifecycleStatus.ACTIVE and version_record is not None, + "rollback_action_available": metadata.status == ToolLifecycleStatus.ACTIVE and rollback_candidate is not None, + "rollback_target_version_id": rollback_candidate.version_id if rollback_candidate is not None else None, + "rollback_target_version_number": rollback_candidate.version_number if rollback_candidate is not None else None, } def _serialize_draft_summary(self, draft: ToolDraft) -> dict: diff --git a/admin_app/view/rendering.py b/admin_app/view/rendering.py index be73971..ebe2564 100644 --- a/admin_app/view/rendering.py +++ b/admin_app/view/rendering.py @@ -672,6 +672,88 @@ def render_tool_review_page( +
Detalhe da versao
+Selecione uma versao da fila para validar o contrato, inspecionar o codigo completo gerado e registrar a decisao da diretoria.
+O detalhe da versao aparece aqui junto com o resumo funcional e o gate humano atual.
+Contexto e parametros
+Validacoes automaticas
+Historico da diretoria
+Proximos passos
+${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}
${validationSummary}${queuedAt}${escapeHtml(payload?.message || "Nenhuma tool aguardando revisao agora.")}
${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}
${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}
Nenhuma publicacao ativa retornada pela sessao web.
${escapeHtml(message || "A sessao atual nao possui permissao para ler as publicacoes ativas.")}
A leitura do detalhe da versao esta em andamento.
`; + detailMeta.innerHTML = `${escapeHtml(message || "Escolha uma versao da fila para abrir o detalhe governado.")}
`; + detailMeta.innerHTML = `${escapeHtml(message || "A sessao atual nao pode visualizar o detalhe de revisao desta versao.")}
`; + detailMeta.innerHTML = `${escapeHtml(payload?.description || "Sem descricao detalhada para esta versao.")}
`; + + const parameterMarkup = parameters.length > 0 + ? parameters.map((item) => `