websocket.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. """
  2. Trace WebSocket 推送
  3. 实时推送进行中 Trace 的更新,支持断线续传
  4. """
  5. from typing import Dict, Set, Any
  6. from datetime import datetime
  7. from ipaddress import ip_address
  8. import asyncio
  9. from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
  10. from .protocols import TraceStore
  11. router = APIRouter(prefix="/api/traces", tags=["websocket"])
  12. LOCAL_DEVELOPMENT_ORIGINS = frozenset({
  13. "http://127.0.0.1:3000",
  14. "http://localhost:3000",
  15. })
  16. # ===== 全局状态 =====
  17. _trace_store: TraceStore = None
  18. _active_connections: Dict[str, Set[WebSocket]] = {} # trace_id -> Set[WebSocket]
  19. def set_trace_store(store: TraceStore):
  20. """设置 TraceStore 实例"""
  21. global _trace_store
  22. _trace_store = store
  23. def get_trace_store() -> TraceStore:
  24. """获取 TraceStore 实例"""
  25. if _trace_store is None:
  26. raise RuntimeError("TraceStore not initialized")
  27. return _trace_store
  28. def is_local_websocket_origin(websocket: WebSocket) -> bool:
  29. """Trace 流只接受本地前端的浏览器 WebSocket。"""
  30. return websocket.headers.get("origin") in LOCAL_DEVELOPMENT_ORIGINS
  31. def is_loopback_websocket_peer(websocket: WebSocket) -> bool:
  32. """Reject absent, non-IP, and non-loopback ASGI peers."""
  33. host = websocket.client.host if websocket.client else None
  34. if not host:
  35. return False
  36. try:
  37. return ip_address(host).is_loopback
  38. except ValueError:
  39. return False
  40. # ===== WebSocket 路由 =====
  41. @router.websocket("/{trace_id}/watch")
  42. async def watch_trace(
  43. websocket: WebSocket,
  44. trace_id: str,
  45. since_event_id: int = Query(0, description="从哪个事件 ID 开始(0=补发所有历史)")
  46. ):
  47. """
  48. 监听 Trace 的更新,支持断线续传
  49. 事件类型:
  50. - connected: 连接成功,返回 goal_tree 和 sub_traces
  51. - goal_added: 新增 Goal
  52. - goal_updated: Goal 状态变化(含级联完成)
  53. - message_added: 新 Message(含 affected_goals)
  54. - sub_trace_started: Sub-Trace 开始执行
  55. - sub_trace_completed: Sub-Trace 完成
  56. - trace_completed: 执行完成
  57. Args:
  58. trace_id: Trace ID
  59. since_event_id: 从哪个事件 ID 开始
  60. - 0: 补发所有历史事件(初次连接)
  61. - >0: 补发指定 ID 之后的事件(断线重连)
  62. """
  63. if not is_loopback_websocket_peer(websocket):
  64. await websocket.close(code=1008, reason="Loopback client required")
  65. return
  66. if not is_local_websocket_origin(websocket):
  67. await websocket.close(code=1008, reason="Local development origin required")
  68. return
  69. await websocket.accept()
  70. # 验证 Trace 存在
  71. store = get_trace_store()
  72. trace = await store.get_trace(trace_id)
  73. if not trace:
  74. await websocket.send_json({
  75. "event": "error",
  76. "message": "Trace not found"
  77. })
  78. await websocket.close()
  79. return
  80. # 注册连接
  81. if trace_id not in _active_connections:
  82. _active_connections[trace_id] = set()
  83. _active_connections[trace_id].add(websocket)
  84. try:
  85. # 获取 GoalTree 和 Sub-Traces
  86. goal_tree = await store.get_goal_tree(trace_id)
  87. # 获取所有 Sub-Traces(通过 parent_trace_id 查询)
  88. sub_traces = {}
  89. all_traces = await store.list_traces(limit=1000)
  90. for t in all_traces:
  91. if t.parent_trace_id == trace_id:
  92. sub_traces[t.trace_id] = t.to_dict()
  93. # 发送连接成功消息 + 完整状态(含 trace 当前执行状态)
  94. from .run_api import _running_tasks # 避免循环导入,在函数内 import
  95. is_running = (
  96. trace_id in _running_tasks and not _running_tasks[trace_id].done()
  97. )
  98. await websocket.send_json({
  99. "event": "connected",
  100. "trace_id": trace_id,
  101. "current_event_id": trace.last_event_id,
  102. "trace_status": trace.status if not is_running else "running",
  103. "is_running": is_running,
  104. "goal_tree": goal_tree.to_dict() if goal_tree else None,
  105. "sub_traces": sub_traces
  106. })
  107. # 补发历史事件(since_event_id=0 表示补发所有历史)
  108. last_sent_event_id = since_event_id
  109. if since_event_id >= 0:
  110. missed_events = await store.get_events(trace_id, since_event_id)
  111. # 限制补发数量(最多 100 条)
  112. if len(missed_events) > 100:
  113. await websocket.send_json({
  114. "event": "error",
  115. "message": f"Too many missed events ({len(missed_events)}), please reload via REST API"
  116. })
  117. else:
  118. for evt in missed_events:
  119. await websocket.send_json(evt)
  120. if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
  121. last_sent_event_id = max(last_sent_event_id, evt["event_id"])
  122. # 保持连接:同时支持心跳 + 轮询 events.jsonl(跨进程写入时也能实时推送)
  123. while True:
  124. try:
  125. # 允许在没有客户端消息时继续轮询事件流
  126. data = await asyncio.wait_for(websocket.receive_text(), timeout=0.5)
  127. if data == "ping":
  128. await websocket.send_json({"event": "pong"})
  129. except WebSocketDisconnect:
  130. break
  131. except asyncio.TimeoutError:
  132. pass
  133. try:
  134. new_events = await store.get_events(trace_id, last_sent_event_id)
  135. if len(new_events) > 100:
  136. await websocket.send_json({
  137. "event": "error",
  138. "message": f"Too many missed events ({len(new_events)}), please reload via REST API"
  139. })
  140. continue
  141. for evt in new_events:
  142. await websocket.send_json(evt)
  143. if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
  144. last_sent_event_id = max(last_sent_event_id, evt["event_id"])
  145. except WebSocketDisconnect:
  146. break
  147. finally:
  148. # 清理连接
  149. if trace_id in _active_connections:
  150. _active_connections[trace_id].discard(websocket)
  151. if not _active_connections[trace_id]:
  152. del _active_connections[trace_id]
  153. # ===== 广播函数(由 AgentRunner 或 TraceStore 调用)=====
  154. async def broadcast_goal_added(trace_id: str, goal_dict: Dict[str, Any]):
  155. """
  156. 广播 Goal 添加事件
  157. Args:
  158. trace_id: Trace ID
  159. goal_dict: Goal 字典(完整数据,含 stats)
  160. """
  161. if trace_id not in _active_connections:
  162. return
  163. store = get_trace_store()
  164. event_id = await store.append_event(trace_id, "goal_added", {
  165. "goal": goal_dict
  166. })
  167. message = {
  168. "event": "goal_added",
  169. "event_id": event_id,
  170. "ts": datetime.now().isoformat(),
  171. "goal": goal_dict
  172. }
  173. await _broadcast_to_trace(trace_id, message)
  174. async def broadcast_goal_updated(
  175. trace_id: str,
  176. goal_id: str,
  177. updates: Dict[str, Any],
  178. affected_goals: list[Dict[str, Any]] = None
  179. ):
  180. """
  181. 广播 Goal 更新事件(patch 语义)
  182. Args:
  183. trace_id: Trace ID
  184. goal_id: Goal ID
  185. updates: 更新字段(patch 格式)
  186. affected_goals: 受影响的 Goals(含级联完成的父节点)
  187. """
  188. if trace_id not in _active_connections:
  189. return
  190. store = get_trace_store()
  191. event_id = await store.append_event(trace_id, "goal_updated", {
  192. "goal_id": goal_id,
  193. "updates": updates,
  194. "affected_goals": affected_goals or []
  195. })
  196. message = {
  197. "event": "goal_updated",
  198. "event_id": event_id,
  199. "ts": datetime.now().isoformat(),
  200. "goal_id": goal_id,
  201. "patch": updates,
  202. "affected_goals": affected_goals or []
  203. }
  204. await _broadcast_to_trace(trace_id, message)
  205. async def broadcast_message_added(
  206. trace_id: str,
  207. event_id: int,
  208. message_dict: Dict[str, Any],
  209. affected_goals: list[Dict[str, Any]] = None,
  210. ):
  211. """
  212. 广播 Message 添加事件(不在此处写入 events.jsonl)
  213. 说明:
  214. - message_added 的 events.jsonl 写入由 TraceStore.append_event 负责
  215. - 这里仅负责把“已经持久化”的事件推送给当前活跃连接
  216. """
  217. if trace_id not in _active_connections:
  218. return
  219. message = {
  220. "event": "message_added",
  221. "event_id": event_id,
  222. "ts": datetime.now().isoformat(),
  223. "message": message_dict,
  224. "affected_goals": affected_goals or [],
  225. }
  226. await _broadcast_to_trace(trace_id, message)
  227. async def broadcast_sub_trace_started(
  228. trace_id: str,
  229. sub_trace_id: str,
  230. parent_goal_id: str,
  231. agent_type: str,
  232. task: str
  233. ):
  234. """
  235. 广播 Sub-Trace 开始事件
  236. Args:
  237. trace_id: 主 Trace ID
  238. sub_trace_id: Sub-Trace ID
  239. parent_goal_id: 父 Goal ID
  240. agent_type: Agent 类型
  241. task: 任务描述
  242. """
  243. if trace_id not in _active_connections:
  244. return
  245. store = get_trace_store()
  246. event_id = await store.append_event(trace_id, "sub_trace_started", {
  247. "trace_id": sub_trace_id,
  248. "parent_trace_id": trace_id,
  249. "parent_goal_id": parent_goal_id,
  250. "agent_type": agent_type,
  251. "task": task
  252. })
  253. message = {
  254. "event": "sub_trace_started",
  255. "event_id": event_id,
  256. "ts": datetime.now().isoformat(),
  257. "trace_id": sub_trace_id,
  258. "parent_goal_id": parent_goal_id,
  259. "agent_type": agent_type,
  260. "task": task
  261. }
  262. await _broadcast_to_trace(trace_id, message)
  263. async def broadcast_sub_trace_completed(
  264. trace_id: str,
  265. sub_trace_id: str,
  266. status: str,
  267. summary: str = "",
  268. stats: Dict[str, Any] = None
  269. ):
  270. """
  271. 广播 Sub-Trace 完成事件
  272. Args:
  273. trace_id: 主 Trace ID
  274. sub_trace_id: Sub-Trace ID
  275. status: 状态(completed/failed)
  276. summary: 总结
  277. stats: 统计信息
  278. """
  279. if trace_id not in _active_connections:
  280. return
  281. store = get_trace_store()
  282. event_id = await store.append_event(trace_id, "sub_trace_completed", {
  283. "trace_id": sub_trace_id,
  284. "status": status,
  285. "summary": summary,
  286. "stats": stats or {}
  287. })
  288. message = {
  289. "event": "sub_trace_completed",
  290. "event_id": event_id,
  291. "ts": datetime.now().isoformat(),
  292. "trace_id": sub_trace_id,
  293. "status": status,
  294. "summary": summary,
  295. "stats": stats or {}
  296. }
  297. await _broadcast_to_trace(trace_id, message)
  298. async def broadcast_trace_completed(trace_id: str, total_messages: int):
  299. """
  300. 广播 Trace 完成事件
  301. Args:
  302. trace_id: Trace ID
  303. total_messages: 总 Message 数
  304. """
  305. if trace_id not in _active_connections:
  306. return
  307. store = get_trace_store()
  308. event_id = await store.append_event(trace_id, "trace_completed", {
  309. "total_messages": total_messages
  310. })
  311. message = {
  312. "event": "trace_completed",
  313. "event_id": event_id,
  314. "ts": datetime.now().isoformat(),
  315. "trace_id": trace_id,
  316. "total_messages": total_messages
  317. }
  318. await _broadcast_to_trace(trace_id, message)
  319. # 完成后清理所有连接
  320. if trace_id in _active_connections:
  321. del _active_connections[trace_id]
  322. async def broadcast_trace_status_changed(trace_id: str, status: str):
  323. """
  324. 广播 Trace 状态变化事件(用于暂停/继续等状态切换)
  325. Args:
  326. trace_id: Trace ID
  327. status: 新状态 (running/stopped/completed/failed)
  328. """
  329. if trace_id not in _active_connections:
  330. return
  331. store = get_trace_store()
  332. trace = await store.get_trace(trace_id)
  333. if not trace:
  334. return
  335. event_id = await store.append_event(trace_id, "trace_status_changed", {
  336. "status": status
  337. })
  338. message = {
  339. "event": "trace_status_changed",
  340. "event_id": event_id,
  341. "ts": datetime.now().isoformat(),
  342. "trace_id": trace_id,
  343. "status": status,
  344. "total_cost": trace.total_cost,
  345. "total_messages": trace.total_messages
  346. }
  347. await _broadcast_to_trace(trace_id, message)
  348. # ===== 内部辅助函数 =====
  349. async def _broadcast_to_trace(trace_id: str, message: Dict[str, Any]):
  350. """
  351. 向指定 Trace 的所有连接广播消息
  352. Args:
  353. trace_id: Trace ID
  354. message: 消息内容
  355. """
  356. if trace_id not in _active_connections:
  357. return
  358. disconnected = []
  359. for websocket in _active_connections[trace_id]:
  360. try:
  361. await websocket.send_json(message)
  362. except Exception:
  363. disconnected.append(websocket)
  364. # 清理断开的连接
  365. for ws in disconnected:
  366. _active_connections[trace_id].discard(ws)