From d4573cd00ab1a22fb36e92580cef621cdaa90e32 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 15:19:48 +0800 Subject: [PATCH] feat(awooop): expose execution evidence on incidents --- .../src/services/platform_operator_service.py | 90 +++++++++++++++++++ .../test_awooop_operator_timeline_labels.py | 40 +++++++++ apps/web/messages/en.json | 4 + apps/web/messages/zh-TW.json | 4 + .../src/components/awooop/status-chain.tsx | 26 ++++++ .../src/components/incident/incident-card.tsx | 33 +++++++ 6 files changed, 197 insertions(+) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 070e6d90..9c87c391 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1212,6 +1212,94 @@ def _status_chain_mcp_section(truth_chain: dict[str, Any] | None) -> dict[str, A } +def _first_non_empty(row: Mapping[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + value = row.get(key) + if value not in (None, ""): + return value + return None + + +def _status_chain_execution_section(truth_chain: dict[str, Any] | None) -> dict[str, Any]: + execution = truth_chain.get("execution") if isinstance(truth_chain, dict) else {} + if not isinstance(execution, dict): + execution = {} + ops = execution.get("automation_operation_log") + if not isinstance(ops, list): + ops = [] + latest_op = ops[0] if ops and isinstance(ops[0], dict) else {} + + playbook_ids: list[str] = [] + playbook_paths: list[str] = [] + for row in ops: + if not isinstance(row, dict): + continue + _append_unique(playbook_ids, row.get("matched_playbook_id")) + _append_unique(playbook_ids, row.get("input_playbook_id")) + _append_unique(playbook_ids, row.get("output_playbook_id")) + _append_unique(playbook_paths, row.get("input_playbook_path")) + _append_unique(playbook_paths, row.get("output_playbook_path")) + _append_unique(playbook_paths, row.get("input_ansible_playbook_path")) + _append_unique(playbook_paths, row.get("output_ansible_playbook_path")) + + ansible = execution.get("ansible") if isinstance(execution.get("ansible"), dict) else {} + ansible_records = ansible.get("records") if isinstance(ansible.get("records"), list) else [] + latest_ansible = ( + ansible_records[0] + if ansible_records and isinstance(ansible_records[0], dict) + else {} + ) + candidate_catalog = ( + ansible.get("candidate_catalog") + if isinstance(ansible.get("candidate_catalog"), dict) + else {} + ) + candidates = ( + candidate_catalog.get("candidates") + if isinstance(candidate_catalog.get("candidates"), list) + else [] + ) + + return { + "operation_total": len(ops), + "latest_operation_type": latest_op.get("operation_type"), + "latest_status": latest_op.get("status"), + "latest_actor": latest_op.get("actor"), + "latest_action": _first_non_empty(latest_op, ("input_action", "output_action")), + "latest_executor": _first_non_empty( + latest_op, + ( + "input_executor", + "output_executor", + "input_execution_backend", + "output_execution_backend", + ), + ), + "playbook_ids": playbook_ids[:5], + "playbook_paths": playbook_paths[:5], + "ansible": { + "considered": bool(ansible.get("considered")), + "record_total": len(ansible_records), + "candidate_count": len(candidates), + "not_used_reason": ansible.get("not_used_reason"), + "latest_operation_type": latest_ansible.get("operation_type"), + "latest_status": latest_ansible.get("status"), + "latest_playbook_path": latest_ansible.get("playbook_path"), + "latest_check_mode": latest_ansible.get("check_mode"), + "candidate_playbooks": [ + { + "catalog_id": item.get("catalog_id"), + "playbook_path": item.get("playbook_path"), + "risk_level": item.get("risk_level"), + "match_score": item.get("match_score"), + } + for item in candidates[:3] + if isinstance(item, dict) + ], + }, + } + + def _build_awooop_status_chain( *, incident_ids: list[str], @@ -1290,6 +1378,7 @@ def _build_awooop_status_chain( needs_human = True mcp_section = _status_chain_mcp_section(truth_chain) + execution_section = _status_chain_execution_section(truth_chain) blockers = [ str(item) for item in [ @@ -1332,6 +1421,7 @@ def _build_awooop_status_chain( "auto_repair": latest.get("writes_auto_repair_result"), }, "mcp": mcp_section, + "execution": execution_section, } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 2c717411..72b27d98 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -504,6 +504,41 @@ def test_awooop_status_chain_marks_verified_repair() -> None: ], }, }, + "execution": { + "automation_operation_log": [ + { + "operation_type": "playbook_executed", + "status": "success", + "actor": "auto_repair_executor", + "input_action": "restart_service", + "input_executor": "ansible", + "input_playbook_id": "pb-host-restart", + "input_playbook_path": "infra/ansible/playbooks/188-ai-web.yml", + } + ], + "ansible": { + "considered": True, + "records": [ + { + "operation_type": "ansible_check_mode_executed", + "status": "success", + "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", + "check_mode": "true", + } + ], + "candidate_catalog": { + "candidates": [ + { + "catalog_id": "ansible:188-ai-web", + "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", + "risk_level": "medium", + "match_score": 3, + } + ] + }, + "not_used_reason": None, + }, + }, }, remediation_history={ "total": 1, @@ -531,6 +566,11 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["mcp"]["gateway"]["policy_enforced_total"] == 2 assert chain["mcp"]["legacy"]["total"] == 1 assert chain["mcp"]["top_tools"][0]["tool_name"] == "prometheus.query" + assert chain["execution"]["operation_total"] == 1 + assert chain["execution"]["latest_executor"] == "ansible" + assert chain["execution"]["playbook_ids"] == ["pb-host-restart"] + assert chain["execution"]["ansible"]["considered"] is True + assert chain["execution"]["ansible"]["candidate_count"] == 1 def test_awooop_status_chain_marks_read_only_manual_gate() -> None: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index cb693616..da4daa38 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -432,6 +432,10 @@ "flowEvidenceKm": "KM {count}", "flowEvidenceRepair": "Repair {count}", "flowMcpDetail": "MCP detail: Gateway success {success} / failed {failed} / blocked {blocked}; first-class {firstClass}; legacy {legacy}; tools {tools}", + "flowExecutionDetail": "Execution detail: Executor {executor}; Operation {operation} / {status}; Ansible {ansible}; PlayBook {playbook}", + "flowExecutionAnsibleConsidered": "considered ({records} records / {candidates} candidates)", + "flowExecutionAnsibleNotUsed": "not used: {reason}", + "flowExecutionAnsibleEmpty": "--", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "Complete", "flowStages": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 44e11196..46a08f58 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -433,6 +433,10 @@ "flowEvidenceKm": "KM {count}", "flowEvidenceRepair": "修復 {count}", "flowMcpDetail": "MCP 明細:Gateway 成功 {success} / 失敗 {failed} / 阻擋 {blocked};一級治理 {firstClass};Legacy {legacy};工具 {tools}", + "flowExecutionDetail": "執行明細:Executor {executor};Operation {operation} / {status};Ansible {ansible};PlayBook {playbook}", + "flowExecutionAnsibleConsidered": "已納入 ({records} records / {candidates} candidates)", + "flowExecutionAnsibleNotUsed": "未使用:{reason}", + "flowExecutionAnsibleEmpty": "--", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "已完成", "flowStages": { diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index c22a25ae..f09adb5c 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -62,6 +62,32 @@ export interface AwoooPStatusChain { last_error?: string | null; }>; }; + execution?: { + operation_total?: number | null; + latest_operation_type?: string | null; + latest_status?: string | null; + latest_actor?: string | null; + latest_action?: string | null; + latest_executor?: string | null; + playbook_ids?: string[]; + playbook_paths?: string[]; + ansible?: { + considered?: boolean | null; + record_total?: number | null; + candidate_count?: number | null; + not_used_reason?: string | null; + latest_operation_type?: string | null; + latest_status?: string | null; + latest_playbook_path?: string | null; + latest_check_mode?: string | null | boolean; + candidate_playbooks?: Array<{ + catalog_id?: string | null; + playbook_path?: string | null; + risk_level?: string | null; + match_score?: number | null; + }>; + }; + }; } function toneClass(chain?: AwoooPStatusChain | null) { diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index 5ff2eac9..262a9e6b 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -137,6 +137,11 @@ function chainValue(value: string | null | undefined, fallback = '--'): string { return normalized || fallback } +function compactChainPath(value: string | null | undefined, fallback = '--'): string { + const normalized = chainValue(value, fallback) + return normalized.replace(/^infra\/ansible\/playbooks\//, '') +} + /** 格式化持續時間 */ function formatDuration(createdAt: string | undefined): string { if (!createdAt) return '--' @@ -351,6 +356,30 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange tools: mcpTopTools || '--', }) : null + const execution = statusChain?.execution + const ansible = execution?.ansible + const ansibleSummary = ansible?.considered + ? t('flowExecutionAnsibleConsidered', { + records: ansible.record_total ?? 0, + candidates: ansible.candidate_count ?? 0, + }) + : ansible?.not_used_reason + ? t('flowExecutionAnsibleNotUsed', { reason: String(ansible.not_used_reason).slice(0, 96) }) + : t('flowExecutionAnsibleEmpty') + const executionPlaybook = compactChainPath( + execution?.playbook_paths?.[0] + ?? execution?.playbook_ids?.[0] + ?? ansible?.candidate_playbooks?.[0]?.playbook_path + ) + const statusChainExecutionDetail = hasTruthChain + ? t('flowExecutionDetail', { + executor: chainValue(execution?.latest_executor), + operation: chainValue(execution?.latest_operation_type), + status: chainValue(execution?.latest_status), + ansible: ansibleSummary, + playbook: executionPlaybook, + }) + : null const serviceName = incident.affected_services?.[0] ?? '--' const duration = formatDuration(incident.created_at) @@ -626,6 +655,10 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange {statusChainMcpDetail} + / + + {statusChainExecutionDetail} + )}