diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 2c1eb79c..930a832f 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -24,7 +24,14 @@ from src.core.awooop_operator_auth import ( ) from src.services.platform_operator_service import ( decide_approval as decide_approval_svc, +) +from src.services.platform_operator_service import ( + get_run_detail as get_run_detail_svc, +) +from src.services.platform_operator_service import ( list_approvals as list_approvals_svc, +) +from src.services.platform_operator_service import ( list_runs as list_runs_svc, ) @@ -104,6 +111,21 @@ async def list_runs( ) +@router.get( + "/runs/{run_id}/detail", + summary="查詢 Run 詳細時間線", + description=( + "返回單一 Run 的主狀態、Step Journal、MCP Gateway audit、" + "入站 Channel Event 與出站訊息,供 Operator Console 顯示完整處置脈絡。" + ), +) +async def get_run_detail( + run_id: str, + project_id: str | None = Query(None, description="租戶 ID(可選)"), +) -> dict[str, Any]: + return await get_run_detail_svc(run_id=run_id, project_id=project_id) + + @router.get( "/approvals", response_model=ListApprovalsResponse, diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 704d9a9e..e38d6929 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -15,12 +15,16 @@ from uuid import UUID import structlog from fastapi import HTTPException, status from sqlalchemy import func, select +from sqlalchemy import or_ as sa_or from src.db.awooop_models import ( AwoooPContractRevision, AwoooPConversationEvent, + AwoooPMcpGatewayAudit, + AwoooPOutboundMessage, AwoooPProject, AwoooPRunState, + AwoooPRunStepJournal, ) from src.db.base import get_db_context from src.services.audit_sink import write_audit @@ -33,6 +37,7 @@ _MAX_CONTRACTS = 200 _DEFAULT_PER_PAGE = 50 _MAX_PER_PAGE = 200 _MAX_EVENTS = 100 +_MAX_TIMELINE_ITEMS = 100 # ============================================================================= # Tenants @@ -149,6 +154,278 @@ async def list_runs( return {"runs": runs, "total": total, "page": page, "per_page": per_page} +def _timeline_item( + *, + ts: Any, + kind: str, + title: str, + status: str, + summary: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build one Operator Console timeline item.""" + return { + "ts": ts, + "kind": kind, + "title": title, + "status": status, + "summary": summary, + "metadata": metadata or {}, + } + + +async def get_run_detail( + run_id: str, + project_id: str | None = None, +) -> dict[str, Any]: + """取得單一 Run 的處置脈絡,供 AwoooP Run detail / Timeline 顯示。""" + try: + run_uuid = uuid.UUID(run_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"run_id 格式錯誤: {exc}", + ) from exc + + async with get_db_context(project_id or "awoooi") as db: + run_stmt = select(AwoooPRunState).where(AwoooPRunState.run_id == run_uuid) + if project_id is not None: + run_stmt = run_stmt.where(AwoooPRunState.project_id == project_id) + run_result = await db.execute(run_stmt) + run = run_result.scalar_one_or_none() + + if run is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"run {run_id!r} 不存在", + ) + + steps_result = await db.execute( + select(AwoooPRunStepJournal) + .where(AwoooPRunStepJournal.run_id == run_uuid) + .order_by(AwoooPRunStepJournal.step_seq.asc()) + .limit(_MAX_TIMELINE_ITEMS) + ) + steps = list(steps_result.scalars().all()) + + inbound_where = [AwoooPConversationEvent.run_id == run_uuid] + if run.trigger_ref: + try: + trigger_event_uuid = uuid.UUID(run.trigger_ref) + inbound_where.append(AwoooPConversationEvent.event_id == trigger_event_uuid) + except ValueError: + inbound_where.append( + AwoooPConversationEvent.provider_event_id == run.trigger_ref + ) + inbound_result = await db.execute( + select(AwoooPConversationEvent) + .where(sa_or_(*inbound_where)) + .order_by(AwoooPConversationEvent.received_at.asc()) + .limit(_MAX_TIMELINE_ITEMS) + ) + inbound_events = list(inbound_result.scalars().all()) + + outbound_result = await db.execute( + select(AwoooPOutboundMessage) + .where(AwoooPOutboundMessage.run_id == run_uuid) + .order_by(AwoooPOutboundMessage.queued_at.asc()) + .limit(_MAX_TIMELINE_ITEMS) + ) + outbound_messages = list(outbound_result.scalars().all()) + + mcp_result = await db.execute( + select(AwoooPMcpGatewayAudit) + .where(AwoooPMcpGatewayAudit.run_id == run_uuid) + .order_by(AwoooPMcpGatewayAudit.created_at.asc()) + .limit(_MAX_TIMELINE_ITEMS) + ) + mcp_calls = list(mcp_result.scalars().all()) + + run_payload = { + "run_id": run.run_id, + "project_id": run.project_id, + "agent_id": run.agent_id, + "state": run.state, + "is_shadow": run.is_shadow, + "trace_id": run.trace_id, + "trigger_type": run.trigger_type, + "trigger_ref": run.trigger_ref, + "cost_usd": run.cost_usd, + "step_count": run.step_count, + "attempt_count": run.attempt_count, + "max_attempts": run.max_attempts, + "error_code": run.error_code, + "error_detail": run.error_detail, + "created_at": run.created_at, + "started_at": run.started_at, + "completed_at": run.completed_at, + "timeout_at": run.timeout_at, + "heartbeat_at": run.heartbeat_at, + } + + step_items = [ + { + "step_id": row.step_id, + "step_seq": row.step_seq, + "tool_name": row.tool_name, + "result_status": row.result_status, + "was_blocked": row.was_blocked, + "block_reason": row.block_reason, + "error_code": row.error_code, + "latency_ms": row.latency_ms, + "created_at": row.created_at, + "completed_at": row.completed_at, + } + for row in steps + ] + + inbound_items = [ + { + "event_id": row.event_id, + "channel_type": row.channel_type, + "provider_event_id": row.provider_event_id, + "content_preview": row.content_preview, + "is_duplicate": row.is_duplicate, + "received_at": row.received_at, + } + for row in inbound_events + ] + + outbound_items = [ + { + "message_id": row.message_id, + "channel_type": row.channel_type, + "message_type": row.message_type, + "content_preview": row.content_preview, + "send_status": row.send_status, + "send_error": row.send_error, + "provider_message_id": row.provider_message_id, + "queued_at": row.queued_at, + "sent_at": row.sent_at, + "triggered_by_state": row.triggered_by_state, + } + for row in outbound_messages + ] + + mcp_items = [ + { + "call_id": row.call_id, + "tool_name": row.tool_name, + "result_status": row.result_status, + "block_gate": row.block_gate, + "block_reason": row.block_reason, + "latency_ms": row.latency_ms, + "created_at": row.created_at, + } + for row in mcp_calls + ] + + timeline: list[dict[str, Any]] = [ + _timeline_item( + ts=run.created_at, + kind="run", + title="Run 建立", + status=run.state, + summary=f"{run.trigger_type or 'unknown'} → {run.agent_id}", + metadata={"trace_id": run.trace_id, "trigger_ref": run.trigger_ref}, + ) + ] + if run.started_at: + timeline.append( + _timeline_item( + ts=run.started_at, + kind="run", + title="Run 開始執行", + status="running", + summary=run.worker_id, + ) + ) + for row in inbound_events: + timeline.append( + _timeline_item( + ts=row.received_at, + kind="inbound", + title=f"{row.channel_type} 入站事件", + status="duplicate" if row.is_duplicate else "received", + summary=row.content_preview, + metadata={"provider_event_id": row.provider_event_id}, + ) + ) + for row in steps: + timeline.append( + _timeline_item( + ts=row.completed_at or row.created_at, + kind="step", + title=f"Step {row.step_seq}: {row.tool_name}", + status=row.result_status, + summary=row.block_reason or row.error_code, + metadata={ + "was_blocked": row.was_blocked, + "latency_ms": row.latency_ms, + }, + ) + ) + for row in mcp_calls: + timeline.append( + _timeline_item( + ts=row.created_at, + kind="mcp", + title=f"MCP: {row.tool_name}", + status=row.result_status, + summary=row.block_reason, + metadata={ + "block_gate": row.block_gate, + "latency_ms": row.latency_ms, + }, + ) + ) + for row in outbound_messages: + timeline.append( + _timeline_item( + ts=row.sent_at or row.queued_at, + kind="outbound", + title=f"{row.channel_type} 出站:{row.message_type}", + status=row.send_status, + summary=row.content_preview or row.send_error, + metadata={ + "provider_message_id": row.provider_message_id, + "triggered_by_state": row.triggered_by_state, + }, + ) + ) + if run.completed_at: + timeline.append( + _timeline_item( + ts=run.completed_at, + kind="run", + title="Run 結束", + status=run.state, + summary=run.error_detail or run.error_code, + ) + ) + + timeline = sorted( + timeline, + key=lambda item: item["ts"] or run.created_at, + )[:_MAX_TIMELINE_ITEMS] + + return { + "run": run_payload, + "steps": step_items, + "inbound_events": inbound_items, + "outbound_messages": outbound_items, + "mcp_calls": mcp_items, + "timeline": timeline, + "counts": { + "steps": len(step_items), + "inbound_events": len(inbound_items), + "outbound_messages": len(outbound_items), + "mcp_calls": len(mcp_items), + "timeline": len(timeline), + }, + } + + # ============================================================================= # Channel Events # ============================================================================= diff --git a/apps/api/tests/test_platform_router_order.py b/apps/api/tests/test_platform_router_order.py index c9c5eeee..ef12c3d1 100644 --- a/apps/api/tests/test_platform_router_order.py +++ b/apps/api/tests/test_platform_router_order.py @@ -11,8 +11,10 @@ def test_runs_list_route_is_registered_before_dynamic_run_id() -> None: ] assert "/runs/list" in paths + assert "/runs/{run_id}/detail" in paths assert "/runs/{run_id}" in paths assert paths.index("/runs/list") < paths.index("/runs/{run_id}") + assert paths.index("/runs/{run_id}/detail") < paths.index("/runs/{run_id}") def test_recent_events_route_is_registered() -> None: 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 new file mode 100644 index 00000000..a15c55ac --- /dev/null +++ b/apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx @@ -0,0 +1,328 @@ +// ============================================================================= +// WOOO AIOps - AwoooP Run Detail / Timeline +// ============================================================================= +// 將 Run FSM、Channel Event、MCP Audit、出站訊息收斂成同一條處置脈絡。 + +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { + Activity, + AlertCircle, + ArrowLeft, + Clock, + MessageSquareText, + RefreshCw, + Route, + Send, + ShieldAlert, + Wrench, +} from "lucide-react"; + +import { Link } from "@/i18n/routing"; +import { cn } from "@/lib/utils"; + +interface RunDetail { + run_id: string; + project_id: string; + agent_id: string; + state: string; + is_shadow: boolean; + trace_id?: string | null; + trigger_type?: string | null; + trigger_ref?: string | null; + cost_usd: number | string; + step_count: number; + attempt_count: number; + max_attempts: number; + error_code?: string | null; + error_detail?: string | null; + created_at: string; + started_at?: string | null; + completed_at?: string | null; + timeout_at?: string | null; + heartbeat_at?: string | null; +} + +interface TimelineItem { + ts: string | null; + kind: "run" | "inbound" | "outbound" | "step" | "mcp" | string; + title: string; + status: string; + summary?: string | null; + metadata?: Record; +} + +interface RunDetailResponse { + run: RunDetail; + timeline: TimelineItem[]; + counts: { + steps: number; + inbound_events: number; + outbound_messages: number; + mcp_calls: number; + timeline: number; + }; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; +const AUTO_REFRESH_INTERVAL = 30_000; + +const STATUS_STYLE: Record = { + completed: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + success: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + sent: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + received: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + pending: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + shadow: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", + failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + error: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + blocked: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + cancelled: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + timeout: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", +}; + +function formatTime(value?: string | null) { + if (!value) return "--"; + return new Date(value).toLocaleString("zh-TW", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function statusClass(status: string) { + return STATUS_STYLE[status] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]"; +} + +function itemIcon(kind: string) { + if (kind === "inbound") return MessageSquareText; + if (kind === "outbound") return Send; + if (kind === "step") return Wrench; + if (kind === "mcp") return ShieldAlert; + return Route; +} + +function DetailField({ label, value }: { label: string; value?: string | number | null }) { + return ( +
+
{label}
+
{value ?? "--"}
+
+ ); +} + +function TimelineRow({ item }: { item: TimelineItem }) { + const Icon = itemIcon(item.kind); + return ( +
+
{formatTime(item.ts)}
+
+
+ + +

{item.title}

+ + {item.status} + +
+ {item.summary && ( +

+ {item.summary} +

+ )} + {item.metadata && Object.keys(item.metadata).length > 0 && ( +
+ {Object.entries(item.metadata).slice(0, 4).map(([key, value]) => ( +
+
{key}
+
+ {value === null || value === undefined ? "--" : String(value)} +
+
+ ))} +
+ )} +
+
+ ); +} + +export default function RunDetailPage({ + params, +}: { + params: { run_id: string }; +}) { + const { run_id } = params; + const searchParams = useSearchParams(); + const projectId = searchParams.get("project_id") ?? ""; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + const fetchDetail = useCallback(async () => { + try { + setError(null); + const query = new URLSearchParams(); + if (projectId) query.set("project_id", projectId); + const suffix = query.toString() ? `?${query.toString()}` : ""; + const res = await fetch(`${API_BASE}/api/v1/platform/runs/${run_id}/detail${suffix}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: RunDetailResponse = await res.json(); + setDetail(data); + setLastRefresh(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : "載入失敗"); + } finally { + setLoading(false); + } + }, [projectId, run_id]); + + useEffect(() => { + setLoading(true); + fetchDetail(); + }, [fetchDetail]); + + useEffect(() => { + const timer = setInterval(fetchDetail, AUTO_REFRESH_INTERVAL); + return () => clearInterval(timer); + }, [fetchDetail]); + + const run = detail?.run; + const durationText = useMemo(() => { + if (!run?.created_at) return "--"; + const end = run.completed_at || run.heartbeat_at || new Date().toISOString(); + const ms = Math.max(0, new Date(end).getTime() - new Date(run.created_at).getTime()); + return `${Math.round(ms / 1000)}s`; + }, [run]); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 949bce55..efd492e8 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -6,6 +6,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { Link } from "@/i18n/routing"; import { Activity, BellOff, @@ -264,9 +265,12 @@ function RunRow({ run }: { run: Run }) { return ( - + {run.run_id.slice(0, 8)} - +