From a99dccfc73cf5a763a2c42434fa09d70559df603 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 13 May 2026 11:30:08 +0800 Subject: [PATCH] feat(awooop): summarize gateway usage in truth chain --- .../services/awooop_truth_chain_service.py | 128 +++++++++++++++++- .../tests/test_awooop_truth_chain_service.py | 33 +++++ docs/LOGBOOK.md | 34 +++++ 3 files changed, 193 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 0b223195..413dce68 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -346,6 +346,122 @@ def _summarize_mcp(rows: list[dict[str, Any]]) -> dict[str, Any]: } +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).lower() == "true" + + +def _counter_bucket( + buckets: dict[str, dict[str, Any]], + key: str, + *, + label: str, +) -> dict[str, Any]: + return buckets.setdefault( + key, + { + label: key, + "total": 0, + "success": 0, + "failed": 0, + "blocked": 0, + }, + ) + + +def _summarize_gateway_mcp(rows: list[dict[str, Any]]) -> dict[str, Any]: + by_agent: dict[str, dict[str, Any]] = {} + by_tool: dict[str, dict[str, Any]] = {} + by_scope: dict[str, dict[str, Any]] = {} + success_count = 0 + failed_count = 0 + blocked_count = 0 + first_class_count = 0 + bridge_count = 0 + policy_enforced_count = 0 + approval_executor_count = 0 + + for row in rows: + gate_result = row.get("gate_result") if isinstance(row.get("gate_result"), dict) else {} + gateway_path = str(gate_result.get("gateway_path") or "") + schema_version = str(gate_result.get("schema_version") or "") + policy_enforced = _as_bool(gate_result.get("policy_enforced")) + required_scope = str(gate_result.get("required_scope") or "unknown") + status = str(row.get("result_status") or "unknown").lower() + is_blocked = status == "blocked" or row.get("block_gate") is not None + is_success = status == "success" + is_failed = status == "failed" + agent_id = str(row.get("agent_id") or "unknown") + tool_name = str(row.get("tool_name") or "unknown") + + if gateway_path == "awooop_mcp_gateway" and policy_enforced: + first_class_count += 1 + if schema_version == "legacy_mcp_bridge_v1" or policy_enforced is False: + bridge_count += 1 + if policy_enforced: + policy_enforced_count += 1 + if agent_id == "approval_executor": + approval_executor_count += 1 + if is_success: + success_count += 1 + if is_failed: + failed_count += 1 + if is_blocked: + blocked_count += 1 + + for bucket in ( + _counter_bucket(by_agent, agent_id, label="agent_id"), + _counter_bucket(by_tool, tool_name, label="tool_name"), + _counter_bucket(by_scope, required_scope, label="required_scope"), + ): + bucket["total"] += 1 + if is_success: + bucket["success"] += 1 + if is_failed: + bucket["failed"] += 1 + if is_blocked: + bucket["blocked"] += 1 + + blockers: list[str] = [] + if blocked_count: + stage = "gateway_blocked" + stage_status = "blocked" + blockers.append("mcp_gateway_blocked") + elif first_class_count and failed_count: + stage = "provider_failed_after_gateway" + stage_status = "failed" + blockers.append("provider_failed_after_gateway") + elif success_count: + stage = "gateway_execution_succeeded" + stage_status = "success" + elif bridge_count and not first_class_count: + stage = "legacy_bridge_only" + stage_status = "observed" + blockers.append("legacy_bridge_not_policy_enforced") + else: + stage = "no_gateway_records" + stage_status = "missing" + + return { + "total": len(rows), + "success": success_count, + "failed": failed_count, + "blocked": blocked_count, + "first_class_total": first_class_count, + "legacy_bridge_total": bridge_count, + "policy_enforced_total": policy_enforced_count, + "approval_executor_total": approval_executor_count, + "stage": stage, + "stage_status": stage_status, + "needs_human": bool(blocked_count or (first_class_count and failed_count)), + "blockers": blockers, + "by_agent": list(by_agent.values()), + "by_tool": list(by_tool.values()), + "by_scope": list(by_scope.values()), + } + + async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[str, Any]: """Return a read-only truth chain for an incident, drift report, or run id.""" async with get_db_context(project_id) as db: @@ -683,6 +799,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ source_type = _source_type(source_id, incident, drift) legacy_mcp_summary = _summarize_mcp(legacy_mcp_rows) + gateway_mcp_summary = _summarize_gateway_mcp(gateway_mcp_rows) truth_status = _truth_status( incident=incident, approvals=approvals, @@ -694,6 +811,13 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ legacy_mcp_total=legacy_mcp_summary["total"], outbound_visible_total=len(outbound_rows), ) + if incident is None and drift is None and not runs and gateway_mcp_rows: + truth_status = { + "current_stage": gateway_mcp_summary["stage"], + "stage_status": gateway_mcp_summary["stage_status"], + "needs_human": gateway_mcp_summary["needs_human"], + "blockers": gateway_mcp_summary["blockers"], + } reconciliation = build_incident_reconciliation( incident=incident, approvals=approvals, @@ -713,7 +837,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ "project_id": project_id, "source_id": source_id, "source_type": source_type, - "found": incident is not None or drift is not None or bool(runs), + "found": incident is not None or drift is not None or bool(runs) or bool(gateway_mcp_rows), "truth_status": truth_status, "linked_ids": { "incident_id": incident.get("incident_id") if incident else None, @@ -734,7 +858,7 @@ async def fetch_truth_chain(source_id: str, project_id: str = "awoooi") -> dict[ }, "mcp": { "awooop_gateway": { - "total": len(gateway_mcp_rows), + **gateway_mcp_summary, "records": gateway_mcp_rows, }, "legacy": { diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index e602c5b0..fcb262ca 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -10,6 +10,7 @@ from src.services.awooop_ansible_audit_service import ( from src.services.awooop_truth_chain_service import ( build_incident_reconciliation, _clean_row, + _summarize_gateway_mcp, _truth_status, ) from src.services.drift_repeat_state import ( @@ -78,6 +79,38 @@ def test_truth_status_marks_repeated_pending_drift_as_human_needed() -> None: assert "drift_ai_confidence_zero" in status["blockers"] +def test_gateway_summary_surfaces_first_class_approval_execution() -> None: + summary = _summarize_gateway_mcp([ + { + "agent_id": "approval_executor", + "tool_name": "ssh_docker_restart", + "result_status": "failed", + "block_gate": None, + "gate_result": { + "schema_version": "awooop_mcp_gateway_audit_v1", + "gateway_path": "awooop_mcp_gateway", + "policy_enforced": True, + "required_scope": "write", + "is_shadow": False, + "gate5_approval": True, + }, + } + ]) + + assert summary["total"] == 1 + assert summary["first_class_total"] == 1 + assert summary["legacy_bridge_total"] == 0 + assert summary["policy_enforced_total"] == 1 + assert summary["approval_executor_total"] == 1 + assert summary["stage"] == "provider_failed_after_gateway" + assert summary["stage_status"] == "failed" + assert summary["needs_human"] is True + assert summary["by_agent"][0]["agent_id"] == "approval_executor" + assert summary["by_tool"][0]["tool_name"] == "ssh_docker_restart" + assert summary["by_scope"][0]["required_scope"] == "write" + assert summary["by_scope"][0]["failed"] == 1 + + def _drift_item( *, resource_name: str = "awoooi-api", diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index e3624dd1..884e0f89 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -7104,3 +7104,37 @@ provider_error=Host '192.0.2.1' not in SSH_MCP_ALLOWED_HOSTS - T9 已完成:已批准 SSH execution 會進 first-class `McpGateway`,不再是 `legacy_direct_provider`。 - smoke 使用 `192.0.2.1` 保留位址,故 provider failure 是預期安全結果;重點是五閘門與 audit 已真實通過到 provider 前一層。 - 目前整體進度更新:約 65%。 + +### 2026-05-13 — AwoooP truth-chain T10:MCP Gateway 使用狀態摘要(local green) + +**目的**: + +- 讓 Operator 不只看到 Gateway raw records,也能直接判斷「是否真的經過 AwoooP MCP Gateway」、「是不是 legacy bridge」、「哪個 agent/tool/scope」、「卡在 gate 還是 provider」。 +- 對應 Telegram 告警看不出 AI 自動化流程進度的問題,truth-chain 要提供可查、可聚合的狀態面。 + +**變更**: + +- `awooop_truth_chain_service.py` 新增 `_summarize_gateway_mcp()`。 +- `mcp.awooop_gateway` 現在包含: + - `first_class_total` + - `legacy_bridge_total` + - `policy_enforced_total` + - `approval_executor_total` + - `stage` / `stage_status` / `needs_human` / `blockers` + - `by_agent` / `by_tool` / `by_scope` +- 只用 Gateway trace id 查詢時,`found=true`,並以 Gateway summary 推導 truth status。 + +**local verification**: + +```text +python -m pytest tests/test_awooop_truth_chain_service.py tests/test_platform_router_order.py tests/test_awooop_operator_auth.py -q +19 passed + +python -m ruff check --select F821 src/services/awooop_truth_chain_service.py tests/test_awooop_truth_chain_service.py +All checks passed + +python -m py_compile src/services/awooop_truth_chain_service.py tests/test_awooop_truth_chain_service.py +OK +``` + +**目前整體進度**:約 66%。