273 lines
8.9 KiB
Python
273 lines
8.9 KiB
Python
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)
|