diff --git a/apps/api/src/services/awooop_ansible_audit_service.py b/apps/api/src/services/awooop_ansible_audit_service.py index a73d66ff..2e2f2386 100644 --- a/apps/api/src/services/awooop_ansible_audit_service.py +++ b/apps/api/src/services/awooop_ansible_audit_service.py @@ -266,6 +266,7 @@ def build_ansible_truth( "schema_version": "ansible_executor_audit_v1", "operation_types": sorted(ANSIBLE_OPERATION_TYPES), "required_audit_fields": [ + "incident_id", "operation_type", "status", "actor", @@ -404,7 +405,7 @@ async def record_ansible_decision_audit( SELECT op_id FROM automation_operation_log WHERE operation_type = 'ansible_candidate_matched' - AND input ->> 'incident_id' = :incident_id + AND coalesce(incident_id::text, input ->> 'incident_id') = :incident_id AND input ->> 'executor' = 'ansible' LIMIT 1 """), @@ -415,12 +416,13 @@ async def record_ansible_decision_audit( await db.execute( text(""" INSERT INTO automation_operation_log ( - operation_type, actor, status, + operation_type, actor, status, incident_id, input, output, dry_run_result, tags ) VALUES ( :operation_type, 'decision_manager', :status, + NULLIF(:incident_id, ''), CAST(:input AS jsonb), CAST(:output AS jsonb), CAST(:dry_run_result AS jsonb), @@ -430,6 +432,7 @@ async def record_ansible_decision_audit( { "operation_type": payload["operation_type"], "status": payload["status"], + "incident_id": incident_id, "input": json.dumps(payload["input"], ensure_ascii=False), "output": json.dumps(payload["output"], ensure_ascii=False), "dry_run_result": json.dumps(payload["dry_run_result"], ensure_ascii=False), diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index a2266c3b..43ea7d27 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -81,6 +81,10 @@ def _json_loads(value: Any) -> dict[str, Any]: return {} +def _incident_id_from_payload(payload: dict[str, Any]) -> str: + return str(payload.get("incident_id") or "").strip() + + def detect_ansible_transport_blockers(*values: Any) -> list[str]: combined = " ".join(str(value or "") for value in values) blockers: list[str] = [] @@ -159,7 +163,7 @@ def build_ansible_check_mode_claim_input( candidate_input: dict[str, Any], ) -> dict[str, Any]: safe = _safe_candidate(candidate_input) - incident_id = str(candidate_input.get("incident_id") or "") + incident_id = _incident_id_from_payload(candidate_input) return { "incident_id": incident_id, "executor": "ansible", @@ -355,13 +359,14 @@ async def claim_pending_check_modes( inserted = await db.execute( text(""" INSERT INTO automation_operation_log ( - operation_type, actor, status, + operation_type, actor, status, incident_id, input, output, dry_run_result, parent_op_id, tags ) VALUES ( 'ansible_check_mode_executed', 'ansible_check_mode_worker', 'pending', + NULLIF(:incident_id, ''), CAST(:input AS jsonb), '{}'::jsonb, CAST(:dry_run_result AS jsonb), @@ -371,6 +376,7 @@ async def claim_pending_check_modes( RETURNING op_id """), { + "incident_id": _incident_id_from_payload(claim_input), "input": json.dumps(claim_input, ensure_ascii=False), "dry_run_result": json.dumps({ "check_mode_executed": False, @@ -442,7 +448,7 @@ async def _insert_skipped_candidate( reason: str, ) -> None: input_payload = { - "incident_id": str(candidate_input.get("incident_id") or ""), + "incident_id": _incident_id_from_payload(candidate_input), "executor": "ansible", "execution_backend": "ansible", "execution_mode": "check_mode", @@ -454,13 +460,14 @@ async def _insert_skipped_candidate( await db.execute( text(""" INSERT INTO automation_operation_log ( - operation_type, actor, status, + operation_type, actor, status, incident_id, input, output, dry_run_result, parent_op_id, tags ) VALUES ( 'ansible_execution_skipped', 'ansible_check_mode_worker', 'dry_run', + NULLIF(:incident_id, ''), CAST(:input AS jsonb), CAST(:output AS jsonb), CAST(:dry_run_result AS jsonb), @@ -469,6 +476,7 @@ async def _insert_skipped_candidate( ) """), { + "incident_id": _incident_id_from_payload(input_payload), "input": json.dumps(input_payload, ensure_ascii=False), "output": json.dumps({ "not_used_reason": reason, diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 2940832f..8cacbac9 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -1652,22 +1652,55 @@ async def fetch_automation_quality_summary( incidents = await _fetch_all( db, """ + WITH source_candidates AS ( + SELECT + incident_id::text AS incident_id, + created_at AS seen_at + FROM incidents + WHERE (project_id = :project_id OR project_id IS NULL) + AND created_at >= :cutoff + + UNION ALL + + SELECT + coalesce(incident_id::text, input ->> 'incident_id') AS incident_id, + created_at AS seen_at + FROM automation_operation_log + WHERE created_at >= :cutoff + AND operation_type IN ( + 'ansible_candidate_matched', + 'ansible_check_mode_executed', + 'ansible_execution_skipped', + 'ansible_apply_executed', + 'ansible_rollback_executed' + ) + AND coalesce(incident_id::text, input ->> 'incident_id', '') <> '' + ), + source_ids AS ( + SELECT + incident_id, + max(seen_at) AS recent_evidence_at + FROM source_candidates + WHERE incident_id IS NOT NULL AND incident_id <> '' + GROUP BY incident_id + ) SELECT - incident_id, - project_id, - status::text AS status, - severity::text AS severity, - alertname, - alert_category, - notification_type, - created_at, - updated_at, - resolved_at, - verification_result - FROM incidents - WHERE (project_id = :project_id OR project_id IS NULL) - AND created_at >= :cutoff - ORDER BY created_at DESC + incidents.incident_id, + incidents.project_id, + incidents.status::text AS status, + incidents.severity::text AS severity, + incidents.alertname, + incidents.alert_category, + incidents.notification_type, + incidents.created_at, + incidents.updated_at, + incidents.resolved_at, + incidents.verification_result, + source_ids.recent_evidence_at + FROM source_ids + JOIN incidents ON incidents.incident_id = source_ids.incident_id + WHERE (incidents.project_id = :project_id OR incidents.project_id IS NULL) + ORDER BY source_ids.recent_evidence_at DESC LIMIT :limit """, { diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index c4a39599..a63b9433 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -8,10 +8,12 @@ from types import SimpleNamespace from src.services.awooop_ansible_audit_service import ( build_ansible_decision_audit_payload, build_ansible_truth, + record_ansible_decision_audit, ) from src.services.awooop_ansible_check_mode_service import ( build_ansible_check_mode_claim_input, build_ansible_check_mode_command, + claim_pending_check_modes, detect_ansible_transport_blockers, ) from src.services.awooop_truth_chain_service import ( @@ -26,6 +28,7 @@ from src.services.awooop_truth_chain_service import ( build_automation_quality, build_incident_reconciliation, fetch_truth_chain, + fetch_automation_quality_summary, summarize_automation_quality_records, ) from src.services.drift_repeat_state import ( @@ -68,6 +71,25 @@ def test_fetch_truth_chain_can_match_inbound_provider_event_id() -> None: assert "provider_event_id = :source_id" in source +def test_quality_summary_includes_recent_ansible_operation_incidents() -> None: + source = inspect.getsource(fetch_automation_quality_summary) + + assert "FROM automation_operation_log" in source + assert "input ->> 'incident_id'" in source + assert "JOIN incidents ON incidents.incident_id = source_ids.incident_id" in source + assert "source_ids.recent_evidence_at DESC" in source + + +def test_ansible_audit_writes_incident_id_column_for_truth_chain_join() -> None: + decision_source = inspect.getsource(record_ansible_decision_audit) + claim_source = inspect.getsource(claim_pending_check_modes) + + assert "operation_type, actor, status, incident_id" in decision_source + assert "coalesce(incident_id::text, input ->> 'incident_id')" in decision_source + assert "operation_type, actor, status, incident_id" in claim_source + assert "NULLIF(:incident_id, '')" in claim_source + + def test_fetch_truth_chain_returns_inbound_redacted_envelope_fields() -> None: source = inspect.getsource(fetch_truth_chain)