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