feat(awooop): add run detail timeline
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m18s
CD Pipeline / build-and-deploy (push) Successful in 3m58s
CD Pipeline / post-deploy-checks (push) Successful in 1m25s

This commit is contained in:
Your Name
2026-05-07 03:55:01 +08:00
parent f10ab71c52
commit 66e22e26cb
5 changed files with 635 additions and 2 deletions

View File

@@ -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
# =============================================================================