diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 9f2b3491..f296ce87 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -131,8 +131,23 @@ def _is_no_action_operation(row: dict[str, Any]) -> bool: ) +def _is_audit_only_operation(row: dict[str, Any]) -> bool: + operation_type = str(row.get("operation_type") or "") + status = str(row.get("status") or "").lower() + if status == "dry_run": + return True + return operation_type in { + "ansible_candidate_matched", + "ansible_execution_skipped", + } + + def _effective_execution_ops(automation_ops: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [row for row in automation_ops if not _is_no_action_operation(row)] + return [ + row + for row in automation_ops + if not _is_no_action_operation(row) and not _is_audit_only_operation(row) + ] def build_incident_reconciliation( @@ -315,17 +330,17 @@ def _truth_status( stage = "approval_required" stage_status = "waiting" needs_human = True - elif "APPROVED" in approval_statuses and not has_execution_records: - if approval_no_action or "NO_ACTION" in approval_actions: + elif not has_execution_records and (approval_no_action or "NO_ACTION" in approval_actions): + if approval_statuses: stage = "manual_required" stage_status = "blocked" needs_human = True blockers.append("approval_resolved_no_action_without_execution") - else: - stage = "execution_missing" - stage_status = "blocked" - needs_human = True - blockers.append("approved_without_execution_record") + elif "APPROVED" in approval_statuses and not has_execution_records: + stage = "execution_missing" + stage_status = "blocked" + needs_human = True + blockers.append("approved_without_execution_record") op_statuses = {str(row.get("status") or "").lower() for row in effective_ops} repair_successes = {row.get("success") for row in repair_rows} @@ -410,6 +425,7 @@ def build_automation_quality( legacy_total = int(legacy_mcp_summary.get("total") or 0) effective_ops = _effective_execution_ops(automation_ops) noop_ops = [row for row in automation_ops if _is_no_action_operation(row)] + audit_only_ops = [row for row in automation_ops if _is_audit_only_operation(row)] automation_statuses = {str(row.get("status") or "").lower() for row in effective_ops} auto_repair_successes = {row.get("success") for row in auto_repair_executions} has_execution = bool(effective_ops or auto_repair_executions) @@ -438,7 +454,7 @@ def build_automation_quality( if any(status in {"PENDING", "WAITING_APPROVAL"} for status in approval_statuses): gate("approval_state", "warning", "waiting_approval") - elif "APPROVED" in approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions) and not has_execution: + elif approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions) and not has_execution: gate("approval_state", "failed", "approved_no_action_without_execution") elif approvals: gate("approval_state", "passed", ",".join(sorted(approval_statuses))) @@ -472,7 +488,7 @@ def build_automation_quality( verdict = "execution_failed" elif has_execution: verdict = "execution_unverified" - elif "APPROVED" in approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions): + elif approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions): verdict = "manual_required_no_action" elif any(status in {"PENDING", "WAITING_APPROVAL"} for status in approval_statuses): verdict = "approval_required" @@ -520,6 +536,7 @@ def build_automation_quality( "automation_operation_records": len(automation_ops), "effective_execution_records": len(effective_ops), "noop_operation_records": len(noop_ops), + "audit_only_operation_records": len(audit_only_ops), "auto_repair_execution_records": len(auto_repair_executions), "verification_result": verification_result, "knowledge_entries": len(km_entries), diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index cb0b65df..53f5c3c0 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -61,7 +61,7 @@ def test_truth_status_marks_no_action_approval_as_manual_required() -> None: def test_truth_status_does_not_treat_no_action_audit_as_execution() -> None: status = _truth_status( incident={"incident_id": "INC-1", "status": "RESOLVED"}, - approvals=[{"status": "APPROVED", "action": "未知操作 | NO_ACTION"}], + approvals=[{"status": "EXECUTION_SUCCESS", "action": "未知操作 | NO_ACTION"}], evidence_rows=[{"sensors_attempted": 8, "sensors_succeeded": 6}], automation_ops=[ { @@ -71,6 +71,11 @@ def test_truth_status_does_not_treat_no_action_audit_as_execution() -> None: "output_reason": "NO_ACTION", "output_action": "未知操作 | NO_ACTION", }, + { + "operation_type": "ansible_candidate_matched", + "status": "dry_run", + "output_not_used_reason": "Ansible check-mode is not wired yet", + }, ], drift=None, drift_repeat_count=0, @@ -279,7 +284,7 @@ def test_automation_quality_marks_no_action_without_execution() -> None: def test_automation_quality_ignores_no_action_audit_rows_as_execution() -> None: quality = build_automation_quality( incident={"incident_id": "INC-1", "status": "RESOLVED"}, - approvals=[{"status": "APPROVED", "action": "未知操作 | NO_ACTION"}], + approvals=[{"status": "EXECUTION_SUCCESS", "action": "未知操作 | NO_ACTION"}], evidence_rows=[{"sensors_attempted": 8, "sensors_succeeded": 6}], automation_ops=[ { @@ -289,6 +294,11 @@ def test_automation_quality_ignores_no_action_audit_rows_as_execution() -> None: "output_reason": "NO_ACTION", "output_action": "未知操作 | NO_ACTION", }, + { + "operation_type": "ansible_candidate_matched", + "status": "dry_run", + "output_not_used_reason": "Ansible check-mode is not wired yet", + }, ], auto_repair_executions=[], gateway_mcp_summary={"total": 8}, @@ -300,9 +310,10 @@ def test_automation_quality_ignores_no_action_audit_rows_as_execution() -> None: gates = {row["name"]: row["status"] for row in quality["gates"]} assert quality["verdict"] == "manual_required_no_action" - assert quality["facts"]["automation_operation_records"] == 1 + assert quality["facts"]["automation_operation_records"] == 2 assert quality["facts"]["effective_execution_records"] == 0 assert quality["facts"]["noop_operation_records"] == 1 + assert quality["facts"]["audit_only_operation_records"] == 1 assert gates["execution_recorded"] == "missing" assert gates["verification_recorded"] == "not_applicable"