Files
taiji-AI-PAD/services/mcp-server/app/event_bus.py
T
chenchenandClaude Opus 4.7 610fde5d03 feat(mcp-server): Heicode integration + register transaction hardening
== Heicode integration (~41 endpoints across 5 modules) ==
- §2 ResourceBinding (5 endpoints) — resources.py / resource_grants.py
- §4 NewAPI metadata proxy (4 endpoints) — heicode_proxy.py + heicode_client.py
- §5 Agnet platform stub (12 endpoints, in-memory mock) — agnet_stub.py
- §6 Task orchestration (5 endpoints + 3 extension endpoints) — heicode_tasks.py
  6.1-6.5: intent / list / get / answer / messages
  6.6-6.8: execution / delivery / audit?tab=... (Slice 8/9/10)
- §7 SSE single channel + approvals (4 endpoints + 5 event types) —
  heicode_events.py + event_bus.py
- §7.8.1 internal billing-provider PUT endpoint — auth.py (routes)

== Schema changes ==
- migrations/026 heicode_tasks (orchestration state)
- migrations/027 users.billing_provider (litellm | newapi switch)
- migrations/028 heicode_approvals (high-risk approval queue)

== Register transaction hardening (P0 + P1 + P2) ==
routes/auth.py register():
- Pre-existing P0: failed register returned IntegrityError str verbatim
  (leaking SQL params + ~50 plaintext LiteLLM keys per attempt).
  Now logs exc_info, returns {code: REGISTER_FAILED, message: ...}.
- Pre-existing P0: model dedupe — two ModelProvider rows with overlapping
  supported_models (e.g. taiji/gpt-4o-mini in both taiji and azure providers)
  collide on uq_tenant_model. seen_models set deduplicates within the loop.
- New P1: track created_litellm_keys; on any failure call delete_key() for
  each — prevents remote orphan keys when DB rollback fires.
- New P1: replace verify_code with peek_verification_code at the start;
  only call verify_code (which consumes) after commit succeeds. Failed
  registrations no longer burn the user's one-shot code.
- New P2: narrow inner `except (LiteLLMClientError, Exception)` to just
  LiteLLMClientError so SQLAlchemy errors bubble to the outer rollback
  instead of being silently swallowed into a half-allocated 200 response.
- New P2: same narrowing on outer `except (AgentManagerError, Exception)`.

== Auth middleware ==
- app/auth.py: allow /api/auth/internal/billing-provider and
  /api/auth/internal/approvals to bypass user JWT (service-token auth
  via HEICODE_INTERNAL_SERVICE_TOKEN, validated in-route).

== Docs ==
- Heicode-接口契约文档.md v2.2 (41 endpoints + SSE schema + 6.6-6.8)
- Heicode-对接进度与待办.md (through §7.14 SSE + 7.8.2 delivery回执)
- Heicode-完整调用流程图.md (sequence + routing diagrams)
- Agent-Manager-Heicode对接需求文档.md
- HEICODE_API_INTEGRATION.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:43:10 +08:00

72 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Heicode SSE 事件总线(§7.8.3 配套)
进程内 pub/sub:每个用户的活跃 SSE 订阅者用一个 asyncio.Queue 排队接收事件;
其他业务路径(approvals/tasks/...)通过 emit() 发布事件。
⚠️ 单进程范围:mcp-server 多副本部署下,副本 A 上的订阅者**收不到**副本 B 上
emit 的事件。MVP 阶段可接受(cc-haha 单连接);生产化时换成 Redis pub/sub
或 NATS(mcp-server 已部署 NATS)即可,emit/subscribe 接口不变。
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional, Set
class _EventBus:
"""每用户独立队列的内存 pub/sub。"""
def __init__(self):
# user_id (str) → set of asyncio.Queue
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
self._lock = asyncio.Lock()
async def subscribe(self, user_id: str) -> asyncio.Queue:
"""订阅指定用户的事件流。返回一个新的 Queue。"""
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
async with self._lock:
self._subscribers.setdefault(user_id, set()).add(queue)
return queue
async def unsubscribe(self, user_id: str, queue: asyncio.Queue) -> None:
async with self._lock:
subs = self._subscribers.get(user_id)
if subs:
subs.discard(queue)
if not subs:
self._subscribers.pop(user_id, None)
async def emit(self, user_id: str, event_type: str, data: Dict[str, Any]) -> int:
"""向指定用户的所有订阅者推送事件。返回送达的订阅者数。
队列已满时 drop(不阻塞 emit;客户端断重连后会重拉一次状态)。
"""
payload = {"event": event_type, "data": data}
delivered = 0
async with self._lock:
subs = list(self._subscribers.get(user_id, []))
for q in subs:
try:
q.put_nowait(payload)
delivered += 1
except asyncio.QueueFull:
pass
return delivered
def subscriber_count(self, user_id: Optional[str] = None) -> int:
if user_id is not None:
return len(self._subscribers.get(user_id, set()))
return sum(len(s) for s in self._subscribers.values())
# 单例
_default_bus: Optional[_EventBus] = None
def get_event_bus() -> _EventBus:
global _default_bus
if _default_bus is None:
_default_bus = _EventBus()
return _default_bus