from __future__ import annotations import asyncio import time from dataclasses import dataclass, field from enum import StrEnum from typing import Any from yuxi.utils.logging_config import logger class ApprovalStatus(StrEnum): PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" EXPIRED = "expired" @dataclass class ApprovalRequest: request_id: str action: str description: str requester: str chat_id: str = "" status: ApprovalStatus = ApprovalStatus.PENDING created_at: float = field(default_factory=time.time) expires_at: float = field(default=0) metadata: dict[str, Any] = field(default_factory=dict) class ExecApprovalManager: DEFAULT_TIMEOUT = 300 def __init__(self): self._pending: dict[str, ApprovalRequest] = {} self._history: list[ApprovalRequest] = [] self._lock = asyncio.Lock() async def request_approval( self, action: str, description: str, requester: str, chat_id: str = "", timeout_seconds: int = DEFAULT_TIMEOUT, metadata: dict[str, Any] | None = None, ) -> ApprovalRequest: request_id = f"exec_{int(time.time() * 1000)}_{action}" now = time.time() request = ApprovalRequest( request_id=request_id, action=action, description=description, requester=requester, chat_id=chat_id, created_at=now, expires_at=now + timeout_seconds, metadata=metadata or {}, ) async with self._lock: self._pending[request_id] = request logger.info( f"[Yuanbao] Exec approval requested: {request_id} action={action} by {requester} timeout={timeout_seconds}s" ) return request async def approve(self, request_id: str, approver: str = "") -> bool: async with self._lock: request = self._pending.pop(request_id, None) if request is None: return False if time.time() > request.expires_at: request.status = ApprovalStatus.EXPIRED self._history.append(request) return False request.status = ApprovalStatus.APPROVED self._history.append(request) logger.info(f"[Yuanbao] Exec approved: {request_id} by {approver or 'system'}") return True async def reject(self, request_id: str, reason: str = "") -> bool: async with self._lock: request = self._pending.pop(request_id, None) if request is None: return False request.status = ApprovalStatus.REJECTED request.metadata["reject_reason"] = reason self._history.append(request) logger.info(f"[Yuanbao] Exec rejected: {request_id} reason={reason}") return True async def get_pending(self, requester: str | None = None) -> list[ApprovalRequest]: async with self._lock: pending = list(self._pending.values()) if requester: pending = [r for r in pending if r.requester == requester] return pending async def cleanup_expired(self) -> int: async with self._lock: now = time.time() to_remove = [] for rid, req in self._pending.items(): if now > req.expires_at: req.status = ApprovalStatus.EXPIRED self._history.append(req) to_remove.append(rid) for rid in to_remove: self._pending.pop(rid, None) return len(to_remove) @property def pending_count(self) -> int: return len(self._pending)