mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-04 00:27:14 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
1130 lines
48 KiB
Python
1130 lines
48 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import dataclasses
|
||
import functools
|
||
import json
|
||
import re
|
||
import time
|
||
import traceback
|
||
import uuid
|
||
import sqlalchemy
|
||
|
||
from ..core import app, entities as core_entities, taskmgr
|
||
|
||
from ..discover import engine
|
||
|
||
from ..entity.persistence import bot as persistence_bot
|
||
from ..entity.persistence import pipeline as persistence_pipeline
|
||
from ..entity.persistence import workspace as persistence_workspace
|
||
|
||
from ..entity.errors import platform as platform_errors
|
||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||
from ..api.http.authz import WorkspaceRequiredError
|
||
from ..workspace.errors import WorkspaceInvariantError
|
||
|
||
from .logger import EventLogger
|
||
|
||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||
|
||
|
||
class RuntimeBot:
|
||
"""运行时机器人"""
|
||
|
||
ap: app.Application
|
||
|
||
bot_entity: persistence_bot.Bot
|
||
|
||
enable: bool
|
||
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||
|
||
task_wrapper: taskmgr.TaskWrapper | None
|
||
|
||
task_context: taskmgr.TaskContext
|
||
|
||
logger: EventLogger
|
||
|
||
execution_context: ExecutionContext
|
||
|
||
workspace_uuid: str
|
||
|
||
placement_generation: int
|
||
|
||
def __init__(
|
||
self,
|
||
ap: app.Application,
|
||
bot_entity: persistence_bot.Bot,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
logger: EventLogger,
|
||
execution_context: ExecutionContext,
|
||
):
|
||
if not isinstance(execution_context, ExecutionContext):
|
||
raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
|
||
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
|
||
raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
|
||
if execution_context.placement_generation <= 0:
|
||
raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
|
||
entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
|
||
if entity_workspace_uuid != execution_context.workspace_uuid:
|
||
raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
|
||
if execution_context.bot_uuid not in (None, bot_entity.uuid):
|
||
raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
|
||
|
||
self.ap = ap
|
||
self.bot_entity = bot_entity
|
||
self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
|
||
self.workspace_uuid = self.execution_context.workspace_uuid
|
||
self.placement_generation = self.execution_context.placement_generation
|
||
self.enable = bot_entity.enable
|
||
self.adapter = adapter
|
||
self.task_context = taskmgr.TaskContext()
|
||
self.task_wrapper = None
|
||
self.logger = logger
|
||
self._shutdown_lock = asyncio.Lock()
|
||
self._shutdown_complete = False
|
||
|
||
async def assert_execution_active(self) -> None:
|
||
"""Fail closed when this long-lived adapter belongs to a stale placement."""
|
||
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
self.workspace_uuid,
|
||
expected_generation=self.placement_generation,
|
||
)
|
||
|
||
@staticmethod
|
||
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||
"""Evaluate a single operator condition."""
|
||
if operator == 'eq':
|
||
return actual == expected
|
||
elif operator == 'neq':
|
||
return actual != expected
|
||
elif operator == 'contains':
|
||
return expected in actual
|
||
elif operator == 'not_contains':
|
||
return expected not in actual
|
||
elif operator == 'starts_with':
|
||
return actual.startswith(expected)
|
||
elif operator == 'regex':
|
||
try:
|
||
return bool(re.search(expected, actual))
|
||
except re.error:
|
||
return False
|
||
return False
|
||
|
||
PIPELINE_DISCARD = '__discard__'
|
||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||
|
||
def resolve_pipeline_uuid(
|
||
self,
|
||
launcher_type: str,
|
||
launcher_id: str,
|
||
message_text: str,
|
||
message_element_types: list[str] | None = None,
|
||
) -> tuple[str | None, bool]:
|
||
"""Resolve pipeline UUID based on routing rules.
|
||
|
||
Rules are evaluated in order; first match wins.
|
||
Falls back to use_pipeline_uuid if no rule matches.
|
||
|
||
Rule types:
|
||
- launcher_type: session type ("person" / "group")
|
||
- launcher_id: session / group id
|
||
- message_content: message text content
|
||
- message_has_element: message contains element of given type
|
||
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||
Operators: eq (has), neq (doesn't have)
|
||
|
||
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||
|
||
When pipeline_uuid is ``__discard__``, the message should be
|
||
silently dropped by the caller.
|
||
|
||
Returns:
|
||
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||
when a routing rule matched, False when falling back to default.
|
||
"""
|
||
rules = self.bot_entity.pipeline_routing_rules or []
|
||
element_type_set = set(message_element_types or [])
|
||
|
||
for rule in rules:
|
||
rule_type = rule.get('type')
|
||
operator = rule.get('operator', 'eq')
|
||
rule_value = rule.get('value', '')
|
||
target_uuid = rule.get('pipeline_uuid')
|
||
if not rule_type or not target_uuid:
|
||
continue
|
||
|
||
if rule_type == 'launcher_type':
|
||
if self._match_operator(launcher_type, operator, rule_value):
|
||
return target_uuid, True
|
||
elif rule_type == 'launcher_id':
|
||
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||
return target_uuid, True
|
||
elif rule_type == 'message_content':
|
||
if self._match_operator(message_text, operator, rule_value):
|
||
return target_uuid, True
|
||
elif rule_type == 'message_has_element':
|
||
has_element = rule_value in element_type_set
|
||
if operator == 'eq' and has_element:
|
||
return target_uuid, True
|
||
elif operator == 'neq' and not has_element:
|
||
return target_uuid, True
|
||
|
||
return self.bot_entity.use_pipeline_uuid, False
|
||
|
||
def resolve_event_pipeline_uuid(
|
||
self,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
launcher_type: str,
|
||
launcher_id: str,
|
||
message_text: str,
|
||
message_element_types: list[str] | None = None,
|
||
) -> tuple[str | None, bool]:
|
||
"""Resolve a pipeline, honoring a trusted per-task adapter override."""
|
||
|
||
get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
|
||
if callable(get_override):
|
||
override = get_override()
|
||
if override:
|
||
return str(override), False
|
||
return self.resolve_pipeline_uuid(
|
||
launcher_type,
|
||
launcher_id,
|
||
message_text,
|
||
message_element_types,
|
||
)
|
||
|
||
async def _record_discarded_message(
|
||
self,
|
||
launcher_type: provider_session.LauncherTypes,
|
||
launcher_id: str | int,
|
||
sender_id: str | int,
|
||
message_event: platform_events.MessageEvent,
|
||
message_chain: platform_message.MessageChain,
|
||
) -> None:
|
||
"""Record a discarded message in the monitoring system."""
|
||
try:
|
||
if hasattr(message_chain, 'model_dump'):
|
||
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||
else:
|
||
message_content = str(message_chain)
|
||
|
||
sender_name = None
|
||
if hasattr(message_event, 'sender'):
|
||
if hasattr(message_event.sender, 'nickname'):
|
||
sender_name = message_event.sender.nickname
|
||
elif hasattr(message_event.sender, 'member_name'):
|
||
sender_name = message_event.sender.member_name
|
||
|
||
# Use the same session_id format as monitoring_helper.py
|
||
session_id = f'{launcher_type}_{launcher_id}'
|
||
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||
|
||
await self.ap.monitoring_service.record_message(
|
||
self.execution_context,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||
pipeline_id=self.PIPELINE_DISCARD,
|
||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||
message_content=message_content,
|
||
session_id=session_id,
|
||
status='discarded',
|
||
level='info',
|
||
platform=platform,
|
||
user_id=str(sender_id),
|
||
user_name=sender_name,
|
||
)
|
||
|
||
# Ensure the session exists so the message appears in the session monitor.
|
||
# Don't overwrite pipeline info — a session may have messages from
|
||
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||
self.execution_context,
|
||
session_id,
|
||
)
|
||
if not session_updated:
|
||
# No session yet (first message for this launcher was discarded).
|
||
await self.ap.monitoring_service.record_session_start(
|
||
self.execution_context,
|
||
session_id=session_id,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||
pipeline_id=self.PIPELINE_DISCARD,
|
||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||
platform=platform,
|
||
user_id=str(sender_id),
|
||
user_name=sender_name,
|
||
)
|
||
except Exception as e:
|
||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||
|
||
async def initialize(self):
|
||
def tenant_scoped_listener(listener):
|
||
"""Bind adapter callbacks to a Workspace without holding a DB transaction."""
|
||
|
||
@functools.wraps(listener)
|
||
async def wrapped(*args, **kwargs):
|
||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||
cloud_runtime = (
|
||
getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
|
||
)
|
||
if cloud_runtime:
|
||
if not callable(tenant_scope):
|
||
raise RuntimeError('Cloud platform callbacks require an explicit tenant scope')
|
||
async with tenant_scope(self.workspace_uuid):
|
||
return await listener(*args, **kwargs)
|
||
return await listener(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
async def on_friend_message(
|
||
event: platform_events.FriendMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
await self.assert_execution_active()
|
||
image_components = [
|
||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||
]
|
||
|
||
await self.logger.info(
|
||
f'{event.message_chain}',
|
||
images=image_components,
|
||
message_session_id=f'person_{event.sender.id}',
|
||
)
|
||
|
||
# Push to webhooks and check if pipeline should be skipped
|
||
skip_pipeline = False
|
||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||
self.execution_context,
|
||
event,
|
||
self.bot_entity.uuid,
|
||
adapter.__class__.__name__,
|
||
)
|
||
|
||
# Only add to query pool if no webhook requested to skip pipeline
|
||
if not skip_pipeline:
|
||
launcher_id = event.sender.id
|
||
|
||
if hasattr(adapter, 'get_launcher_id'):
|
||
custom_launcher_id = adapter.get_launcher_id(event)
|
||
if custom_launcher_id:
|
||
launcher_id = custom_launcher_id
|
||
|
||
message_text = str(event.message_chain)
|
||
element_types = [comp.type for comp in event.message_chain]
|
||
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
|
||
adapter,
|
||
'person',
|
||
launcher_id,
|
||
message_text,
|
||
element_types,
|
||
)
|
||
|
||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||
await self.logger.info('Person message discarded by routing rule')
|
||
await self._record_discarded_message(
|
||
provider_session.LauncherTypes.PERSON,
|
||
launcher_id,
|
||
event.sender.id,
|
||
event,
|
||
event.message_chain,
|
||
)
|
||
return
|
||
|
||
await self.ap.msg_aggregator.add_message(
|
||
bot_uuid=self.bot_entity.uuid,
|
||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||
launcher_id=launcher_id,
|
||
sender_id=event.sender.id,
|
||
message_event=event,
|
||
message_chain=event.message_chain,
|
||
adapter=adapter,
|
||
pipeline_uuid=pipeline_uuid,
|
||
routed_by_rule=routed_by_rule,
|
||
execution_context=self.execution_context,
|
||
)
|
||
else:
|
||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||
|
||
async def on_group_message(
|
||
event: platform_events.GroupMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
await self.assert_execution_active()
|
||
image_components = [
|
||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||
]
|
||
|
||
await self.logger.info(
|
||
f'{event.message_chain}',
|
||
images=image_components,
|
||
message_session_id=f'group_{event.group.id}',
|
||
)
|
||
|
||
# Push to webhooks and check if pipeline should be skipped
|
||
skip_pipeline = False
|
||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||
self.execution_context,
|
||
event,
|
||
self.bot_entity.uuid,
|
||
adapter.__class__.__name__,
|
||
)
|
||
|
||
# Only add to query pool if no webhook requested to skip pipeline
|
||
if not skip_pipeline:
|
||
launcher_id = event.group.id
|
||
|
||
if hasattr(adapter, 'get_launcher_id'):
|
||
custom_launcher_id = adapter.get_launcher_id(event)
|
||
if custom_launcher_id:
|
||
launcher_id = custom_launcher_id
|
||
|
||
message_text = str(event.message_chain)
|
||
element_types = [comp.type for comp in event.message_chain]
|
||
pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
|
||
adapter,
|
||
'group',
|
||
launcher_id,
|
||
message_text,
|
||
element_types,
|
||
)
|
||
|
||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||
await self.logger.info('Group message discarded by routing rule')
|
||
await self._record_discarded_message(
|
||
provider_session.LauncherTypes.GROUP,
|
||
launcher_id,
|
||
event.sender.id,
|
||
event,
|
||
event.message_chain,
|
||
)
|
||
return
|
||
|
||
await self.ap.msg_aggregator.add_message(
|
||
bot_uuid=self.bot_entity.uuid,
|
||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||
launcher_id=launcher_id,
|
||
sender_id=event.sender.id,
|
||
message_event=event,
|
||
message_chain=event.message_chain,
|
||
adapter=adapter,
|
||
pipeline_uuid=pipeline_uuid,
|
||
routed_by_rule=routed_by_rule,
|
||
execution_context=self.execution_context,
|
||
)
|
||
else:
|
||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||
|
||
self.adapter.register_listener(platform_events.FriendMessage, tenant_scoped_listener(on_friend_message))
|
||
self.adapter.register_listener(platform_events.GroupMessage, tenant_scoped_listener(on_group_message))
|
||
|
||
# Register feedback listener (only effective on adapters that support it)
|
||
async def on_feedback(
|
||
event: platform_events.FeedbackEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
try:
|
||
await self.assert_execution_active()
|
||
# Resolve pipeline name
|
||
pipeline_name = ''
|
||
if self.bot_entity.use_pipeline_uuid:
|
||
try:
|
||
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||
persistence_pipeline.LegacyPipeline.workspace_uuid == self.workspace_uuid,
|
||
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid,
|
||
)
|
||
)
|
||
pipeline_row = pipeline_result.first()
|
||
if pipeline_row:
|
||
pipeline_name = pipeline_row[0]
|
||
except Exception:
|
||
pass
|
||
|
||
await self.ap.monitoring_service.record_feedback(
|
||
self.execution_context,
|
||
feedback_id=event.feedback_id,
|
||
feedback_type=event.feedback_type,
|
||
feedback_content=event.feedback_content,
|
||
inaccurate_reasons=event.inaccurate_reasons,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name,
|
||
pipeline_id=self.bot_entity.use_pipeline_uuid or '',
|
||
pipeline_name=pipeline_name,
|
||
session_id=event.session_id,
|
||
message_id=event.message_id,
|
||
stream_id=event.stream_id,
|
||
user_id=event.user_id,
|
||
platform=adapter.__class__.__name__,
|
||
)
|
||
await self.logger.info(
|
||
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
|
||
)
|
||
except Exception:
|
||
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||
|
||
self.adapter.register_listener(platform_events.FeedbackEvent, tenant_scoped_listener(on_feedback))
|
||
|
||
async def run(self):
|
||
async def exception_wrapper():
|
||
try:
|
||
self.task_context.set_current_action('Running...')
|
||
await self.adapter.run_async()
|
||
self.task_context.set_current_action('Exited.')
|
||
except Exception as e:
|
||
if isinstance(e, asyncio.CancelledError):
|
||
self.task_context.set_current_action('Exited.')
|
||
return
|
||
|
||
traceback_str = traceback.format_exc()
|
||
self.task_context.set_current_action('Exited with error.')
|
||
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}')
|
||
|
||
self.task_wrapper = self.ap.task_mgr.create_task(
|
||
exception_wrapper(),
|
||
kind='platform-adapter',
|
||
name=f'platform-adapter-{self.adapter.__class__.__name__}',
|
||
context=self.task_context,
|
||
scopes=[
|
||
core_entities.LifecycleControlScope.APPLICATION,
|
||
core_entities.LifecycleControlScope.PLATFORM,
|
||
],
|
||
instance_uuid=self.execution_context.instance_uuid,
|
||
workspace_uuid=self.execution_context.workspace_uuid,
|
||
placement_generation=(self.execution_context.placement_generation),
|
||
)
|
||
|
||
async def shutdown(self):
|
||
async with self._shutdown_lock:
|
||
if self._shutdown_complete:
|
||
return
|
||
|
||
wrapper = self.task_wrapper
|
||
self.task_wrapper = None
|
||
try:
|
||
await asyncio.wait_for(self.adapter.kill(), timeout=15)
|
||
finally:
|
||
if wrapper is not None:
|
||
self.ap.task_mgr.cancel_task(wrapper.id)
|
||
if wrapper.task is not asyncio.current_task():
|
||
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||
await asyncio.wait_for(wrapper.task, timeout=5)
|
||
self._shutdown_complete = True
|
||
|
||
|
||
# 控制QQ消息输入输出的类
|
||
class PlatformManager:
|
||
# ====== 4.0 ======
|
||
ap: app.Application = None
|
||
|
||
bots: list[RuntimeBot]
|
||
|
||
websocket_proxy_bots: dict[str, RuntimeBot]
|
||
|
||
adapter_components: list[engine.Component]
|
||
|
||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]]
|
||
|
||
def __init__(self, ap: app.Application = None):
|
||
self.ap = ap
|
||
self._bots_by_key: dict[tuple[str, str, str], RuntimeBot] = {}
|
||
self._bot_keys_by_workspace: dict[
|
||
str,
|
||
set[tuple[str, str, str]],
|
||
] = {}
|
||
self._bot_keys_by_uuid: dict[str, set[tuple[str, str, str]]] = {}
|
||
self.websocket_proxy_bots = {}
|
||
self.adapter_components = []
|
||
self.adapter_dict = {}
|
||
self._scope_generations: dict[tuple[str, str], int] = {}
|
||
self._proxy_last_accessed: dict[str, float] = {}
|
||
self._runtime_mutation_lock = asyncio.Lock()
|
||
|
||
@staticmethod
|
||
def _runtime_bot_key(bot: RuntimeBot) -> tuple[str, str, str]:
|
||
context = getattr(bot, 'execution_context', None)
|
||
instance_uuid = str(getattr(context, 'instance_uuid', '__test_instance__'))
|
||
workspace_uuid = str(
|
||
getattr(bot, 'workspace_uuid', None) or getattr(context, 'workspace_uuid', '__test_workspace__')
|
||
)
|
||
bot_uuid = str(
|
||
getattr(getattr(bot, 'bot_entity', None), 'uuid', None)
|
||
or getattr(context, 'bot_uuid', None)
|
||
or f'__runtime_{id(bot)}'
|
||
)
|
||
return instance_uuid, workspace_uuid, bot_uuid
|
||
|
||
def _register_runtime_bot(self, bot: RuntimeBot) -> RuntimeBot | None:
|
||
key = self._runtime_bot_key(bot)
|
||
previous = self._bots_by_key.get(key)
|
||
self._bots_by_key[key] = bot
|
||
self._bot_keys_by_workspace.setdefault(key[1], set()).add(key)
|
||
self._bot_keys_by_uuid.setdefault(key[2], set()).add(key)
|
||
return previous
|
||
|
||
def _pop_runtime_bot(
|
||
self,
|
||
key: tuple[str, str, str],
|
||
) -> RuntimeBot | None:
|
||
bot = self._bots_by_key.pop(key, None)
|
||
if bot is None:
|
||
return None
|
||
workspace_keys = self._bot_keys_by_workspace.get(key[1])
|
||
if workspace_keys is not None:
|
||
workspace_keys.discard(key)
|
||
if not workspace_keys:
|
||
self._bot_keys_by_workspace.pop(key[1], None)
|
||
uuid_keys = self._bot_keys_by_uuid.get(key[2])
|
||
if uuid_keys is not None:
|
||
uuid_keys.discard(key)
|
||
if not uuid_keys:
|
||
self._bot_keys_by_uuid.pop(key[2], None)
|
||
return bot
|
||
|
||
@property
|
||
def bots(self) -> list[RuntimeBot]:
|
||
"""Compatibility view over the indexed platform runtime registry."""
|
||
|
||
return list(self._bots_by_key.values())
|
||
|
||
@bots.setter
|
||
def bots(self, bots: list[RuntimeBot]) -> None:
|
||
self._bots_by_key = {}
|
||
self._bot_keys_by_workspace = {}
|
||
self._bot_keys_by_uuid = {}
|
||
for bot in bots:
|
||
self._register_runtime_bot(bot)
|
||
|
||
def _max_workspace_proxies(self) -> int:
|
||
instance_data = getattr(
|
||
getattr(self.ap, 'instance_config', None),
|
||
'data',
|
||
{},
|
||
)
|
||
value = instance_data.get('system', {}).get('websocket_retention', {}).get('max_workspace_proxies', 1024)
|
||
try:
|
||
return max(int(value), 1)
|
||
except (TypeError, ValueError):
|
||
return 1024
|
||
|
||
async def _evict_idle_websocket_proxy_unlocked(self) -> None:
|
||
"""Make room without interrupting a live socket or in-flight query."""
|
||
|
||
if len(self.websocket_proxy_bots) < self._max_workspace_proxies():
|
||
return
|
||
from .sources.websocket_manager import WebSocketScope, ws_connection_manager
|
||
|
||
for workspace_uuid in sorted(
|
||
self.websocket_proxy_bots,
|
||
key=lambda item: self._proxy_last_accessed.get(item, 0.0),
|
||
):
|
||
proxy_bot = self.websocket_proxy_bots[workspace_uuid]
|
||
listener_tasks = getattr(proxy_bot.adapter, 'inbound_listener_tasks', ())
|
||
if any(not task.done() for task in tuple(listener_tasks)):
|
||
continue
|
||
scope = WebSocketScope.from_context(proxy_bot.execution_context)
|
||
if ws_connection_manager.get_stats(scope=scope)['total_connections'] > 0:
|
||
continue
|
||
self.websocket_proxy_bots.pop(workspace_uuid, None)
|
||
self._proxy_last_accessed.pop(workspace_uuid, None)
|
||
await proxy_bot.shutdown()
|
||
if not self._bot_keys_by_workspace.get(workspace_uuid):
|
||
self._scope_generations.pop(
|
||
(proxy_bot.execution_context.instance_uuid, workspace_uuid),
|
||
None,
|
||
)
|
||
return
|
||
raise RuntimeError('WebSocket Workspace proxy capacity reached and every proxy is active')
|
||
|
||
async def _observe_execution_context(self, context: ExecutionContext) -> None:
|
||
"""Shutdown superseded Workspace adapters when placement advances."""
|
||
|
||
async with self._runtime_mutation_lock:
|
||
await self._observe_execution_context_unlocked(context)
|
||
|
||
async def _observe_execution_context_unlocked(self, context: ExecutionContext) -> None:
|
||
scope = (context.instance_uuid, context.workspace_uuid)
|
||
previous_generation = self._scope_generations.get(scope)
|
||
if previous_generation is not None and context.placement_generation < previous_generation:
|
||
raise WorkspaceInvariantError('Platform runtime placement generation rolled back')
|
||
if previous_generation == context.placement_generation:
|
||
return
|
||
if previous_generation is not None:
|
||
proxy_bot = self.websocket_proxy_bots.pop(context.workspace_uuid, None)
|
||
self._proxy_last_accessed.pop(context.workspace_uuid, None)
|
||
if proxy_bot is not None and proxy_bot.enable:
|
||
await proxy_bot.shutdown()
|
||
for key in tuple(self._bot_keys_by_workspace.get(context.workspace_uuid, ())):
|
||
bot = self._pop_runtime_bot(key)
|
||
if bot is None:
|
||
continue
|
||
if bot.enable:
|
||
await bot.shutdown()
|
||
self._scope_generations[scope] = context.placement_generation
|
||
|
||
async def initialize(self):
|
||
# delete all bot log images
|
||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||
|
||
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||
|
||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||
for component in self.adapter_components:
|
||
if component.metadata.name in disabled_adapters:
|
||
continue
|
||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||
self.adapter_dict = adapter_dict
|
||
|
||
# Filter out disabled adapters from components list (for API responses)
|
||
if disabled_adapters:
|
||
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||
|
||
await self.load_bots_from_db()
|
||
|
||
# OSS may have no persisted bots. Its singleton Workspace still needs
|
||
# a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
|
||
# request/runtime context instead of guessing among Workspaces.
|
||
if not self.websocket_proxy_bots:
|
||
try:
|
||
binding = await self.ap.workspace_service.get_execution_binding()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
await self.get_websocket_proxy_bot(
|
||
ExecutionContext(
|
||
instance_uuid=binding.instance_uuid,
|
||
workspace_uuid=binding.workspace_uuid,
|
||
placement_generation=binding.placement_generation,
|
||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||
)
|
||
)
|
||
|
||
@property
|
||
def websocket_proxy_bot(self) -> RuntimeBot:
|
||
"""Compatibility accessor that is safe only for a singleton Workspace."""
|
||
|
||
if len(self.websocket_proxy_bots) != 1:
|
||
raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
|
||
return next(iter(self.websocket_proxy_bots.values()))
|
||
|
||
@websocket_proxy_bot.setter
|
||
def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
|
||
"""Keep isolated tests that inject one proxy bot working."""
|
||
|
||
workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
|
||
self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
|
||
|
||
@staticmethod
|
||
def _normalize_execution_context(
|
||
context: ExecutionContext | RequestContext,
|
||
*,
|
||
bot_uuid: str | None = None,
|
||
pipeline_uuid: str | None = None,
|
||
) -> ExecutionContext:
|
||
if isinstance(context, RequestContext):
|
||
return ExecutionContext.from_request(
|
||
context,
|
||
bot_uuid=bot_uuid,
|
||
pipeline_uuid=pipeline_uuid,
|
||
)
|
||
if not isinstance(context, ExecutionContext):
|
||
raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
|
||
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
||
raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
|
||
if context.placement_generation <= 0:
|
||
raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
|
||
updates = {}
|
||
if bot_uuid is not None:
|
||
if context.bot_uuid not in (None, bot_uuid):
|
||
raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
|
||
updates['bot_uuid'] = bot_uuid
|
||
if pipeline_uuid is not None:
|
||
if context.pipeline_uuid not in (None, pipeline_uuid):
|
||
raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
|
||
updates['pipeline_uuid'] = pipeline_uuid
|
||
return dataclasses.replace(context, **updates) if updates else context
|
||
|
||
async def get_websocket_proxy_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
) -> RuntimeBot:
|
||
execution_context = self._normalize_execution_context(context)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||
if existing is not None:
|
||
if existing.placement_generation != execution_context.placement_generation:
|
||
raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
|
||
self._proxy_last_accessed[execution_context.workspace_uuid] = time.monotonic()
|
||
return existing
|
||
|
||
await self._evict_idle_websocket_proxy_unlocked()
|
||
binding = await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
websocket_adapter_class = self.adapter_dict['websocket']
|
||
websocket_logger = EventLogger(
|
||
name='websocket-adapter',
|
||
ap=self.ap,
|
||
execution_context=execution_context,
|
||
owner='websocket-proxy-bot',
|
||
)
|
||
websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
|
||
proxy_context = dataclasses.replace(
|
||
execution_context,
|
||
instance_uuid=binding.instance_uuid,
|
||
bot_uuid='websocket-proxy-bot',
|
||
)
|
||
runtime_bot = RuntimeBot(
|
||
ap=self.ap,
|
||
bot_entity=persistence_bot.Bot(
|
||
uuid='websocket-proxy-bot',
|
||
workspace_uuid=binding.workspace_uuid,
|
||
name='WebSocket',
|
||
description='',
|
||
adapter='websocket',
|
||
adapter_config={},
|
||
enable=True,
|
||
),
|
||
adapter=websocket_adapter_inst,
|
||
logger=websocket_logger,
|
||
execution_context=proxy_context,
|
||
)
|
||
await runtime_bot.initialize()
|
||
self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
|
||
self._proxy_last_accessed[binding.workspace_uuid] = time.monotonic()
|
||
return runtime_bot
|
||
|
||
def get_running_adapters(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
|
||
execution_context = self._normalize_execution_context(context)
|
||
return [
|
||
bot.adapter
|
||
for bot in self.bots
|
||
if bot.enable
|
||
and bot.workspace_uuid == execution_context.workspace_uuid
|
||
and bot.placement_generation == execution_context.placement_generation
|
||
]
|
||
|
||
async def load_bots_from_db(self):
|
||
self.ap.logger.info('Loading bots from db...')
|
||
|
||
async with self._runtime_mutation_lock:
|
||
old_bots = [*self.websocket_proxy_bots.values(), *self.bots]
|
||
self.websocket_proxy_bots = {}
|
||
self._proxy_last_accessed = {}
|
||
self.bots = []
|
||
self._scope_generations = {}
|
||
for bot in old_bots:
|
||
if not bot.enable:
|
||
continue
|
||
try:
|
||
await bot.shutdown()
|
||
except Exception as exc:
|
||
self.ap.logger.warning(f'Failed to stop old platform runtime during reload: {exc}')
|
||
|
||
list_bindings = getattr(
|
||
self.ap.workspace_service,
|
||
'list_active_execution_bindings',
|
||
None,
|
||
)
|
||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||
cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
|
||
if cloud_runtime:
|
||
if not callable(list_bindings) or not callable(tenant_uow):
|
||
raise RuntimeError('Cloud platform loading requires explicit instance discovery and tenant UoWs')
|
||
for binding in await list_bindings():
|
||
try:
|
||
async with tenant_uow(binding.workspace_uuid):
|
||
await self._load_workspace_bots(
|
||
binding.workspace_uuid,
|
||
_binding=binding,
|
||
)
|
||
except Exception as exc:
|
||
self.ap.logger.error(
|
||
f'Failed to load Workspace bots for {binding.workspace_uuid}: {exc}\n{traceback.format_exc()}'
|
||
)
|
||
return
|
||
|
||
instance_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
|
||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||
if callable(instance_uow) and callable(tenant_scope):
|
||
async with instance_uow(self.ap.workspace_service.instance_uuid) as discovery:
|
||
workspace_uuids = list(
|
||
(
|
||
await discovery.session.scalars(
|
||
sqlalchemy.select(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
|
||
.where(
|
||
persistence_workspace.WorkspaceExecutionState.instance_uuid
|
||
== self.ap.workspace_service.instance_uuid,
|
||
persistence_workspace.WorkspaceExecutionState.state
|
||
== persistence_workspace.WorkspaceExecutionStatus.ACTIVE.value,
|
||
persistence_workspace.WorkspaceExecutionState.write_fenced.is_(False),
|
||
)
|
||
.order_by(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
|
||
)
|
||
).all()
|
||
)
|
||
else:
|
||
# Compatibility for lightweight tests and pre-tenancy managers.
|
||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||
workspace_uuids = sorted({bot.workspace_uuid for bot in result.all()})
|
||
|
||
for workspace_uuid in workspace_uuids:
|
||
try:
|
||
if callable(tenant_scope):
|
||
async with tenant_scope(workspace_uuid):
|
||
await self._load_workspace_bots(workspace_uuid)
|
||
else:
|
||
await self._load_workspace_bots(workspace_uuid)
|
||
except Exception as e:
|
||
self.ap.logger.error(
|
||
f'Failed to load Workspace bots for {workspace_uuid}: {e}\n{traceback.format_exc()}'
|
||
)
|
||
|
||
async def _load_workspace_bots(
|
||
self,
|
||
workspace_uuid: str,
|
||
*,
|
||
_binding=None,
|
||
) -> None:
|
||
result = await self.ap.persistence_mgr.execute_async(
|
||
sqlalchemy.select(persistence_bot.Bot)
|
||
.where(persistence_bot.Bot.workspace_uuid == workspace_uuid)
|
||
.order_by(persistence_bot.Bot.uuid)
|
||
)
|
||
binding = _binding
|
||
for bot in result.all():
|
||
try:
|
||
if binding is None:
|
||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
|
||
execution_context = ExecutionContext(
|
||
instance_uuid=binding.instance_uuid,
|
||
workspace_uuid=binding.workspace_uuid,
|
||
placement_generation=binding.placement_generation,
|
||
bot_uuid=bot.uuid,
|
||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||
)
|
||
await self.load_bot(
|
||
execution_context,
|
||
bot,
|
||
_binding_validated=True,
|
||
)
|
||
except platform_errors.AdapterNotFoundError as e:
|
||
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
|
||
except Exception as e:
|
||
self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')
|
||
|
||
async def load_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
|
||
*,
|
||
_binding_validated: bool = False,
|
||
) -> RuntimeBot:
|
||
"""加载机器人"""
|
||
if isinstance(bot_entity, sqlalchemy.Row):
|
||
bot_entity = persistence_bot.Bot(**bot_entity._mapping)
|
||
elif isinstance(bot_entity, dict):
|
||
bot_entity = persistence_bot.Bot(**bot_entity)
|
||
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
|
||
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
|
||
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
|
||
if not _binding_validated:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
if not _binding_validated:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
|
||
logger = EventLogger(
|
||
name=f'platform-adapter-{bot_entity.name}',
|
||
ap=self.ap,
|
||
execution_context=execution_context,
|
||
owner=bot_entity.uuid,
|
||
)
|
||
|
||
if bot_entity.adapter not in self.adapter_dict:
|
||
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
|
||
|
||
adapter_inst = self.adapter_dict[bot_entity.adapter](
|
||
bot_entity.adapter_config,
|
||
logger,
|
||
)
|
||
if hasattr(adapter_inst, 'ap'):
|
||
adapter_inst.ap = self.ap
|
||
|
||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||
adapter_inst.set_bot_uuid(bot_entity.uuid)
|
||
|
||
runtime_bot = RuntimeBot(
|
||
ap=self.ap,
|
||
bot_entity=bot_entity,
|
||
adapter=adapter_inst,
|
||
logger=logger,
|
||
execution_context=execution_context,
|
||
)
|
||
|
||
await runtime_bot.initialize()
|
||
|
||
bot_key = self._runtime_bot_key(runtime_bot)
|
||
existing_bot = self._bots_by_key.get(bot_key)
|
||
if existing_bot is not None and existing_bot is not runtime_bot:
|
||
try:
|
||
if existing_bot.enable:
|
||
await existing_bot.shutdown()
|
||
except BaseException:
|
||
if runtime_bot.enable:
|
||
await runtime_bot.shutdown()
|
||
raise
|
||
self._register_runtime_bot(runtime_bot)
|
||
|
||
return runtime_bot
|
||
|
||
async def get_bot_by_uuid(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_uuid: str,
|
||
) -> RuntimeBot | None:
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context(execution_context)
|
||
proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||
if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
|
||
if proxy_bot.placement_generation != execution_context.placement_generation:
|
||
return None
|
||
return proxy_bot
|
||
bot = self._bots_by_key.get(
|
||
(
|
||
execution_context.instance_uuid,
|
||
execution_context.workspace_uuid,
|
||
bot_uuid,
|
||
)
|
||
)
|
||
if bot is None or bot.placement_generation != execution_context.placement_generation:
|
||
return None
|
||
return bot
|
||
|
||
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
|
||
"""Resolve an opaque public bot UUID without consulting request headers."""
|
||
|
||
try:
|
||
normalized = str(uuid.UUID(route_key))
|
||
except (ValueError, AttributeError, TypeError):
|
||
return None
|
||
keys = tuple(self._bot_keys_by_uuid.get(normalized, ()))
|
||
if len(keys) != 1:
|
||
return None
|
||
key = keys[0]
|
||
bot = self._bots_by_key.get(key)
|
||
if bot is None:
|
||
return None
|
||
try:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
bot.workspace_uuid,
|
||
expected_generation=bot.placement_generation,
|
||
)
|
||
except Exception:
|
||
return None
|
||
if self._bots_by_key.get(key) is bot:
|
||
return bot
|
||
return None
|
||
|
||
async def remove_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_uuid: str,
|
||
) -> None:
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
key = (
|
||
execution_context.instance_uuid,
|
||
execution_context.workspace_uuid,
|
||
bot_uuid,
|
||
)
|
||
bot = self._bots_by_key.get(key)
|
||
if bot is not None and bot.placement_generation == execution_context.placement_generation:
|
||
if bot.enable:
|
||
await bot.shutdown()
|
||
self._pop_runtime_bot(key)
|
||
|
||
def get_available_adapters_info(self) -> list[dict]:
|
||
return [
|
||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
|
||
]
|
||
|
||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||
for component in self.adapter_components:
|
||
if component.metadata.name == name:
|
||
return component.to_plain_dict()
|
||
return None
|
||
|
||
def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:
|
||
for component in self.adapter_components:
|
||
if component.metadata.name == name:
|
||
return component
|
||
return None
|
||
|
||
async def run(self):
|
||
# This method will only be called when the application launching
|
||
for proxy_bot in self.websocket_proxy_bots.values():
|
||
await proxy_bot.run()
|
||
|
||
for bot in self.bots:
|
||
if bot.enable:
|
||
await bot.run()
|
||
|
||
async def shutdown(self):
|
||
async with self._runtime_mutation_lock:
|
||
runtime_bots = [*self.websocket_proxy_bots.values(), *self.bots]
|
||
self.websocket_proxy_bots = {}
|
||
self._proxy_last_accessed = {}
|
||
self.bots = []
|
||
for bot in runtime_bots:
|
||
if not bot.enable:
|
||
continue
|
||
try:
|
||
await bot.shutdown()
|
||
except Exception as exc:
|
||
self.ap.logger.warning(f'Failed to stop platform runtime during shutdown: {exc}')
|
||
self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)
|