diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index 14a086c8b..e0c7d1252 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -38,6 +38,10 @@ class BotsRouterGroup(group.RouterGroup): logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) return self.success(data={'logs': logs, 'total_count': total_count}) + @self.route('//event-routes/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + return self.success(data=await self.ap.bot_service.list_event_route_statuses(bot_uuid)) + async def _dry_run_event_route(bot_uuid: str) -> str: json_data = await quart.request.json if not isinstance(json_data, dict): diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 3c87357d9..43d457ea4 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -20,6 +20,7 @@ class BotService: FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found' FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible' FAILURE_INVALID_EVENT = 'invalid_event' + ROUTE_TRACE_KIND = 'event_route_trace' BOT_FIELDS = { 'uuid', @@ -103,6 +104,41 @@ class BotService: def _format_diagnostic_steps(cls, diagnostic_details: list[dict[str, typing.Any]] | None) -> list[str]: return [cls._format_diagnostic_step(step) for step in diagnostic_details or []] + @classmethod + def _event_route_status_from_log(cls, log: typing.Any) -> dict[str, typing.Any] | None: + if hasattr(log, 'to_json'): + log_data = log.to_json() + elif isinstance(log, dict): + log_data = log + else: + log_data = { + 'seq_id': getattr(log, 'seq_id', None), + 'timestamp': getattr(log, 'timestamp', None), + 'level': getattr(getattr(log, 'level', None), 'value', getattr(log, 'level', None)), + 'text': getattr(log, 'text', None), + 'metadata': getattr(log, 'metadata', None), + } + + metadata = log_data.get('metadata') + if not isinstance(metadata, dict) or metadata.get('kind') != cls.ROUTE_TRACE_KIND: + return None + + return { + 'binding_id': metadata.get('binding_id'), + 'event_pattern': metadata.get('event_pattern'), + 'event_type': metadata.get('event_type'), + 'target_type': metadata.get('target_type'), + 'target_uuid': metadata.get('target_uuid') or '', + 'last_status': metadata.get('status'), + 'failure_code': metadata.get('failure_code'), + 'reason': metadata.get('reason') or log_data.get('text') or '', + 'run_id': metadata.get('run_id'), + 'timestamp': log_data.get('timestamp'), + 'seq_id': log_data.get('seq_id'), + 'level': log_data.get('level'), + 'message': log_data.get('text') or '', + } + @staticmethod def _target_kind(target_type: typing.Any, target_kind: str | None = None) -> str | None: if target_kind: @@ -644,6 +680,71 @@ class BotService: return [log.to_json() for log in logs], total_count + async def list_event_route_statuses(self, bot_uuid: str) -> dict[str, typing.Any]: + """Return recent runtime status for Bot event routes from in-memory Bot logs.""" + from ....platform.botmgr import RuntimeBot + + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception('Bot not found') + + latest_by_binding: dict[str, dict[str, typing.Any]] = {} + unmatched_events: list[dict[str, typing.Any]] = [] + for log in getattr(runtime_bot.logger, 'logs', []): + status = self._event_route_status_from_log(log) + if status is None: + continue + binding_id = status.get('binding_id') + if binding_id: + latest_by_binding[str(binding_id)] = status + else: + unmatched_events.append(status) + + raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', []) + bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings) + routes: list[dict[str, typing.Any]] = [] + current_binding_ids: set[str] = set() + for index, binding in enumerate(bindings): + binding_id = binding.get('id') + if binding_id: + current_binding_ids.add(str(binding_id)) + route_status = { + 'binding_id': binding_id, + 'event_pattern': binding.get('event_pattern'), + 'event_type': None, + 'target_type': binding.get('target_type'), + 'target_uuid': binding.get('target_uuid') or '', + 'last_status': None, + 'failure_code': None, + 'reason': None, + 'run_id': None, + 'timestamp': None, + 'seq_id': None, + 'level': None, + 'message': '', + 'order': binding.get('order', index), + 'enabled': binding.get('enabled', True), + 'current': True, + } + if binding_id and str(binding_id) in latest_by_binding: + route_status.update(latest_by_binding[str(binding_id)]) + route_status['order'] = binding.get('order', index) + route_status['enabled'] = binding.get('enabled', True) + route_status['current'] = True + routes.append(route_status) + + stale_routes = [ + {**status, 'current': False} + for binding_id, status in latest_by_binding.items() + if binding_id not in current_binding_ids + ] + + return { + 'routes': routes, + 'unmatched_events': unmatched_events[-10:], + 'stale_routes': stale_routes, + } + async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None: """Send message to a specific target via bot diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 939e6d560..163f09793 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -305,6 +305,37 @@ class RuntimeBot: """Return the selected event binding plus per-binding diagnostic steps.""" return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) + async def _record_event_route_trace( + self, + *, + event_type: str, + status: str, + text: str, + level: str = 'info', + binding: dict[str, typing.Any] | None = None, + target_type: str | None = None, + target_uuid: str | None = None, + failure_code: str | None = None, + reason: str | None = None, + run_id: str | None = None, + ) -> None: + """Record structured event routing state while preserving the human log.""" + binding = binding or {} + metadata = { + 'kind': 'event_route_trace', + 'event_type': event_type, + 'status': status, + 'binding_id': binding.get('id'), + 'event_pattern': binding.get('event_pattern'), + 'target_type': target_type or binding.get('target_type'), + 'target_uuid': target_uuid or binding.get('target_uuid') or '', + 'failure_code': failure_code, + 'reason': reason or text, + 'run_id': run_id, + } + log_method = getattr(self.logger, level, self.logger.info) + await log_method(text, metadata=metadata) + def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None: """Return the first Pipeline target configured for an event type.""" matched: list[tuple[int, int, str]] = [] @@ -773,10 +804,24 @@ class RuntimeBot: event_binding = self._resolve_eba_event_binding(event, event_type) if event_binding is None: - await self.logger.info(f'Platform event {event_type} ignored: no event route matched') + await self._record_event_route_trace( + event_type=event_type, + status='not_matched', + failure_code='route_not_found', + reason='No event route matched', + text=f'Platform event {event_type} ignored: no event route matched', + ) return target_type = event_binding.get('target_type') + await self._record_event_route_trace( + event_type=event_type, + status='matched', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + text=f'EBA event {event_type} matched route {event_binding.get("id") or ""}'.strip(), + ) if target_type == 'discard': if isinstance(event, platform_events.MessageReceivedEvent): await self._dispatch_eba_message_to_pipeline( @@ -785,12 +830,35 @@ class RuntimeBot: pipeline_uuid=self.PIPELINE_DISCARD, routed_by_event_binding=True, ) + await self._record_event_route_trace( + event_type=event_type, + status='discarded', + binding=event_binding, + target_type=target_type, + text=f'EBA event {event_type} discarded by event binding', + ) return - await self.logger.info(f'EBA event {event_type} discarded by event binding') + await self._record_event_route_trace( + event_type=event_type, + status='discarded', + binding=event_binding, + target_type=target_type, + text=f'EBA event {event_type} discarded by event binding', + ) return if target_type == 'pipeline': if not self._is_message_event_type(event_type): - await self.logger.warning(f'EBA event {event_type} ignored Pipeline target for non-message event') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + failure_code='processor_incompatible', + reason='Pipeline targets only support message events', + text=f'EBA event {event_type} ignored Pipeline target for non-message event', + ) return await self._dispatch_eba_message_to_pipeline( event, @@ -798,26 +866,82 @@ class RuntimeBot: pipeline_uuid=event_binding.get('target_uuid'), routed_by_event_binding=True, ) + await self._record_event_route_trace( + event_type=event_type, + status='delivered', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + text=f'EBA event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(), + ) return if target_type != 'agent': - await self.logger.warning(f'EBA event {event_type} ignored unsupported target type {target_type}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=event_binding.get('target_uuid'), + failure_code='processor_incompatible', + reason=f'Unsupported event binding target type: {target_type}', + text=f'EBA event {event_type} ignored unsupported target type {target_type}', + ) return target_uuid = event_binding.get('target_uuid') agent = await self.ap.agent_service.get_agent(target_uuid) if not agent or agent.get('kind') != 'agent': - await self.logger.warning(f'EBA event {event_type} target agent not found: {target_uuid}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_not_found', + reason='Agent target not found', + text=f'EBA event {event_type} target agent not found: {target_uuid}', + ) return if not agent.get('enabled', True): - await self.logger.info(f'EBA event {event_type} target agent disabled: {target_uuid}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_disabled', + reason='Agent target is disabled', + text=f'EBA event {event_type} target agent disabled: {target_uuid}', + ) return if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type): - await self.logger.info(f'EBA event {event_type} target agent does not support this event: {target_uuid}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_incompatible', + reason='Agent target does not support this event type', + text=f'EBA event {event_type} target agent does not support this event: {target_uuid}', + ) return binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid) if binding is None: - await self.logger.warning(f'EBA event {event_type} target agent has no runner: {target_uuid}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='warning', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='processor_not_found', + reason='Agent target has no runner', + text=f'EBA event {event_type} target agent has no runner: {target_uuid}', + ) return envelope = self._eba_event_to_agent_envelope(event, adapter) @@ -826,15 +950,42 @@ class RuntimeBot: async for output in self.ap.agent_run_orchestrator.run(envelope, binding): outputs.append(output) except Exception: - await self.logger.error(f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}') + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='runner_failed', + reason='Agent runner failed', + text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}', + ) return try: await self._deliver_agent_outputs(envelope, outputs) except Exception: - await self.logger.error( - f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}' + await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='delivery_failed', + reason='Agent output delivery failed', + text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}', ) + return + await self._record_event_route_trace( + event_type=event_type, + status='delivered', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + text=f'EBA event {event_type} delivered to Agent {target_uuid}', + ) async def _record_discarded_message( self, diff --git a/src/langbot/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py index 681648656..5c86d8cc9 100644 --- a/src/langbot/pkg/platform/logger.py +++ b/src/langbot/pkg/platform/logger.py @@ -41,6 +41,9 @@ class EventLog(pydantic.BaseModel): message_session_id: typing.Optional[str] = None """消息会话ID,仅收发消息事件有值""" + metadata: typing.Optional[dict[str, typing.Any]] = None + """Structured machine-readable metadata for product surfaces.""" + def to_json(self) -> dict: return { 'seq_id': self.seq_id, @@ -49,6 +52,7 @@ class EventLog(pydantic.BaseModel): 'text': self.text, 'images': self.images, 'message_session_id': self.message_session_id, + 'metadata': self.metadata, } @@ -131,6 +135,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): try: image_keys = [] @@ -161,6 +166,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): text=text, images=image_keys, message_session_id=message_session_id, + metadata=metadata, ) ) self.seq_id_inc += 1 @@ -179,6 +185,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.INFO, @@ -186,6 +193,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def debug( @@ -194,6 +202,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.DEBUG, @@ -201,6 +210,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def warning( @@ -209,6 +219,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.WARNING, @@ -216,6 +227,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) async def error( @@ -224,6 +236,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images: typing.Optional[list[platform_message.Image]] = None, message_session_id: typing.Optional[str] = None, no_throw: bool = True, + metadata: typing.Optional[dict[str, typing.Any]] = None, ): await self._add_log( level=EventLogLevel.ERROR, @@ -231,4 +244,5 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger): images=images, message_session_id=message_session_id, no_throw=no_throw, + metadata=metadata, ) diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 19de8d246..07654b5fd 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -97,6 +97,27 @@ def fake_bot_app(): app.bot_service.update_bot = AsyncMock(return_value={}) app.bot_service.delete_bot = AsyncMock() app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1)) + app.bot_service.list_event_route_statuses = AsyncMock( + return_value={ + 'routes': [ + { + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'last_status': 'delivered', + 'failure_code': None, + 'reason': 'delivered', + 'timestamp': 100, + 'seq_id': 1, + 'current': True, + } + ], + 'unmatched_events': [], + 'stale_routes': [], + } + ) app.bot_service.dry_run_event_route = AsyncMock( return_value={ 'matched': True, @@ -268,6 +289,26 @@ class TestBotEventRouteDryRunEndpoint: ) +@pytest.mark.usefixtures('mock_circular_import_chain') +class TestBotEventRouteStatusEndpoint: + """Tests for bot event route runtime status endpoint.""" + + @pytest.mark.asyncio + async def test_get_event_route_status_success(self, quart_test_client, fake_bot_app): + """GET event route status returns recent route traces.""" + response = await quart_test_client.get( + '/api/v1/platform/bots/test-bot-uuid/event-routes/status', + headers={'Authorization': 'Bearer test_token'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['routes'][0]['binding_id'] == 'binding-1' + assert data['data']['routes'][0]['last_status'] == 'delivered' + fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with('test-bot-uuid') + + @pytest.mark.usefixtures('mock_circular_import_chain') class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 546d44a8f..d061f1678 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -997,6 +997,134 @@ class TestBotServiceListEventLogs: assert total == 5 +class TestBotServiceEventRouteStatuses: + """Tests for event route runtime status aggregation.""" + + async def test_list_event_route_statuses_bot_not_found_raises(self): + """Raises Exception when runtime bot not found.""" + ap = SimpleNamespace() + ap.platform_mgr = SimpleNamespace() + ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None) + + service = BotService(ap) + + with pytest.raises(Exception, match='Bot not found'): + await service.list_event_route_statuses('missing-bot') + + async def test_list_event_route_statuses_merges_latest_trace_by_binding(self): + """Current route definitions are enriched with the latest trace log.""" + ap = SimpleNamespace() + ap.platform_mgr = SimpleNamespace() + + runtime_bot = SimpleNamespace() + runtime_bot.bot_entity = SimpleNamespace( + event_bindings=[ + { + 'id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'enabled': True, + 'order': 0, + }, + { + 'id': 'binding-2', + 'event_pattern': 'message.received', + 'target_type': 'pipeline', + 'target_uuid': 'pipeline-1', + 'enabled': False, + 'order': 1, + }, + ] + ) + runtime_bot.logger = SimpleNamespace( + logs=[ + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 1, + 'timestamp': 100, + 'level': 'info', + 'text': 'old matched', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'status': 'matched', + 'failure_code': None, + 'reason': 'matched', + 'run_id': None, + }, + } + ) + ), + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 2, + 'timestamp': 120, + 'level': 'error', + 'text': 'runner failed', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': 'binding-1', + 'event_pattern': 'platform.member.joined', + 'event_type': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'status': 'failed', + 'failure_code': 'runner_failed', + 'reason': 'Agent runner failed', + 'run_id': None, + }, + } + ) + ), + SimpleNamespace( + to_json=Mock( + return_value={ + 'seq_id': 3, + 'timestamp': 130, + 'level': 'info', + 'text': 'no route', + 'metadata': { + 'kind': 'event_route_trace', + 'binding_id': None, + 'event_pattern': None, + 'event_type': 'platform.member.left', + 'target_type': None, + 'target_uuid': '', + 'status': 'not_matched', + 'failure_code': 'route_not_found', + 'reason': 'No event route matched', + 'run_id': None, + }, + } + ) + ), + ] + ) + ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot) + + service = BotService(ap) + result = await service.list_event_route_statuses('bot-1') + + assert len(result['routes']) == 2 + assert result['routes'][0]['binding_id'] == 'binding-1' + assert result['routes'][0]['last_status'] == 'failed' + assert result['routes'][0]['failure_code'] == 'runner_failed' + assert result['routes'][0]['timestamp'] == 120 + assert result['routes'][0]['current'] is True + assert result['routes'][1]['binding_id'] == 'binding-2' + assert result['routes'][1]['last_status'] is None + assert result['routes'][1]['enabled'] is False + assert result['unmatched_events'][0]['event_type'] == 'platform.member.left' + assert result['unmatched_events'][0]['failure_code'] == 'route_not_found' + + class TestBotServiceSendMessage: """Tests for send_message method.""" diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index cff005914..04740b86f 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -3,7 +3,9 @@ RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests """ from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock + +import pytest class TestMatchOperator: @@ -54,6 +56,90 @@ class TestMatchOperator: assert cls._match_operator('hello', 'unknown_op', 'hello') is False +class TestEventRouteTrace: + """Test structured event route trace logging.""" + + @staticmethod + def _make_bot(event_bindings: list[dict]): + from langbot.pkg.platform.botmgr import RuntimeBot + + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(uuid='bot-1', event_bindings=event_bindings) + bot.logger = SimpleNamespace( + info=AsyncMock(), + warning=AsyncMock(), + error=AsyncMock(), + ) + return bot + + @pytest.mark.asyncio + async def test_dispatch_no_matching_route_records_trace(self): + """A route miss is visible as structured route trace metadata.""" + bot = self._make_bot([]) + + await bot._dispatch_eba_event_to_agent(SimpleNamespace(type='platform.member.joined'), Mock()) + + bot.logger.info.assert_awaited_once() + _, kwargs = bot.logger.info.await_args + metadata = kwargs['metadata'] + assert metadata['kind'] == 'event_route_trace' + assert metadata['event_type'] == 'platform.member.joined' + assert metadata['status'] == 'not_matched' + assert metadata['failure_code'] == 'route_not_found' + + @pytest.mark.asyncio + async def test_record_event_route_trace_includes_binding_and_target(self): + """Trace metadata preserves binding and processor identifiers.""" + bot = self._make_bot([]) + + await bot._record_event_route_trace( + event_type='platform.member.joined', + status='failed', + level='warning', + binding={ + 'id': 'binding-1', + 'event_pattern': 'platform.member.*', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + }, + failure_code='processor_disabled', + reason='Agent target is disabled', + text='disabled', + ) + + bot.logger.warning.assert_awaited_once() + _, kwargs = bot.logger.warning.await_args + metadata = kwargs['metadata'] + assert metadata['binding_id'] == 'binding-1' + assert metadata['event_pattern'] == 'platform.member.*' + assert metadata['target_type'] == 'agent' + assert metadata['target_uuid'] == 'agent-1' + assert metadata['status'] == 'failed' + + +class TestEventLoggerMetadata: + """Test platform EventLogger metadata compatibility.""" + + @pytest.mark.asyncio + async def test_metadata_is_serialized_without_breaking_no_throw_position(self): + """Metadata is optional and no_throw remains the fourth positional argument.""" + from langbot.pkg.platform.logger import EventLogger + + logger = EventLogger(name='test', ap=SimpleNamespace()) + + await logger.info('plain log', None, None, False) + await logger.info( + 'route trace', + metadata={'kind': 'event_route_trace', 'status': 'matched'}, + ) + + assert logger.logs[0].to_json()['metadata'] is None + assert logger.logs[1].to_json()['metadata'] == { + 'kind': 'event_route_trace', + 'status': 'matched', + } + + class TestResolvePipelineUuid: """Test the resolve_pipeline_uuid method.""" diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index 6a7e7db96..75ce722a3 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { UseFormReturn } from 'react-hook-form'; @@ -19,6 +19,7 @@ import { ListChecks, Plus, Play, + RefreshCw, Trash2, Workflow, XCircle, @@ -79,6 +80,7 @@ import { EventBinding, Agent, BotRouteDryRunResult, + BotEventRouteStatus, } from '@/app/infra/entities/api'; import { backendClient } from '@/app/infra/http'; @@ -213,6 +215,36 @@ function targetTypeLabel( return t('bots.targetAgent'); } +function routeStatusLabel( + status: BotEventRouteStatus['last_status'] | undefined, + t: TFunction, +) { + if (!status) return t('bots.routeStatusIdle'); + const key = `bots.routeStatus.${status}`; + const label = t(key); + return label === key ? String(status) : label; +} + +function routeStatusBadgeClass( + status: BotEventRouteStatus['last_status'] | undefined, +) { + if (status === 'delivered') { + return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-300'; + } + if (status === 'matched' || status === 'discarded') { + return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/30 dark:text-sky-300'; + } + if (status === 'failed' || status === 'not_matched') { + return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300'; + } + return 'border-border bg-muted/40 text-muted-foreground'; +} + +function formatRouteStatusTime(timestamp: number | null | undefined) { + if (!timestamp) return ''; + return new Date(timestamp * 1000).toLocaleString(); +} + // ── target combobox (type + target merged, with search + groups) ─────────────── // Encoded value: "discard", "agent:", "pipeline:" @@ -843,6 +875,7 @@ function RouteDryRunDialog({ interface BindingCardProps { binding: EventBinding; globalIndex: number; + routeStatus?: BotEventRouteStatus; eventOptions: string[]; agentOptions: Agent[]; expandedIds: Set; @@ -856,6 +889,7 @@ interface BindingCardProps { function BindingCardContent({ binding, globalIndex, + routeStatus, eventOptions, agentOptions, expandedIds, @@ -870,6 +904,7 @@ function BindingCardContent({ const isExpanded = expandedIds.has(id); const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0; const pipelineAllowed = isMessageEventPattern(binding.event_pattern); + const statusTime = formatRouteStatusTime(routeStatus?.timestamp); return (
@@ -956,6 +991,23 @@ function BindingCardContent({ onUpdate={(patch) => onUpdate(globalIndex, patch)} /> +
+ + {routeStatusLabel(routeStatus?.last_status, t)} + + + {routeStatus?.failure_code || routeStatus?.reason || statusTime} + +
+ {!pipelineAllowed && binding.target_type === 'pipeline' && ( {t('bots.unsupportedPipelineEvent')} @@ -984,6 +1036,23 @@ function BindingCardContent({
+
+ + {routeStatusLabel(routeStatus?.last_status, t)} + + + {routeStatus?.failure_code || routeStatus?.reason || statusTime} + +
+ {/* conditions panel */} {isExpanded && (
@@ -1035,6 +1104,9 @@ export default function EventBindingsEditor({ const [expandedIds, setExpandedIds] = useState>(new Set()); const [disabledSectionOpen, setDisabledSectionOpen] = useState(false); const [activeId, setActiveId] = useState(null); + const [routeStatuses, setRouteStatuses] = useState([]); + const [routeStatusLoading, setRouteStatusLoading] = useState(false); + const [routeStatusError, setRouteStatusError] = useState(null); // stable ids for dnd const nextId = useRef(0); @@ -1058,6 +1130,36 @@ export default function EventBindingsEditor({ () => (supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS), [supportedEvents], ); + const routeStatusByBinding = useMemo(() => { + const map = new Map(); + routeStatuses.forEach((status) => { + if (status.binding_id) map.set(String(status.binding_id), status); + }); + return map; + }, [routeStatuses]); + + const refreshRouteStatuses = useCallback(async () => { + if (!botId) { + setRouteStatuses([]); + setRouteStatusError(null); + return; + } + setRouteStatusLoading(true); + setRouteStatusError(null); + try { + const response = await backendClient.getBotEventRouteStatuses(botId); + setRouteStatuses(response.routes || []); + } catch (error) { + const err = error as { msg?: string }; + setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed')); + } finally { + setRouteStatusLoading(false); + } + }, [botId, t]); + + useEffect(() => { + refreshRouteStatuses(); + }, [refreshRouteStatuses]); function updateBindings(next: EventBinding[]) { form.setValue('event_bindings', next, { shouldDirty: true }); @@ -1175,6 +1277,11 @@ export default function EventBindingsEditor({ key={idsRef.current[sortIdx]} binding={binding} globalIndex={globalIdx} + routeStatus={ + binding.id + ? routeStatusByBinding.get(String(binding.id)) + : undefined + } eventOptions={eventOptions} agentOptions={agentOptions} expandedIds={expandedIds} @@ -1191,6 +1298,11 @@ export default function EventBindingsEditor({ +
+ {routeStatusError && ( +

{routeStatusError}

+ )} {/* disabled section */} {disabledBindings.length > 0 && ( @@ -1241,6 +1368,9 @@ export default function EventBindingsEditor({ key={b.id ?? i} binding={b} globalIndex={i} + routeStatus={ + b.id ? routeStatusByBinding.get(String(b.id)) : undefined + } eventOptions={eventOptions} agentOptions={agentOptions} expandedIds={expandedIds} diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index e829c232d..9e6429944 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -274,6 +274,38 @@ export interface BotRouteDryRunResult { matched_binding_index?: number | null; } +export interface BotEventRouteStatus { + binding_id?: string | null; + event_pattern?: string | null; + event_type?: string | null; + target_type?: EventBinding['target_type'] | string | null; + target_uuid?: string | null; + last_status?: + | 'matched' + | 'delivered' + | 'discarded' + | 'failed' + | 'not_matched' + | string + | null; + failure_code?: string | null; + reason?: string | null; + run_id?: string | null; + timestamp?: number | null; + seq_id?: number | null; + level?: string | null; + message?: string | null; + order?: number | null; + enabled?: boolean; + current?: boolean; +} + +export interface BotEventRouteStatusResponse { + routes: BotEventRouteStatus[]; + unmatched_events: BotEventRouteStatus[]; + stale_routes: BotEventRouteStatus[]; +} + export interface ApiRespKnowledgeBases { bases: KnowledgeBase[]; } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 6dfb6c3d0..616d7363e 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -61,6 +61,7 @@ import { ApiRespSkill, BotRouteDryRunRequest, BotRouteDryRunResult, + BotEventRouteStatusResponse, } from '@/app/infra/entities/api'; import { Plugin } from '@/app/infra/entities/plugin'; import type { PluginLogEntry } from '@/app/infra/entities/plugin'; @@ -466,6 +467,12 @@ export class BackendClient extends BaseHttpClient { ); } + public getBotEventRouteStatuses( + botId: string, + ): Promise { + return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`); + } + public deleteBot(uuid: string): Promise { return this.delete(`/api/v1/platform/bots/${uuid}`); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c7aface48..970f53205 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -392,6 +392,16 @@ const enUS = { advancedEventValues: 'Advanced event values', eventGroup: 'Group', testRoute: 'Test route', + refreshRouteStatus: 'Refresh status', + routeStatusIdle: 'No run yet', + routeStatusRefreshFailed: 'Failed to refresh route status.', + routeStatus: { + matched: 'Matched', + delivered: 'Delivered', + discarded: 'Discarded', + failed: 'Failed', + not_matched: 'Not matched', + }, dryRunTitle: 'Test event route', dryRunDescription: 'Choose an event and provide a sample payload to see which processor the current route setup will use.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f5356c16a..765a3ef82 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -399,6 +399,16 @@ const jaJP = { advancedEventValues: '高度なイベント値', eventGroup: 'グループ', testRoute: 'ルートをテスト', + refreshRouteStatus: '状態を更新', + routeStatusIdle: '実行記録なし', + routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。', + routeStatus: { + matched: '一致', + delivered: '配信済み', + discarded: '破棄済み', + failed: '失敗', + not_matched: '未一致', + }, dryRunTitle: 'イベントルートをテスト', dryRunDescription: 'イベントとサンプルペイロードを指定し、現在のルート設定でどのプロセッサーに送られるか確認します。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index c8a6e38bb..adb646ef3 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -377,6 +377,16 @@ const zhHans = { advancedEventValues: '高级事件值', eventGroup: '事件组', testRoute: '测试路由', + refreshRouteStatus: '刷新状态', + routeStatusIdle: '暂无运行记录', + routeStatusRefreshFailed: '刷新路由状态失败。', + routeStatus: { + matched: '已命中', + delivered: '已投递', + discarded: '已丢弃', + failed: '失败', + not_matched: '未命中', + }, dryRunTitle: '测试事件路由', dryRunDescription: '选择一个事件并提供示例载荷,检查当前路由配置会命中哪个处理器。',