diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 50248a6a..419a5d37 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -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), }, diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 452521ad..0930881e 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -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") diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index bbc7e0a2..c9de5ca4 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 440be43e..92a6f41e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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", diff --git a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx index 43504cb8..3aecd97b 100644 --- a/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -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({