From 497e36ba9df52b1a00d006ac321795e4bf740a3b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 16:18:33 +0800 Subject: [PATCH] fix(awooop): surface ansible apply proof --- .../services/awooop_ansible_audit_service.py | 149 ++++++++++++++++++ .../services/awooop_truth_chain_service.py | 17 ++ .../src/services/platform_operator_service.py | 30 +++- apps/api/src/services/telegram_gateway.py | 73 ++++++++- .../test_awooop_operator_timeline_labels.py | 81 ++++++++++ .../tests/test_awooop_truth_chain_service.py | 18 ++- .../tests/test_telegram_message_templates.py | 92 ++++++++++- apps/web/messages/en.json | 2 +- apps/web/messages/zh-TW.json | 2 +- .../src/components/awooop/status-chain.tsx | 21 ++- 10 files changed, 468 insertions(+), 17 deletions(-) diff --git a/apps/api/src/services/awooop_ansible_audit_service.py b/apps/api/src/services/awooop_ansible_audit_service.py index a2518588f..028dea4fe 100644 --- a/apps/api/src/services/awooop_ansible_audit_service.py +++ b/apps/api/src/services/awooop_ansible_audit_service.py @@ -167,6 +167,56 @@ def _first_present(row: dict[str, Any], keys: tuple[str, ...]) -> Any: return None +def _json_object(row: dict[str, Any], key: str) -> dict[str, Any]: + raw = _get(row, key) + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _json_value(row: dict[str, Any], container_key: str, value_key: str) -> Any: + return _json_object(row, container_key).get(value_key) + + +def _first_present_with_json(row: dict[str, Any], keys: tuple[str, ...], json_keys: tuple[tuple[str, str], ...] = ()) -> Any: + value = _first_present(row, keys) + if value not in (None, ""): + return value + for container_key, value_key in json_keys: + value = _json_value(row, container_key, value_key) + if value not in (None, ""): + return value + return None + + +def _bool_or_none(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if value in (None, ""): + return None + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return None + + +def _int_or_none(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _is_ansible_operation(row: dict[str, Any]) -> bool: operation_type = str(_get(row, "operation_type") or "").lower() if operation_type in ANSIBLE_OPERATION_TYPES: @@ -201,12 +251,31 @@ def _ansible_record(row: dict[str, Any]) -> dict[str, Any]: "operation_type": _get(row, "operation_type"), "status": _get(row, "status"), "actor": _get(row, "actor"), + "catalog_id": _first_present(row, ("input_catalog_id", "output_catalog_id")), "playbook_id": _first_present(row, ("input_playbook_id", "output_playbook_id")), "playbook_path": _first_present( row, ("input_playbook_path", "output_playbook_path", "input_ansible_playbook_path", "output_ansible_playbook_path"), ), + "execution_mode": _first_present(row, ("input_execution_mode", "output_execution_mode")), "check_mode": _first_present(row, ("input_check_mode", "output_check_mode")), + "check_mode_executed": _first_present_with_json( + row, + ("input_check_mode_executed", "output_check_mode_executed", "dry_run_check_mode_executed"), + (("dry_run_result", "check_mode_executed"),), + ), + "apply_enabled": _first_present(row, ("input_apply_enabled", "output_apply_enabled")), + "apply_executed": _first_present_with_json( + row, + ("input_apply_executed", "output_apply_executed", "dry_run_apply_executed"), + (("dry_run_result", "apply_executed"),), + ), + "approval_source": _first_present(row, ("input_approval_source", "output_approval_source")), + "returncode": _first_present_with_json( + row, + ("output_returncode", "input_returncode", "dry_run_returncode"), + (("dry_run_result", "returncode"),), + ), "not_used_reason": _first_present(row, ("input_not_used_reason", "output_not_used_reason")), "dry_run_result": _get(row, "dry_run_result"), "error": _get(row, "error"), @@ -216,6 +285,80 @@ def _ansible_record(row: dict[str, Any]) -> dict[str, Any]: } +def summarize_ansible_execution(records: list[dict[str, Any]]) -> dict[str, Any]: + """Summarize durable Ansible audit rows for Telegram and Operator UI.""" + typed_records = [row for row in records if isinstance(row, dict)] + check_mode_total = 0 + apply_total = 0 + rollback_total = 0 + pending_check_mode_total = 0 + applied_success_total = 0 + + terminal_check_mode_parent_ids = { + str(row.get("parent_op_id")) + for row in typed_records + if str(row.get("operation_type") or "") in { + "ansible_check_mode_executed", + "ansible_execution_skipped", + } + and row.get("parent_op_id") + } + + for row in typed_records: + operation_type = str(row.get("operation_type") or "") + status = str(row.get("status") or "").lower() + if operation_type == "ansible_check_mode_executed" and status != "pending": + check_mode_total += 1 + elif operation_type == "ansible_apply_executed": + apply_total += 1 + if status in {"success", "completed", "executed", "applied"}: + applied_success_total += 1 + elif operation_type == "ansible_rollback_executed": + rollback_total += 1 + elif ( + operation_type == "ansible_candidate_matched" + and str(row.get("op_id")) not in terminal_check_mode_parent_ids + ): + pending_check_mode_total += 1 + + latest_record = typed_records[0] if typed_records else {} + latest_apply = next( + (row for row in typed_records if row.get("operation_type") == "ansible_apply_executed"), + {}, + ) + latest_check = next( + (row for row in typed_records if row.get("operation_type") == "ansible_check_mode_executed"), + {}, + ) + focused = latest_apply or latest_check or latest_record + returncode = _int_or_none(focused.get("returncode")) + approval_source = focused.get("approval_source") + + return { + "check_mode_total": check_mode_total, + "apply_total": apply_total, + "rollback_total": rollback_total, + "pending_check_mode_total": pending_check_mode_total, + "applied_success_total": applied_success_total, + "applied": applied_success_total > 0, + "controlled_apply": bool(latest_apply) and bool(approval_source), + "latest_operation_type": focused.get("operation_type"), + "latest_status": focused.get("status"), + "latest_catalog_id": focused.get("catalog_id"), + "latest_playbook_path": focused.get("playbook_path"), + "latest_execution_mode": focused.get("execution_mode"), + "latest_check_mode": _bool_or_none(focused.get("check_mode")), + "latest_check_mode_executed": _bool_or_none(focused.get("check_mode_executed")), + "latest_apply_enabled": _bool_or_none(focused.get("apply_enabled")), + "latest_apply_executed": _bool_or_none(focused.get("apply_executed")), + "latest_returncode": returncode if returncode is not None else focused.get("returncode"), + "approval_source": approval_source, + "latest_actor": focused.get("actor"), + "latest_op_id": focused.get("op_id"), + "latest_parent_op_id": focused.get("parent_op_id"), + } + + def _flatten_text(value: Any, pieces: list[str], remaining: int = 80) -> int: if remaining <= 0 or value is None: return remaining @@ -292,9 +435,11 @@ def build_ansible_truth( """Build the truth-chain Ansible section from audited facts and catalog hints.""" records = [_ansible_record(row) for row in automation_ops if _is_ansible_operation(row)] + summary = summarize_ansible_execution(records) return { "considered": bool(records), "records": records, + "summary": summary, "audit_contract": { "schema_version": "ansible_executor_audit_v1", "operation_types": sorted(ANSIBLE_OPERATION_TYPES), @@ -304,8 +449,12 @@ def build_ansible_truth( "status", "actor", "input.executor", + "input.catalog_id", + "input.execution_mode", "input.playbook_path", "input.check_mode", + "input.approval_source", + "output.returncode", "output.not_used_reason", "dry_run_result", ], diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 95c4ebf15..82bf3db98 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -1361,6 +1361,13 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ input ->> 'action' AS input_action, input ->> 'executor' AS input_executor, input ->> 'execution_backend' AS input_execution_backend, + input ->> 'catalog_id' AS input_catalog_id, + input ->> 'execution_mode' AS input_execution_mode, + input ->> 'approval_source' AS input_approval_source, + input ->> 'apply_enabled' AS input_apply_enabled, + input ->> 'apply_executed' AS input_apply_executed, + input ->> 'check_mode_executed' AS input_check_mode_executed, + input ->> 'returncode' AS input_returncode, input ->> 'playbook_id' AS input_playbook_id, input ->> 'playbook_path' AS input_playbook_path, input ->> 'ansible_playbook_path' AS input_ansible_playbook_path, @@ -1370,11 +1377,21 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ output ->> 'reason' AS output_reason, output ->> 'executor' AS output_executor, output ->> 'execution_backend' AS output_execution_backend, + output ->> 'catalog_id' AS output_catalog_id, + output ->> 'execution_mode' AS output_execution_mode, + output ->> 'approval_source' AS output_approval_source, + output ->> 'apply_enabled' AS output_apply_enabled, + output ->> 'apply_executed' AS output_apply_executed, + output ->> 'check_mode_executed' AS output_check_mode_executed, + output ->> 'returncode' AS output_returncode, output ->> 'playbook_id' AS output_playbook_id, output ->> 'playbook_path' AS output_playbook_path, output ->> 'ansible_playbook_path' AS output_ansible_playbook_path, output ->> 'check_mode' AS output_check_mode, output ->> 'not_used_reason' AS output_not_used_reason, + dry_run_result ->> 'returncode' AS dry_run_returncode, + dry_run_result ->> 'apply_executed' AS dry_run_apply_executed, + dry_run_result ->> 'check_mode_executed' AS dry_run_check_mode_executed, created_at FROM automation_operation_log WHERE incident_id::text = :incident_id diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 4e666d976..3bb86a35d 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -38,6 +38,7 @@ from src.db.base import get_db_context from src.db.models import IncidentRecord, MCPAuditLog from src.services.audit_sink import write_audit from src.services.awooop_approval_token import issue_approval_token, record_approval +from src.services.awooop_ansible_audit_service import summarize_ansible_execution from src.services.awooop_truth_chain_service import ( _summarize_gateway_mcp, _summarize_mcp, @@ -2922,6 +2923,11 @@ def _status_chain_execution_section(truth_chain: dict[str, Any] | None) -> dict[ if isinstance(candidate_catalog.get("candidates"), list) else [] ) + ansible_summary = ( + ansible.get("summary") + if isinstance(ansible.get("summary"), dict) + else summarize_ansible_execution([row for row in ansible_records if isinstance(row, dict)]) + ) return { "operation_total": len(ops), @@ -2945,10 +2951,26 @@ def _status_chain_execution_section(truth_chain: dict[str, Any] | None) -> dict[ "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"), + "check_mode_total": ansible_summary.get("check_mode_total"), + "apply_total": ansible_summary.get("apply_total"), + "rollback_total": ansible_summary.get("rollback_total"), + "pending_check_mode_total": ansible_summary.get("pending_check_mode_total"), + "applied_success_total": ansible_summary.get("applied_success_total"), + "applied": ansible_summary.get("applied"), + "controlled_apply": ansible_summary.get("controlled_apply"), + "latest_operation_type": ansible_summary.get("latest_operation_type") or latest_ansible.get("operation_type"), + "latest_status": ansible_summary.get("latest_status") or latest_ansible.get("status"), + "latest_catalog_id": ansible_summary.get("latest_catalog_id") or latest_ansible.get("catalog_id"), + "latest_playbook_path": ansible_summary.get("latest_playbook_path") or latest_ansible.get("playbook_path"), + "latest_execution_mode": ansible_summary.get("latest_execution_mode") or latest_ansible.get("execution_mode"), + "latest_check_mode": ( + ansible_summary.get("latest_check_mode") + if ansible_summary.get("latest_check_mode") is not None + else latest_ansible.get("check_mode") + ), + "latest_returncode": ansible_summary.get("latest_returncode"), + "latest_apply_executed": ansible_summary.get("latest_apply_executed"), + "approval_source": ansible_summary.get("approval_source"), "candidate_playbooks": [ { "catalog_id": item.get("catalog_id"), diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index bb6e55573..8f97323cc 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -39,6 +39,7 @@ from opentelemetry import trace from src.core.config import settings from src.core.redis_client import get_redis +from src.services.awooop_ansible_audit_service import summarize_ansible_execution from src.services.operator_outcome import build_operator_outcome from src.services.security_interceptor import ( NonceReplayError, @@ -458,6 +459,20 @@ def _format_awooop_status_chain_lines( if outcome: needs_human = bool(needs_human or outcome.get("needs_human")) next_step = str(outcome.get("next_action") or next_step) + execution = _callback_reply_awooop_execution_snapshot(truth_chain) + ansible = execution.get("ansible") if isinstance(execution.get("ansible"), dict) else {} + ansible_considered = bool(ansible.get("considered")) + ansible_check_total = _safe_int(ansible.get("check_mode_total")) + ansible_apply_total = _safe_int(ansible.get("apply_total")) + ansible_latest_status = str(ansible.get("latest_status") or "--") + ansible_latest_operation = str(ansible.get("latest_operation_type") or "--") + ansible_latest_rc = str(ansible.get("latest_returncode") if ansible.get("latest_returncode") not in (None, "") else "--") + ansible_playbook = str( + ansible.get("latest_catalog_id") + or ansible.get("latest_playbook_path") + or "--" + ) + ansible_approval = str(ansible.get("approval_source") or "--") lines = [ "", @@ -481,6 +496,25 @@ def _format_awooop_status_chain_lines( f"MCP {gateway_total} / " f"KM {km_entries}" ), + ] + if ansible_considered or ansible_check_total or ansible_apply_total: + lines.extend([ + ( + "Ansible: " + f"check {ansible_check_total} / " + f"apply {ansible_apply_total} | " + f"{html.escape(ansible_latest_operation)}/" + f"{html.escape(ansible_latest_status)} / " + f"rc {html.escape(ansible_latest_rc)}" + ), + ( + "PlayBook: " + f"{html.escape(ansible_playbook)} | " + f"approval {html.escape(ansible_approval)}" + ), + ]) + + lines.extend([ ( "ADR-100: " f"{html.escape(remediation_state)} " @@ -494,7 +528,7 @@ def _format_awooop_status_chain_lines( f"人工: {'yes' if needs_human else 'no'}" ), f"下一步: {html.escape(next_step)}", - ] + ]) outcome_lines = _format_operator_outcome_lines(outcome) if outcome_lines: lines.extend(["", *outcome_lines]) @@ -575,7 +609,8 @@ def _format_awooop_agent_evidence_lines( selected_playbook = str(playbook_ids[0] or "") else: selected_playbook = str( - ansible.get("latest_playbook_path") + ansible.get("latest_catalog_id") + or ansible.get("latest_playbook_path") or candidate_playbook.get("playbook_path") or candidate_playbook.get("catalog_id") or "--" @@ -650,7 +685,10 @@ def _format_awooop_agent_evidence_lines( f"{html.escape(selected_playbook)} | " f"ansible {html.escape(_bool_code(ansible.get('considered'), unknown_when_none=True))} / " f"candidates {_safe_int(ansible.get('candidate_count'))} / " - f"check-mode {html.escape(_bool_code(ansible.get('latest_check_mode'), unknown_when_none=True))} / " + f"check {_safe_int(ansible.get('check_mode_total'))} / " + f"apply {_safe_int(ansible.get('apply_total'))} / " + f"rc {html.escape(str(ansible.get('latest_returncode') if ansible.get('latest_returncode') not in (None, '') else '--'))} / " + f"approval {html.escape(str(ansible.get('approval_source') or '--'))} / " f"status {html.escape(str(ansible.get('latest_status') or '--'))}" ), ( @@ -1342,6 +1380,11 @@ def _callback_reply_awooop_execution_snapshot( if isinstance(candidate_catalog.get("candidates"), list) else [] ) + ansible_summary = ( + ansible.get("summary") + if isinstance(ansible.get("summary"), dict) + else summarize_ansible_execution([row for row in ansible_records if isinstance(row, dict)]) + ) return { "operation_total": len(ops), @@ -1364,10 +1407,26 @@ def _callback_reply_awooop_execution_snapshot( "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"), + "check_mode_total": ansible_summary.get("check_mode_total"), + "apply_total": ansible_summary.get("apply_total"), + "rollback_total": ansible_summary.get("rollback_total"), + "pending_check_mode_total": ansible_summary.get("pending_check_mode_total"), + "applied_success_total": ansible_summary.get("applied_success_total"), + "applied": ansible_summary.get("applied"), + "controlled_apply": ansible_summary.get("controlled_apply"), + "latest_operation_type": ansible_summary.get("latest_operation_type") or latest_ansible.get("operation_type"), + "latest_status": ansible_summary.get("latest_status") or latest_ansible.get("status"), + "latest_catalog_id": ansible_summary.get("latest_catalog_id") or latest_ansible.get("catalog_id"), + "latest_playbook_path": ansible_summary.get("latest_playbook_path") or latest_ansible.get("playbook_path"), + "latest_execution_mode": ansible_summary.get("latest_execution_mode") or latest_ansible.get("execution_mode"), + "latest_check_mode": ( + ansible_summary.get("latest_check_mode") + if ansible_summary.get("latest_check_mode") is not None + else latest_ansible.get("check_mode") + ), + "latest_returncode": ansible_summary.get("latest_returncode"), + "latest_apply_executed": ansible_summary.get("latest_apply_executed"), + "approval_source": ansible_summary.get("approval_source"), "candidate_playbooks": [ { "catalog_id": item.get("catalog_id"), diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 38305de69..bcbce1937 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1374,12 +1374,93 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["execution"]["playbook_ids"] == ["pb-host-restart"] assert chain["execution"]["ansible"]["considered"] is True assert chain["execution"]["ansible"]["candidate_count"] == 1 + assert chain["execution"]["ansible"]["check_mode_total"] == 1 + assert chain["execution"]["ansible"]["apply_total"] == 0 assert chain["source_refs"]["inbound_total"] == 1 assert chain["source_refs"]["outbound_total"] == 1 assert chain["source_refs"]["refs"]["sentry_issue_ids"] == ["SENTRY-1"] assert chain["source_refs"]["refs"]["signoz_alerts"] == ["signoz:abc"] +def test_awooop_status_chain_surfaces_controlled_ansible_apply_proof() -> None: + chain = _build_awooop_status_chain( + incident_ids=["INC-20260531-D6A3C4"], + source_id="INC-20260531-D6A3C4", + truth_chain={ + "truth_status": { + "current_stage": "execution_succeeded", + "stage_status": "success", + "needs_human": False, + "blockers": [], + }, + "automation_quality": { + "verdict": "execution_succeeded", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 2, + "effective_execution_records": 1, + "verification_result": "healthy", + "mcp_gateway_total": 2, + "knowledge_entries": 1, + }, + "blockers": [], + }, + "execution": { + "automation_operation_log": [ + { + "operation_type": "ansible_apply_executed", + "status": "success", + "actor": "platform_operator", + "input_executor": "ansible", + "input_catalog_id": "ansible:188-momo-backup-user", + "input_playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + } + ], + "ansible": { + "considered": True, + "records": [ + { + "operation_type": "ansible_apply_executed", + "status": "success", + "actor": "platform_operator", + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "execution_mode": "apply", + "check_mode": False, + "apply_executed": True, + "approval_source": "user_chat_approved_continue", + "returncode": 0, + }, + { + "operation_type": "ansible_check_mode_executed", + "status": "success", + "actor": "ansible_check_mode_worker", + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "execution_mode": "check_mode", + "check_mode": True, + "apply_executed": False, + "returncode": 0, + }, + ], + "candidate_catalog": {"candidates": []}, + }, + }, + }, + remediation_history={"total": 0}, + ) + + ansible = chain["execution"]["ansible"] + assert ansible["check_mode_total"] == 1 + assert ansible["apply_total"] == 1 + assert ansible["applied"] is True + assert ansible["controlled_apply"] is True + assert ansible["latest_catalog_id"] == "ansible:188-momo-backup-user" + assert ansible["latest_execution_mode"] == "apply" + assert ansible["latest_returncode"] == 0 + assert ansible["approval_source"] == "user_chat_approved_continue" + + def test_awooop_status_chain_includes_source_provider_correlation() -> None: chain = _build_awooop_status_chain( incident_ids=["INC-20260520-4D1124"], diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 3897f7989..dd752a927 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -988,9 +988,16 @@ def test_ansible_truth_surfaces_audited_check_mode_record() -> None: "operation_type": "ansible_check_mode_executed", "status": "dry_run", "actor": "platform_operator", + "input_catalog_id": "ansible:188-momo-backup-user", + "input_execution_mode": "check_mode", "input_playbook_path": "infra/ansible/playbooks/188-ai-web.yml", "input_check_mode": "true", - "dry_run_result": {"changed": 1}, + "dry_run_result": { + "changed": 1, + "check_mode_executed": True, + "apply_executed": False, + "returncode": 0, + }, "tags": ["ansible", "check_mode"], "created_at": "2026-05-12T22:00:00+08:00", } @@ -1003,7 +1010,14 @@ def test_ansible_truth_surfaces_audited_check_mode_record() -> None: assert truth["not_used_reason"] is None assert truth["records"][0]["playbook_path"] == "infra/ansible/playbooks/188-ai-web.yml" assert truth["records"][0]["check_mode"] == "true" - assert truth["records"][0]["dry_run_result"] == {"changed": 1} + assert truth["records"][0]["catalog_id"] == "ansible:188-momo-backup-user" + assert truth["records"][0]["execution_mode"] == "check_mode" + assert truth["records"][0]["returncode"] == 0 + assert truth["records"][0]["dry_run_result"]["changed"] == 1 + assert truth["summary"]["check_mode_total"] == 1 + assert truth["summary"]["apply_total"] == 0 + assert truth["summary"]["latest_catalog_id"] == "ansible:188-momo-backup-user" + assert truth["summary"]["latest_returncode"] == 0 assert "ansible_check_mode_executed" in truth["audit_contract"]["operation_types"] assert truth["candidate_catalog"]["decision_effect"] == "none" assert truth["candidate_catalog"]["candidates"][0]["catalog_id"] == "ansible:188-momo-backup-user" diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 7bb1c3f30..607906543 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -329,12 +329,102 @@ def test_awooop_agent_evidence_lines_show_mcp_source_execution_playbook_km() -> assert "PlayBook / Ansible" in joined assert "infra/ansible/playbooks/188-ai-web.yml" in joined assert "ansible yes" in joined - assert "check-mode yes" in joined + assert "check 0" in joined + assert "apply 0" in joined + assert "rc --" in joined assert "KM / Learning" in joined assert "KM 1" in joined assert "verify degraded" in joined +def test_awooop_status_chain_lines_show_ansible_apply_proof() -> None: + """詳情/歷史必須能看出 Ansible 是否真的 apply、由誰批准、returncode 幾。""" + truth_chain = { + "truth_status": { + "current_stage": "execution_succeeded", + "stage_status": "success", + "needs_human": False, + "blockers": [], + }, + "automation_quality": { + "verdict": "execution_succeeded", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 2, + "effective_execution_records": 1, + "verification_result": "healthy", + "mcp_gateway_total": 2, + "knowledge_entries": 1, + }, + "blockers": [], + }, + "execution": { + "automation_operation_log": [ + { + "operation_type": "ansible_apply_executed", + "status": "success", + "actor": "platform_operator", + "input_executor": "ansible", + "input_action": "apply_playbook", + "input_catalog_id": "ansible:188-momo-backup-user", + "input_playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + } + ], + "ansible": { + "considered": True, + "records": [ + { + "operation_type": "ansible_apply_executed", + "status": "success", + "actor": "platform_operator", + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "execution_mode": "apply", + "check_mode": False, + "apply_executed": True, + "approval_source": "user_chat_approved_continue", + "returncode": 0, + }, + { + "operation_type": "ansible_check_mode_executed", + "status": "success", + "actor": "ansible_check_mode_worker", + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "execution_mode": "check_mode", + "check_mode": True, + "apply_executed": False, + "returncode": 0, + }, + ], + "candidate_catalog": {"candidates": []}, + }, + }, + } + + lines = telegram_gateway_module._format_awooop_status_chain_lines( + truth_chain=truth_chain, + remediation_history={"total": 0}, + ) + joined = "\n".join(lines) + assert "Ansible: check 1 / apply 1" in joined + assert "ansible_apply_executed/success" in joined + assert "rc 0" in joined + assert "PlayBook: ansible:188-momo-backup-user" in joined + assert "approval user_chat_approved_continue" in joined + + snapshot = telegram_gateway_module._callback_reply_awooop_execution_snapshot(truth_chain) + ansible = snapshot["ansible"] + assert ansible["check_mode_total"] == 1 + assert ansible["apply_total"] == 1 + assert ansible["applied"] is True + assert ansible["controlled_apply"] is True + assert ansible["latest_catalog_id"] == "ansible:188-momo-backup-user" + assert ansible["latest_execution_mode"] == "apply" + assert ansible["latest_returncode"] == 0 + assert ansible["approval_source"] == "user_chat_approved_continue" + + def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None: """Callback evidence 要保存當下 AwoooP 狀態鏈,不只保存 live query 結果。""" snapshot = telegram_gateway_module._callback_reply_awooop_status_chain_snapshot( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d461d892a..bacd31cfc 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3674,7 +3674,7 @@ "executionDetail": "operation={operation}; action={action}; ops={ops}", "playbook": "PlayBook / Ansible", "playbookValue": "{playbook}", - "playbookDetail": "ansible={ansible}; candidates={candidates}; check-mode={checkMode}; status={status}", + "playbookDetail": "ansible={ansible}; candidates={candidates}; check/apply={check}/{apply}; mode={mode}; rc={rc}; approval={approval}; catalog={catalog}; status={status}", "learning": "KM / Learning", "learningValue": "KM {km}; AutoRepair {autoRepair}; Ops {ops}", "learningDetail": "verification={verification}; next={nextStep}" diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c03cade00..930f8a7a5 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -3675,7 +3675,7 @@ "executionDetail": "operation={operation}; action={action}; ops={ops}", "playbook": "PlayBook / Ansible", "playbookValue": "{playbook}", - "playbookDetail": "ansible={ansible}; candidates={candidates}; check-mode={checkMode}; status={status}", + "playbookDetail": "ansible={ansible}; candidates={candidates}; check/apply={check}/{apply}; mode={mode}; rc={rc}; approval={approval}; catalog={catalog}; status={status}", "learning": "KM / Learning", "learningValue": "KM {km}; AutoRepair {autoRepair}; Ops {ops}", "learningDetail": "verification={verification}; next={nextStep}" diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index 8493204ce..7bfb1b18d 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -92,10 +92,22 @@ export interface AwoooPStatusChain { record_total?: number | null; candidate_count?: number | null; not_used_reason?: string | null; + check_mode_total?: number | null; + apply_total?: number | null; + rollback_total?: number | null; + pending_check_mode_total?: number | null; + applied_success_total?: number | null; + applied?: boolean | null; + controlled_apply?: boolean | null; latest_operation_type?: string | null; latest_status?: string | null; + latest_catalog_id?: string | null; latest_playbook_path?: string | null; + latest_execution_mode?: string | null; latest_check_mode?: string | null | boolean; + latest_returncode?: string | number | null; + latest_apply_executed?: boolean | null; + approval_source?: string | null; candidate_playbooks?: Array<{ catalog_id?: string | null; playbook_path?: string | null; @@ -341,6 +353,7 @@ export function AwoooPStatusChainPanel({ const candidatePlaybook = ansible.candidate_playbooks?.[0]; const selectedPlaybook = execution.playbook_paths?.[0] ?? execution.playbook_ids?.[0] + ?? ansible.latest_catalog_id ?? ansible.latest_playbook_path ?? candidatePlaybook?.playbook_path ?? candidatePlaybook?.catalog_id @@ -408,13 +421,19 @@ export function AwoooPStatusChainPanel({ key: "playbook", Icon: Wrench, tone: (ansible.considered || (ansible.candidate_count ?? 0) > 0 - ? (ansible.latest_status === "applied" ? "success" : "warning") + ? (ansible.applied || (ansible.apply_total ?? 0) > 0 ? "success" : "warning") : "neutral") as SourceFlowTone, label: t("toolchain.playbook"), value: selectedPlaybook, detail: t("toolchain.playbookDetail", { ansible: boolValue(ansible.considered, emptyLabel), candidates: ansible.candidate_count ?? 0, + check: ansible.check_mode_total ?? 0, + apply: ansible.apply_total ?? 0, + mode: valueOrEmpty(ansible.latest_execution_mode, emptyLabel), + rc: valueOrEmpty(ansible.latest_returncode, emptyLabel), + approval: valueOrEmpty(ansible.approval_source, emptyLabel), + catalog: valueOrEmpty(ansible.latest_catalog_id, emptyLabel), checkMode: valueOrEmpty(ansible.latest_check_mode, emptyLabel), status: ansible.latest_status ?? emptyLabel, }),