feat(awooop): expose execution evidence on incidents
All checks were successful
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / tests (push) Successful in 3m27s
CD Pipeline / build-and-deploy (push) Successful in 4m6s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s

This commit is contained in:
Your Name
2026-05-20 15:19:48 +08:00
parent 312042ae6d
commit d4573cd00a
6 changed files with 197 additions and 0 deletions

View File

@@ -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,
}

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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>