feat(awooop): add run detail timeline
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user