diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 04bdef62..21e0659f 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -4079,6 +4079,7 @@ def _status_chain_ansible_apply_gate_handoff( *, ansible_dry_run_only: bool, execution_section: dict[str, Any], + facts: dict[str, Any], incident_ids: list[str], source_id: str | None, verification: str, @@ -4121,6 +4122,64 @@ def _status_chain_ansible_apply_gate_handoff( latest_returncode = str(ansible.get("latest_returncode") or "") dry_run_passed = latest_status == "success" and latest_returncode in {"", "0"} verifier_ready = str(verification).lower() in {"verified", "success", "healthy"} + mcp_evidence_ready = _safe_int(facts.get("mcp_gateway_total")) > 0 + closure_gates = [ + { + "key": "mcp_evidence", + "status": "passed" if mcp_evidence_ready else "warning", + "detail": f"mcp={_safe_int(facts.get('mcp_gateway_total'))}", + "asset_id": f"mcp-evidence:{safe_source_ref}", + }, + { + "key": "dry_run", + "status": "passed" if dry_run_passed else "warning", + "detail": ( + f"check={_safe_int(ansible.get('check_mode_total'))}; " + f"rc={ansible.get('latest_returncode') if ansible.get('latest_returncode') is not None else '--'}" + ), + "asset_id": f"ansible-check-mode:{catalog_id}", + }, + { + "key": "apply_candidate", + "status": "passed", + "detail": f"catalog={catalog_id}", + "asset_id": f"ansible-apply-candidate:{catalog_id}", + }, + { + "key": "owner_release", + "status": "blocked", + "detail": "owner_release_receipt=0", + "asset_id": f"owner-release-approval:{safe_source_ref}", + }, + { + "key": "controlled_execution", + "status": "blocked", + "detail": "runtime_gate=closed", + "asset_id": f"controlled-execution:{safe_source_ref}", + }, + { + "key": "post_apply_verifier", + "status": "blocked", + "detail": f"verification={verification or 'missing'}", + "asset_id": f"verifier-plan:{safe_source_ref}", + }, + { + "key": "km_writeback", + "status": "blocked", + "detail": f"km={_safe_int(facts.get('knowledge_entries'))}", + "asset_id": f"km-writeback-candidate:{safe_source_ref}", + }, + { + "key": "playbook_trust", + "status": "blocked", + "detail": "trust_writeback=0", + "asset_id": f"playbook-trust-update-candidate:{catalog_id}", + }, + ] + closure_ready_count = sum(1 for gate in closure_gates if gate["status"] == "passed") + closure_total_count = len(closure_gates) + closure_blocked_count = sum(1 for gate in closure_gates if gate["status"] == "blocked") + closure_completion_percent = int(round((closure_ready_count / closure_total_count) * 100)) return { "schema_version": "awooop_automation_handoff_v1", @@ -4138,6 +4197,54 @@ def _status_chain_ansible_apply_gate_handoff( "apply_candidate": f"ansible-apply-candidate:{catalog_id}", "verifier": f"verifier-plan:{safe_source_ref}", }, + "closure_readiness": { + "schema_version": "awooop_apply_gate_closure_readiness_v1", + "status": "blocked_before_owner_release", + "completion_percent": closure_completion_percent, + "ready_count": closure_ready_count, + "total_count": closure_total_count, + "blocked_count": closure_blocked_count, + "runtime_execution_authorized": False, + "writes_runtime_state": False, + "next_action": "review_owner_release_packet_before_apply", + "blocked_reason": ( + "owner_release_controlled_execution_verifier_km_playbook_trust_missing" + ), + "gates": closure_gates, + "required_owner_fields": [ + "owner_approval_receipt", + "maintenance_window", + "rollback_owner", + "blast_radius", + "post_apply_verifier_plan", + "km_writeback_owner", + "playbook_trust_writeback_owner", + "evidence_refs", + ], + "readback_assets": [ + { + "key": "owner_execution_rehearsal", + "asset_id": ( + "agent-result-capture-owner-approved-execution-rehearsal:P2-126" + ), + "status": "no_write_rehearsal", + }, + { + "key": "final_candidate_readback", + "asset_id": ( + "agent-result-capture-final-release-candidate-readback:P2-133" + ), + "status": "read_only", + }, + { + "key": "release_verifier_preflight", + "asset_id": ( + "agent-result-capture-release-verifier-preflight-gate:P2-136" + ), + "status": "read_only", + }, + ], + }, "candidate": { "catalog_id": catalog_id, "check_mode_playbook_path": check_mode_playbook, @@ -5026,6 +5133,7 @@ def _build_awooop_status_chain( automation_handoff = _status_chain_ansible_apply_gate_handoff( ansible_dry_run_only=ansible_dry_run_only, execution_section=execution_section, + facts=facts, incident_ids=incident_ids, source_id=source_id, verification=str(verification), diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index ed199caa..63b093dd 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1826,6 +1826,42 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No "blocked", "blocked", ] + closure = chain["automation_handoff"]["closure_readiness"] + assert closure["schema_version"] == "awooop_apply_gate_closure_readiness_v1" + assert closure["status"] == "blocked_before_owner_release" + assert closure["runtime_execution_authorized"] is False + assert closure["writes_runtime_state"] is False + assert closure["ready_count"] == 3 + assert closure["total_count"] == 8 + assert closure["blocked_count"] == 5 + assert closure["completion_percent"] == 38 + assert closure["next_action"] == "review_owner_release_packet_before_apply" + assert [gate["key"] for gate in closure["gates"]] == [ + "mcp_evidence", + "dry_run", + "apply_candidate", + "owner_release", + "controlled_execution", + "post_apply_verifier", + "km_writeback", + "playbook_trust", + ] + assert [gate["status"] for gate in closure["gates"]] == [ + "passed", + "passed", + "passed", + "blocked", + "blocked", + "blocked", + "blocked", + "blocked", + ] + assert "owner_approval_receipt" in closure["required_owner_fields"] + assert "post_apply_verifier_plan" in closure["required_owner_fields"] + assert "km_writeback_owner" in closure["required_owner_fields"] + assert closure["readback_assets"][0]["asset_id"] == ( + "agent-result-capture-owner-approved-execution-rehearsal:P2-126" + ) assert chain["execution"]["ansible"]["check_mode_total"] == 1 assert chain["execution"]["ansible"]["apply_total"] == 0 assert chain["execution"]["ansible"]["applied"] is False diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 42a78022..5b2bdaa8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -10099,6 +10099,7 @@ }, "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "reviewOwnerReleasePacket": "審查 owner 放行包、執行窗口、rollback 與回寫責任", "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", @@ -10137,6 +10138,11 @@ "dryRunAsset": "乾跑資產", "applyAsset": "套用候選資產", "verifierAsset": "Verifier 資產", + "closureTitle": "批准後自動化閉環準備度", + "closureProgress": "{ready}/{total} ready · {percent}%", + "closureBlockedReason": "阻擋:{reason}", + "closureOwnerFields": "Owner 放行欄位", + "closureReadbackAssets": "只讀回查資產", "checklistTitle": "Owner 審查清單", "forbiddenTitle": "禁止動作", "gates": { @@ -10144,6 +10150,26 @@ "applyGate": "套用審查", "verifier": "驗證回寫" }, + "closureGates": { + "mcpEvidence": "MCP 證據", + "dryRun": "乾跑", + "applyCandidate": "Apply 候選", + "ownerRelease": "Owner 放行", + "controlledExecution": "受控執行", + "postApplyVerifier": "套用後驗證", + "kmWriteback": "KM 回寫", + "playbookTrust": "PlayBook 信任" + }, + "closureAssets": { + "ownerExecutionRehearsal": "Owner 批准執行演練", + "finalCandidateReadback": "最終候選回查", + "releaseVerifierPreflight": "Verifier 放行前檢查" + }, + "closureStatuses": { + "blockedBeforeOwnerRelease": "Owner 放行前受阻", + "noWriteRehearsal": "無寫入演練", + "readOnly": "只讀" + }, "statuses": { "passed": "已通過", "blocked": "阻擋", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 42a78022..5b2bdaa8 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -10099,6 +10099,7 @@ }, "nextActions": { "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "reviewOwnerReleasePacket": "審查 owner 放行包、執行窗口、rollback 與回寫責任", "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", @@ -10137,6 +10138,11 @@ "dryRunAsset": "乾跑資產", "applyAsset": "套用候選資產", "verifierAsset": "Verifier 資產", + "closureTitle": "批准後自動化閉環準備度", + "closureProgress": "{ready}/{total} ready · {percent}%", + "closureBlockedReason": "阻擋:{reason}", + "closureOwnerFields": "Owner 放行欄位", + "closureReadbackAssets": "只讀回查資產", "checklistTitle": "Owner 審查清單", "forbiddenTitle": "禁止動作", "gates": { @@ -10144,6 +10150,26 @@ "applyGate": "套用審查", "verifier": "驗證回寫" }, + "closureGates": { + "mcpEvidence": "MCP 證據", + "dryRun": "乾跑", + "applyCandidate": "Apply 候選", + "ownerRelease": "Owner 放行", + "controlledExecution": "受控執行", + "postApplyVerifier": "套用後驗證", + "kmWriteback": "KM 回寫", + "playbookTrust": "PlayBook 信任" + }, + "closureAssets": { + "ownerExecutionRehearsal": "Owner 批准執行演練", + "finalCandidateReadback": "最終候選回查", + "releaseVerifierPreflight": "Verifier 放行前檢查" + }, + "closureStatuses": { + "blockedBeforeOwnerRelease": "Owner 放行前受阻", + "noWriteRehearsal": "無寫入演練", + "readOnly": "只讀" + }, "statuses": { "passed": "已通過", "blocked": "阻擋", diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index 0fb18d6d..cf0fa2a7 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -143,6 +143,30 @@ export interface AwoooPStatusChain { apply_candidate?: string | null; verifier?: string | null; }; + closure_readiness?: { + schema_version?: string; + status?: string | null; + completion_percent?: number | null; + ready_count?: number | null; + total_count?: number | null; + blocked_count?: number | null; + runtime_execution_authorized?: boolean | null; + writes_runtime_state?: boolean | null; + next_action?: string | null; + blocked_reason?: string | null; + gates?: Array<{ + key?: string | null; + status?: string | null; + detail?: string | null; + asset_id?: string | null; + }>; + required_owner_fields?: string[]; + readback_assets?: Array<{ + key?: string | null; + asset_id?: string | null; + status?: string | null; + }>; + }; candidate?: { catalog_id?: string | null; check_mode_playbook_path?: string | null; @@ -272,6 +296,7 @@ export function AwoooPStatusChainPanel({ const outcome = chain?.operator_outcome; const outcomeExecution = outcome?.execution_result; const automationHandoff = chain?.automation_handoff; + const closureReadiness = automationHandoff?.closure_readiness; const blockers = chain?.blockers ?? []; const sourceCorrelation = chain?.source_refs?.correlation; const nextActionLabel = (value: unknown) => { @@ -281,6 +306,7 @@ export function AwoooPStatusChainPanel({ owner_review_apply_gate_or_create_verifier_plan: t("nextActions.openApplyGateWorkItem"), manual_review_no_action_decision: t("nextActions.manualReviewNoActionDecision"), owner_review_repair_candidate_draft: t("nextActions.ownerReviewRepairCandidateDraft"), + review_owner_release_packet_before_apply: t("nextActions.reviewOwnerReleasePacket"), manual_review_or_collect_repair_evidence: t("nextActions.collectRepairEvidence"), manual_investigation: t("nextActions.manualInvestigation"), run_or_review_post_execution_verification: t("nextActions.reviewVerifier"), @@ -437,6 +463,9 @@ export function AwoooPStatusChainPanel({ const executionTotal = execution.operation_total ?? 0; const handoffCandidate = automationHandoff?.candidate; const handoffAssets = automationHandoff?.asset_ids; + const closureGates = closureReadiness?.gates ?? []; + const closureReadbackAssets = closureReadiness?.readback_assets ?? []; + const closureOwnerFields = closureReadiness?.required_owner_fields ?? []; const ownerReviewChecklist = automationHandoff?.owner_review_checklist ?? []; const forbiddenActions = automationHandoff?.forbidden_actions ?? []; const sourceToolchainTone: SourceFlowTone = sourceCorrelation @@ -548,6 +577,35 @@ export function AwoooPStatusChainPanel({ { key: "applyAsset", label: t("applyGate.applyAsset"), value: handoffAssets?.apply_candidate }, { key: "verifierAsset", label: t("applyGate.verifierAsset"), value: handoffAssets?.verifier }, ]; + const closureGateLabel = (key: string | null | undefined) => { + const labels: Record = { + mcp_evidence: t("applyGate.closureGates.mcpEvidence"), + dry_run: t("applyGate.closureGates.dryRun"), + apply_candidate: t("applyGate.closureGates.applyCandidate"), + owner_release: t("applyGate.closureGates.ownerRelease"), + controlled_execution: t("applyGate.closureGates.controlledExecution"), + post_apply_verifier: t("applyGate.closureGates.postApplyVerifier"), + km_writeback: t("applyGate.closureGates.kmWriteback"), + playbook_trust: t("applyGate.closureGates.playbookTrust"), + }; + return labels[String(key ?? "")] ?? valueOrEmpty(key, emptyLabel); + }; + const closureAssetLabel = (key: string | null | undefined) => { + const labels: Record = { + owner_execution_rehearsal: t("applyGate.closureAssets.ownerExecutionRehearsal"), + final_candidate_readback: t("applyGate.closureAssets.finalCandidateReadback"), + release_verifier_preflight: t("applyGate.closureAssets.releaseVerifierPreflight"), + }; + return labels[String(key ?? "")] ?? valueOrEmpty(key, emptyLabel); + }; + const closureStatusLabel = (status: string | null | undefined) => { + const labels: Record = { + blocked_before_owner_release: t("applyGate.closureStatuses.blockedBeforeOwnerRelease"), + no_write_rehearsal: t("applyGate.closureStatuses.noWriteRehearsal"), + read_only: t("applyGate.closureStatuses.readOnly"), + }; + return labels[String(status ?? "")] ?? handoffStatusLabel(status); + }; const handoffWorkItemHref = automationHandoff?.work_item_id ? `/awooop/work-items?project_id=awoooi&work_item_id=${encodeURIComponent(automationHandoff.work_item_id)}${automationHandoff.source_id ? `&incident_id=${encodeURIComponent(automationHandoff.source_id)}` : ""}` : null; @@ -807,6 +865,100 @@ export function AwoooPStatusChainPanel({ ))} + {closureReadiness && ( +
+
+
+
+

{t("applyGate.closureTitle")}

+

+ {t("applyGate.closureBlockedReason", { + reason: valueOrEmpty(closureReadiness.blocked_reason, emptyLabel), + })} +

+
+
+ + {t("applyGate.closureProgress", { + ready: closureReadiness.ready_count ?? 0, + total: closureReadiness.total_count ?? closureGates.length, + percent: closureReadiness.completion_percent ?? 0, + })} + + + {closureStatusLabel(closureReadiness.status)} + +
+
+
+
+ {closureGates.map((gate) => ( +
+
+ + +
+

{closureGateLabel(gate.key)}

+

+ {closureStatusLabel(gate.status)} +

+
+
+

+ {valueOrEmpty(gate.detail, emptyLabel)} +

+

+ {valueOrEmpty(gate.asset_id, emptyLabel)} +

+
+ ))} +
+
+
+
+

{t("applyGate.closureOwnerFields")}

+ + {closureOwnerFields.length} + +
+
+ {(closureOwnerFields.length ? closureOwnerFields : [emptyLabel]).map((item, index) => ( + + {item} + + ))} +
+
+
+
+

{t("applyGate.closureReadbackAssets")}

+ + {closureReadbackAssets.length} + +
+
+ {(closureReadbackAssets.length ? closureReadbackAssets : [{ key: emptyLabel, asset_id: emptyLabel, status: emptyLabel }]).map((item, index) => ( +
+
+

{closureAssetLabel(item.key)}

+ + {closureStatusLabel(item.status)} + +
+

+ {valueOrEmpty(item.asset_id, emptyLabel)} +

+
+ ))} +
+
+
+
+ )}

{t("applyGate.workItem")}