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}
+
>
)}