import asyncio import logging import time from yuxi.channel.protocols import ( ApprovalAction, ApprovalDecision, ApprovalProtocol, ApprovalRequest, ApprovalResult, ) from yuxi.channel.plugins.registry import ChannelPluginRegistry logger = logging.getLogger(__name__) _TERMINAL_DECISIONS = frozenset({ ApprovalDecision.APPROVED, ApprovalDecision.DENIED, ApprovalDecision.CANCELLED, ApprovalDecision.EXPIRED, }) class ApprovalEngine: def __init__(self): self._pending: dict[str, ApprovalRequest] = {} self._results: dict[str, ApprovalResult] = {} self._events: dict[str, asyncio.Event] = {} self._cleanup_task: asyncio.Task | None = None @property def pending_count(self) -> int: return len(self._pending) @property def results_count(self) -> int: return len(self._results) def get_pending_request(self, request_id: str) -> ApprovalRequest | None: return self._pending.get(request_id) def get_result(self, request_id: str) -> ApprovalResult | None: return self._results.get(request_id) def _is_terminal(self, request_id: str) -> bool: existing = self._results.get(request_id) return existing is not None and existing.decision in _TERMINAL_DECISIONS def cancel_request(self, request_id: str, reason: str = "已取消") -> bool: existing = self._results.get(request_id) if existing is not None: logger.warning( "审批请求已达终态,无法取消: req=%s, current=%s", request_id, existing.decision.value, ) return False if request_id not in self._pending: return False del self._pending[request_id] result = ApprovalResult( request_id=request_id, decision=ApprovalDecision.CANCELLED, reason=reason, ) self._results[request_id] = result self._signal(request_id) logger.info("审批请求已取消: req=%s, reason=%s", request_id, reason) return True def register_decision( self, request_id: str, decision: ApprovalDecision, decided_by: str = "external", reason: str | None = None, ) -> bool: existing = self._results.get(request_id) if existing is not None: logger.warning( "审批请求已达终态,无法重新决策: req=%s, current=%s, attempted=%s", request_id, existing.decision.value, decision.value, ) return False request = self._pending.get(request_id) if request is None: return False if not request.approver_ids: logger.warning( "审批身份校验失败: req=%s, 审批人列表为空", request_id, ) return False if decided_by not in request.approver_ids: logger.warning( "审批身份校验失败: req=%s, decided_by=%s 不在审批人列表 %s 中", request_id, decided_by, request.approver_ids, ) return False del self._pending[request_id] result = ApprovalResult( request_id=request_id, decision=decision, decided_by=decided_by, reason=reason, ) self._results[request_id] = result self._signal(request_id) logger.info( "审批决策已注册: req=%s, decision=%s, decided_by=%s, reason=%s", request_id, decision.value, decided_by, reason, ) return True def cleanup_expired(self, max_age_seconds: float = 300) -> int: removed = 0 now = time.monotonic() stale = [ rid for rid, req in self._pending.items() if now - req.created_at > max_age_seconds and rid not in self._results ] for rid in stale: del self._pending[rid] result = ApprovalResult( request_id=rid, decision=ApprovalDecision.EXPIRED, reason="清理过期审批", ) self._results[rid] = result self._signal(rid) removed += 1 if removed > 0: logger.info("清理过期审批请求: count=%d", removed) return removed def cleanup_results(self, max_age_seconds: float = 600) -> int: removed = 0 now = time.monotonic() stale = [rid for rid, res in self._results.items() if now - res.decided_at > max_age_seconds] for rid in stale: del self._results[rid] removed += 1 if removed > 0: logger.info("清理过期审批结果: count=%d", removed) return removed def start_cleanup_task(self, interval: float = 60) -> None: if self._cleanup_task is not None and not self._cleanup_task.done(): return self._cleanup_task = asyncio.create_task(self._run_cleanup_loop(interval)) def stop_cleanup_task(self) -> None: if self._cleanup_task is not None and not self._cleanup_task.done(): self._cleanup_task.cancel() async def _run_cleanup_loop(self, interval: float) -> None: try: while True: await asyncio.sleep(interval) self.cleanup_expired() self.cleanup_results() except asyncio.CancelledError: pass def _signal(self, request_id: str) -> None: event = self._events.pop(request_id, None) if event is not None: event.set() async def request_approval( self, channel_type: str, account_id: str, config: dict, action: ApprovalAction, initiator_peer_id: str, description: str, context: dict, ) -> ApprovalRequest | None: plugin = ChannelPluginRegistry.get(channel_type) if plugin is None or not isinstance(plugin, ApprovalProtocol): return None need = await plugin.check_approval_required(config, action, initiator_peer_id) if not need: return None request = await plugin.create_approval_request( config, action, initiator_peer_id, description, context, ) approvers = plugin.get_approver_ids(config) if not approvers: logger.warning( "审批请求创建失败: channel=%s, 审批人列表为空", channel_type, ) return None request.approver_ids = list(approvers) sent = await plugin.send_approval_notification(config, request, approvers) if not sent: logger.warning( "审批通知发送失败: req=%s, channel=%s, approvers=%s", request.id, channel_type, approvers, ) self._pending[request.id] = request return request async def wait_for_decision( self, channel_type: str, config: dict, request_id: str, timeout: float = 300 ) -> ApprovalResult: existing = self._results.get(request_id) if existing is not None: return existing event = self._events.get(request_id) if event is None: event = asyncio.Event() self._events[request_id] = event loop = asyncio.get_running_loop() deadline = loop.time() + timeout while True: remaining = deadline - loop.time() if remaining <= 0: break plugin_result = await self._check_and_update(channel_type, config, request_id) if plugin_result is not None: self._pending.pop(request_id, None) self._results[request_id] = plugin_result self._events.pop(request_id, None) return plugin_result try: await asyncio.wait_for(event.wait(), timeout=min(remaining, 5)) except asyncio.TimeoutError: continue stored = self._results.get(request_id) if stored is not None: self._events.pop(request_id, None) return stored self._events.pop(request_id, None) expired = ApprovalResult( request_id=request_id, decision=ApprovalDecision.EXPIRED, reason="审批超时", ) self._pending.pop(request_id, None) self._results[request_id] = expired return expired async def _check_and_update(self, channel_type: str, config: dict, request_id: str) -> ApprovalResult | None: plugin = ChannelPluginRegistry.get(channel_type) if plugin is None or not isinstance(plugin, ApprovalProtocol): return None return await plugin.check_approval_status(config, request_id)