feat(awooop): surface legacy mcp evidence in run detail
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m10s
CD Pipeline / build-and-deploy (push) Successful in 4m7s
CD Pipeline / post-deploy-checks (push) Successful in 1m57s

This commit is contained in:
Your Name
2026-05-18 09:16:59 +08:00
parent bc701b8fd3
commit 902593f775
6 changed files with 245 additions and 6 deletions

View File

@@ -29,9 +29,13 @@ from src.db.awooop_models import (
AwoooPRunStepJournal,
)
from src.db.base import get_db_context
from src.db.models import MCPAuditLog
from src.services.audit_sink import write_audit
from src.services.awooop_truth_chain_service import _summarize_gateway_mcp
from src.services.awooop_approval_token import issue_approval_token, record_approval
from src.services.awooop_truth_chain_service import (
_summarize_gateway_mcp,
_summarize_mcp,
)
from src.services.run_state_machine import transition
logger = structlog.get_logger(__name__)
@@ -458,6 +462,24 @@ def _remediation_timeline_summary(item: dict[str, Any]) -> str:
)[:500]
def _legacy_mcp_timeline_status(record: dict[str, Any]) -> str:
if record.get("success") is True:
return "success"
if record.get("success") is False:
return "failed"
return "warning"
def _legacy_mcp_timeline_summary(record: dict[str, Any]) -> str:
return (
f"incident={record.get('incident_id') or '--'} "
f"agent={record.get('agent_role') or '--'} "
f"node={record.get('flywheel_node') or '--'} "
f"duration_ms={record.get('duration_ms') if record.get('duration_ms') is not None else '--'} "
f"error={record.get('error_message') or '--'}"
)[:500]
def _run_remediation_list_summary(
*,
run: AwoooPRunState,
@@ -706,6 +728,60 @@ async def _fetch_run_remediation_history(
}
def _legacy_mcp_record(row: MCPAuditLog) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"flywheel_node": row.flywheel_node,
"mcp_server": row.mcp_server,
"tool_name": row.tool_name,
"duration_ms": row.duration_ms,
"success": row.success,
"error_message": row.error_message,
"incident_id": row.incident_id,
"agent_role": row.agent_role,
"created_at": row.created_at,
}
async def _fetch_run_legacy_mcp_history(
incident_ids: list[str],
*,
limit: int = _MAX_TIMELINE_ITEMS,
) -> dict[str, Any]:
"""Fetch legacy/self-built MCP audit rows linked through incident ids."""
if not incident_ids:
return {
"schema_version": "awooop_run_legacy_mcp_evidence_v1",
"source": "mcp_audit_log",
"incident_ids": [],
"total": 0,
"limit": limit,
"records": [],
"summary": _summarize_mcp([]),
}
async with get_db_context("awoooi") as db:
result = await db.execute(
select(MCPAuditLog)
.where(MCPAuditLog.incident_id.in_(incident_ids))
.order_by(MCPAuditLog.created_at.desc())
.limit(limit)
)
rows = list(result.scalars().all())
records = [_legacy_mcp_record(row) for row in rows]
return {
"schema_version": "awooop_run_legacy_mcp_evidence_v1",
"source": "mcp_audit_log",
"incident_ids": incident_ids,
"total": len(records),
"limit": limit,
"records": records,
"summary": _summarize_mcp(records),
}
async def get_run_detail(
run_id: str,
project_id: str | None = None,
@@ -865,6 +941,7 @@ async def get_run_detail(
inbound_events=inbound_events,
outbound_messages=outbound_messages,
)
legacy_mcp_history = await _fetch_run_legacy_mcp_history(incident_ids)
remediation_history = await _fetch_run_remediation_history(incident_ids)
timeline: list[dict[str, Any]] = [
@@ -940,6 +1017,32 @@ async def get_run_detail(
},
)
)
for record in legacy_mcp_history.get("records", []):
if not isinstance(record, dict):
continue
tool_route = "/".join(
part
for part in (
str(record.get("mcp_server") or ""),
str(record.get("tool_name") or ""),
)
if part
) or "unknown"
timeline.append(
_timeline_item(
ts=record.get("created_at"),
kind="mcp",
title=f"Legacy MCP: {tool_route}",
status=_legacy_mcp_timeline_status(record),
summary=_legacy_mcp_timeline_summary(record),
metadata={
"incident_id": record.get("incident_id"),
"agent_role": record.get("agent_role"),
"flywheel_node": record.get("flywheel_node"),
"history_source": "mcp_audit_log",
},
)
)
for item in remediation_history.get("items", []):
if not isinstance(item, dict):
continue
@@ -1002,6 +1105,7 @@ async def get_run_detail(
"outbound_messages": outbound_items,
"mcp_calls": mcp_items,
"mcp_gateway": mcp_gateway_summary,
"mcp_legacy": legacy_mcp_history,
"remediation_history": remediation_history,
"timeline": timeline,
"counts": {
@@ -1009,6 +1113,7 @@ async def get_run_detail(
"inbound_events": len(inbound_items),
"outbound_messages": len(outbound_items),
"mcp_calls": len(mcp_items),
"legacy_mcp_calls": legacy_mcp_history.get("total", 0),
"remediation_history": remediation_history.get("total", 0),
"timeline": len(timeline),
},

View File

@@ -3,12 +3,14 @@ from types import SimpleNamespace
from src.services.platform_operator_service import (
_collect_run_incident_ids,
_legacy_mcp_timeline_status,
_legacy_mcp_timeline_summary,
_list_filter_context_limit,
_outbound_timeline_title,
_run_remediation_list_summary,
_remediation_summary_matches_incident_id,
_remediation_summary_matches_status,
_remediation_timeline_summary,
_run_remediation_list_summary,
_timeline_sort_key,
)
@@ -109,6 +111,30 @@ def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None:
assert "writes_auto_repair=False" in summary
def test_legacy_mcp_timeline_summary_surfaces_tool_context() -> None:
record = {
"incident_id": "INC-20260514-F85F21",
"agent_role": "pre_decision_investigator",
"flywheel_node": "investigator",
"duration_ms": 127,
"success": True,
"error_message": None,
}
assert _legacy_mcp_timeline_status(record) == "success"
summary = _legacy_mcp_timeline_summary(record)
assert "incident=INC-20260514-F85F21" in summary
assert "agent=pre_decision_investigator" in summary
assert "node=investigator" in summary
assert "duration_ms=127" in summary
def test_legacy_mcp_timeline_status_marks_failed_and_unknown() -> None:
assert _legacy_mcp_timeline_status({"success": False}) == "failed"
assert _legacy_mcp_timeline_status({"success": None}) == "warning"
def test_run_remediation_list_summary_marks_read_only_dry_run() -> None:
run = SimpleNamespace(state="waiting_approval")

View File

@@ -1897,6 +1897,13 @@
"tool": "Tool",
"scope": "Scope",
"blockers": "Blockers",
"legacy": {
"only": "Legacy MCP only",
"total": "Legacy MCP",
"success": "Legacy success",
"failed": "Legacy failed",
"topTool": "Legacy tool"
},
"metrics": {
"firstClass": "First-class",
"policy": "Policy enforced",

View File

@@ -1898,6 +1898,13 @@
"tool": "Tool",
"scope": "Scope",
"blockers": "卡點",
"legacy": {
"only": "Legacy MCP only",
"total": "Legacy MCP",
"success": "Legacy 成功",
"failed": "Legacy 失敗",
"topTool": "Legacy Tool"
},
"metrics": {
"firstClass": "First-class",
"policy": "Policy enforced",

View File

@@ -128,6 +128,30 @@ interface McpGatewaySummary {
by_scope: McpGatewayBucket[];
}
interface LegacyMcpToolBucket {
mcp_server?: string | null;
tool_name?: string | null;
success: number;
failed: number;
last_error?: string | null;
}
interface LegacyMcpSummary {
total: number;
success: number;
failed: number;
by_tool: LegacyMcpToolBucket[];
}
interface LegacyMcpEvidence {
schema_version?: string;
source?: string;
incident_ids?: string[];
total?: number;
limit?: number;
summary?: LegacyMcpSummary;
}
interface RemediationHistoryItem {
id?: string;
incident_id?: string | null;
@@ -169,12 +193,14 @@ interface RunDetailResponse {
run: RunDetail;
timeline: TimelineItem[];
mcp_gateway?: McpGatewaySummary;
mcp_legacy?: LegacyMcpEvidence;
remediation_history?: RunRemediationHistory;
counts: {
steps: number;
inbound_events: number;
outbound_messages: number;
mcp_calls: number;
legacy_mcp_calls?: number;
remediation_history?: number;
timeline: number;
};
@@ -336,7 +362,7 @@ function RunActionPanel({
const evidence = [
{ label: t("evidence.inbound"), value: counts?.inbound_events ?? 0 },
{ label: t("evidence.outbound"), value: counts?.outbound_messages ?? 0 },
{ label: t("evidence.mcp"), value: counts?.mcp_calls ?? 0 },
{ label: t("evidence.mcp"), value: (counts?.mcp_calls ?? 0) + (counts?.legacy_mcp_calls ?? 0) },
{ label: t("evidence.steps"), value: counts?.steps ?? 0 },
];
@@ -549,17 +575,31 @@ function topBucket(
return [...buckets].sort((a, b) => b.total - a.total)[0]?.[field] ?? null;
}
function topLegacyTool(summary?: LegacyMcpSummary | null) {
const top = summary?.by_tool
? [...summary.by_tool].sort((a, b) => (b.success + b.failed) - (a.success + a.failed))[0]
: null;
if (!top) return null;
return [top.mcp_server, top.tool_name].filter(Boolean).join("/") || null;
}
function McpGatewayPanel({
summary,
legacy,
emptyLabel,
statusLabel,
}: {
summary?: McpGatewaySummary;
legacy?: LegacyMcpEvidence;
emptyLabel: string;
statusLabel: (status: string) => string;
}) {
const t = useTranslations("awooop.runDetail.gateway");
const hasRecords = Boolean(summary && summary.total > 0);
const legacyTotal = legacy?.total ?? 0;
const hasAnyRecords = hasRecords || legacyTotal > 0;
const legacySummary = legacy?.summary;
const legacyTool = topLegacyTool(legacySummary);
const toneClass = summary?.needs_human
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: summary?.stage_status === "success"
@@ -582,8 +622,8 @@ function McpGatewayPanel({
<ShieldCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
</div>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", hasRecords ? toneClass : statusClass("pending"))}>
{hasRecords ? statusLabel(summary?.stage_status ?? "pending") : t("emptyState")}
<span className={cn("border px-2 py-0.5 text-xs font-semibold", hasAnyRecords ? toneClass : statusClass("pending"))}>
{hasRecords ? statusLabel(summary?.stage_status ?? "pending") : legacyTotal > 0 ? t("legacy.only") : t("emptyState")}
</span>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
@@ -616,6 +656,21 @@ function McpGatewayPanel({
<span className="font-mono text-xs">{summary.blockers.slice(0, 3).join(", ")}</span>
</div>
)}
{legacyTotal > 0 && (
<div className="grid gap-px border-t border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-4">
{[
{ label: t("legacy.total"), value: legacyTotal },
{ label: t("legacy.success"), value: legacySummary?.success ?? 0 },
{ label: t("legacy.failed"), value: legacySummary?.failed ?? 0 },
{ label: t("legacy.topTool"), value: legacyTool ?? emptyLabel },
].map((item) => (
<div key={item.label} className="min-w-0 bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-1 truncate font-mono text-sm text-[#141413]">{item.value}</p>
</div>
))}
</div>
)}
</section>
);
}
@@ -883,7 +938,7 @@ export default function RunDetailPage({
<div className="bg-white p-4">
<div className="text-xs font-semibold text-[#77736a]">{t("stats.mcpSteps")}</div>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{(detail?.counts.mcp_calls ?? 0) + (detail?.counts.steps ?? 0)}
{(detail?.counts.mcp_calls ?? 0) + (detail?.counts.legacy_mcp_calls ?? 0) + (detail?.counts.steps ?? 0)}
</div>
</div>
<div className="bg-white p-4">
@@ -909,6 +964,7 @@ export default function RunDetailPage({
<McpGatewayPanel
summary={detail?.mcp_gateway}
legacy={detail?.mcp_legacy}
emptyLabel={t("empty")}
statusLabel={statusLabel}
/>

View File

@@ -1,3 +1,41 @@
## 2026-05-18 | T43 AwoooP Run Detail 顯示 legacy/self-built MCP 證據
**背景**Telegram 截圖與前端 Run detail 雖已能顯示 AwoooP Gateway / channel dossier / remediation dry-run但仍有一個可見性缺口許多既有自建 MCP / legacy MCP 呼叫寫在 `mcp_audit_log`Run detail 只讀 `awooop_mcp_gateway_audit`。結果是 operator 容易誤判「沒有真的用 MCP」即使 truth-chain API 其實能查到 legacy MCP audit。
**修正**
- `platform_operator_service.get_run_detail()`
- 透過 Run 關聯出的 `incident_ids` 反查 `mcp_audit_log`
- 新增 `mcp_legacy` 區塊,回傳 schema/source/incident_ids/records/summary。
- 將 legacy/self-built MCP 呼叫加入 Run timeline標題為 `Legacy MCP: server/tool`metadata 顯示 `incident_id` / `agent_role` / `flywheel_node` / `history_source=mcp_audit_log`
- `counts.legacy_mcp_calls` 納入 Run evidence count。
- AwoooP Run detail frontend
- MCP / Steps count 改為 Gateway MCP + legacy MCP + Steps。
- MCP Gateway panel 新增 legacy MCP 區塊,顯示 legacy total / success / failed / top tool。
- zh-TW / en i18n 補齊,不新增硬編碼文案。
**local verification**
- `DATABASE_URL='sqlite+aiosqlite:///:memory:' /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_awooop_operator_timeline_labels.py apps/api/tests/test_awooop_truth_chain_service.py -q`39 passed。
- `/Users/ogt/awoooi/apps/api/.venv/bin/ruff check apps/api/src/services/platform_operator_service.py apps/api/tests/test_awooop_operator_timeline_labels.py`pass。
- `cd apps/web && npm run lint -- --file 'src/app/[locale]/awooop/runs/[run_id]/page.tsx'`pass。
- `cd apps/web && npm run typecheck`pass。
- `python3 -m json.tool apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`pass。
- `git diff --check`pass。
**判讀**
- 這不是新增 MCP 執行能力,而是把既有 legacy/self-built MCP audit 拉進 operator-facing Run detail。接下來看 Run detail 時Gateway 沒資料但 legacy MCP 有資料不會再被誤判成「MCP 沒有跑」。
- 後續仍要把更多 write/admin MCP 收斂進 AwoooP Gateway讓 legacy-only 逐步下降;本輪先補可見性與 truth-chain 對齊。
**目前整體進度**
- Alertmanager 低風險自動修復主線:約 98%。
- 完整 AI 自動化管理產品化:約 99%。
- 告警詳情/歷史/主卡/前端 deep-link 可追溯:約 99%。
- Telegram approval / reject callback 閉環:約 96%。
- Truth-chain 對「自動修復成功但驗證降級」的判讀:約 99%。
- AwoooP MCP 使用可見性:約 85%Gateway + legacy MCP 都能在 Run detail 看見;仍需推進 Gateway choke point
- 188 OpenClaw runtime hygiene約 90%。
- Token hygiene約 65%。
- Gitea infra-lint 可執行性100%。
## 2026-05-18 | T42 MOMO Telegram bot log/token hygiene 收斂
**背景**T39/T41 已把 188 OpenClaw restart-loop 收斂,但 188 `momo-telegram-bot` 仍每約 10 秒由 `httpx INFO` 將 Telegram Bot API request URL 寫入 container log。這不是告警流程本身的 AI 判斷問題,但會讓 Telegram token hygiene 長期維持紅燈,且會污染後續 log matching / SignOz / AwoooP evidence。