feat(awooop): expose execution evidence on incidents
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
<span data-testid="incident-mcp-evidence" style={{ color: '#555550' }}>
|
||||
{statusChainMcpDetail}
|
||||
</span>
|
||||
<span style={{ color: '#b0ad9f' }}>/</span>
|
||||
<span data-testid="incident-execution-evidence" style={{ color: '#555550' }}>
|
||||
{statusChainExecutionDetail}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user