fix(bots): simplify event routing status

This commit is contained in:
RockChinQ
2026-08-25 21:18:01 +08:00
parent 6a6a2b865b
commit 68620c4572
7 changed files with 151 additions and 84 deletions
+15 -28
View File
@@ -276,14 +276,10 @@ class BotService:
) )
return result.first() return result.first()
async def _get_agent_entity( async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None:
self, context: TenantContext, agent_uuid: str
) -> persistence_agent.Agent | None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.select(persistence_agent.Agent).where( sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
persistence_agent.Agent.uuid == agent_uuid
),
persistence_agent.Agent, persistence_agent.Agent,
context, context,
) )
@@ -390,11 +386,7 @@ class BotService:
} }
], ],
) )
pipeline = ( pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None
await self._get_pipeline_entity(tenant_context, target_uuid)
if target_uuid
else None
)
if pipeline is None: if pipeline is None:
return self._diagnostic_result( return self._diagnostic_result(
matched=False, matched=False,
@@ -509,9 +501,7 @@ class BotService:
], ],
) )
async def _normalize_event_bindings( async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
self, context: TenantContext, bindings: list[dict] | None
) -> list[dict]:
"""Validate and normalize Bot event bindings.""" """Validate and normalize Bot event bindings."""
if not bindings: if not bindings:
return [] return []
@@ -544,9 +534,7 @@ class BotService:
elif target_type == 'agent': elif target_type == 'agent':
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.select(persistence_agent.Agent).where( sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
persistence_agent.Agent.uuid == target_uuid
),
persistence_agent.Agent, persistence_agent.Agent,
context, context,
) )
@@ -577,9 +565,7 @@ class BotService:
return normalized return normalized
async def _prepare_bot_data( async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
self, context: TenantContext, bot_data: dict, *, include_uuid: bool
) -> dict:
"""Normalize Bot write payloads to the current event-routing model.""" """Normalize Bot write payloads to the current event-routing model."""
update_data = bot_data.copy() update_data = bot_data.copy()
if not include_uuid: if not include_uuid:
@@ -750,21 +736,19 @@ class BotService:
return [log.to_json() for log in logs], total_count return [log.to_json() for log in logs], total_count
async def list_event_route_statuses( async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
self, context: TenantContext, bot_uuid: str
) -> dict[str, typing.Any]:
"""Return recent runtime status for Bot event routes from in-memory Bot logs.""" """Return recent runtime status for Bot event routes from in-memory Bot logs."""
from ....platform.botmgr import RuntimeBot from ....platform.botmgr import RuntimeBot
if await self.get_bot(context, bot_uuid, include_secret=False) is None: bot = await self.get_bot(context, bot_uuid, include_secret=False)
if bot is None:
raise WorkspaceNotFoundError('Bot not found') raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid) runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
latest_by_binding: dict[str, dict[str, typing.Any]] = {} latest_by_binding: dict[str, dict[str, typing.Any]] = {}
unmatched_events: list[dict[str, typing.Any]] = [] unmatched_events: list[dict[str, typing.Any]] = []
for log in getattr(runtime_bot.logger, 'logs', []): runtime_logs = getattr(getattr(runtime_bot, 'logger', None), 'logs', [])
for log in runtime_logs:
status = self._event_route_status_from_log(log) status = self._event_route_status_from_log(log)
if status is None: if status is None:
continue continue
@@ -774,7 +758,10 @@ class BotService:
else: else:
unmatched_events.append(status) unmatched_events.append(status)
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', []) runtime_entity = getattr(runtime_bot, 'bot_entity', None)
raw_bindings = getattr(runtime_entity, 'event_bindings', None) if runtime_entity is not None else None
if raw_bindings is None:
raw_bindings = bot.get('event_bindings') or []
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings) bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
routes: list[dict[str, typing.Any]] = [] routes: list[dict[str, typing.Any]] = []
current_binding_ids: set[str] = set() current_binding_ids: set[str] = set()
@@ -62,9 +62,7 @@ def _set_discovered_adapters(ap, *webhook_adapters: str) -> None:
) )
for adapter_name in webhook_adapters for adapter_name in webhook_adapters
] ]
ap.discover = SimpleNamespace( ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components))
get_components_by_kind=Mock(return_value=components)
)
class TestBotServiceGetBots: class TestBotServiceGetBots:
@@ -583,6 +581,56 @@ class TestBotServiceListEventLogs:
assert total == 5 assert total == 5
class TestBotServiceListEventRouteStatuses:
"""Tests for event route status when a persisted Bot is not running."""
async def test_returns_saved_routes_when_runtime_bot_is_unavailable(self):
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'bot-uuid',
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'enabled': True,
}
],
}
)
result = await service.list_event_route_statuses(WORKSPACE_UUID, 'bot-uuid')
assert result['routes'] == [
{
'binding_id': 'binding-1',
'event_pattern': 'message.received',
'event_type': None,
'target_type': 'agent',
'target_uuid': 'agent-1',
'last_status': None,
'failure_code': None,
'reason': None,
'run_id': None,
'timestamp': None,
'seq_id': None,
'level': None,
'message': '',
'order': 0,
'enabled': True,
'current': True,
}
]
assert result['unmatched_events'] == []
assert result['stale_routes'] == []
class TestBotServiceSendMessage: class TestBotServiceSendMessage:
"""Tests for send_message method.""" """Tests for send_message method."""
@@ -61,6 +61,11 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -1516,8 +1521,8 @@ export default function EventBindingsEditor({
const response = await backendClient.getBotEventRouteStatuses(botId); const response = await backendClient.getBotEventRouteStatuses(botId);
setRouteStatuses(response.routes || []); setRouteStatuses(response.routes || []);
} catch (error) { } catch (error) {
const err = error as { msg?: string }; console.error('Failed to refresh Bot event route status', error);
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed')); setRouteStatusError(t('bots.routeStatusRefreshFailed'));
} finally { } finally {
setRouteStatusLoading(false); setRouteStatusLoading(false);
} }
@@ -1653,18 +1658,18 @@ export default function EventBindingsEditor({
</Alert> </Alert>
)} )}
<Alert> {catchAllRouteIndex >= 0 && (
<Info className="h-4 w-4" /> <Alert>
<AlertDescription> <Info className="h-4 w-4" />
{catchAllRouteIndex >= 0 <AlertDescription>
? t('bots.routeFallbackCatchAll', { {t('bots.routeFallbackCatchAll', {
route: t('bots.dryRunRuleIndex', { route: t('bots.dryRunRuleIndex', {
index: catchAllRouteIndex + 1, index: catchAllRouteIndex + 1,
}), }),
}) })}
: t('bots.routeFallbackIgnored')} </AlertDescription>
</AlertDescription> </Alert>
</Alert> )}
{/* enabled section */} {/* enabled section */}
<DndContext <DndContext
@@ -1778,22 +1783,27 @@ export default function EventBindingsEditor({
agentOptions={agentOptions} agentOptions={agentOptions}
onRouteStatusUpdate={setRouteStatuses} onRouteStatusUpdate={setRouteStatuses}
/> />
<Button <Tooltip>
type="button" <TooltipTrigger asChild>
variant="ghost" <Button
size="sm" type="button"
onClick={refreshRouteStatuses} variant="ghost"
disabled={!botId || routeStatusLoading} size="icon"
> className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
<RefreshCw aria-label={t('bots.refreshRouteStatus')}
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`} onClick={refreshRouteStatuses}
/> disabled={!botId || routeStatusLoading}
{t('bots.refreshRouteStatus')} >
</Button> <RefreshCw
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{routeStatusError || t('bots.refreshRouteStatus')}
</TooltipContent>
</Tooltip>
</div> </div>
{routeStatusError && (
<p className="text-xs text-destructive">{routeStatusError}</p>
)}
{/* disabled section */} {/* disabled section */}
{disabledBindings.length > 0 && ( {disabledBindings.length > 0 && (
+5 -8
View File
@@ -384,8 +384,7 @@ const enUS = {
routingConnectionDescription: routingConnectionDescription:
'Bind the pipeline that processes messages for this bot', 'Bind the pipeline that processes messages for this bot',
eventRouting: 'Event Routing', eventRouting: 'Event Routing',
eventRoutingDescription: eventRoutingDescription: 'Choose which processor handles each event.',
'Choose which processor handles each event received by this bot. Edit the logic in the corresponding Agent or Pipeline configuration. Pipelines only support message events.',
eventBindings: 'Event Routes', eventBindings: 'Event Routes',
addEventBinding: 'Add Route', addEventBinding: 'Add Route',
addBehavior: 'Add behavior', addBehavior: 'Add behavior',
@@ -425,18 +424,16 @@ const enUS = {
disable: 'Disable', disable: 'Disable',
enable: 'Enable', enable: 'Enable',
disabledBindings: 'Disabled', disabledBindings: 'Disabled',
adapterEventsTitle: 'Events this adapter can receive', adapterEventsTitle: 'Supported events',
adapterEventsDescription: adapterEventsDescription: '{{count}} event types',
'{{count}} event types are available. Routes are matched in order, and unmatched events are not sent to a processor.',
adapterEventsMore: '{{count}} more', adapterEventsMore: '{{count}} more',
advancedEventValues: 'Advanced event values', advancedEventValues: 'View all',
eventGroup: 'Group', eventGroup: 'Group',
routeConflictTitle: 'Some routes overlap', routeConflictTitle: 'Some routes overlap',
routeConflictShadowed: routeConflictShadowed:
'{{shadowed}} may never run because {{winner}} handles the same events first.', '{{shadowed}} may never run because {{winner}} handles the same events first.',
routeConflictMore: '{{count}} more route conflicts need attention.', routeConflictMore: '{{count}} more route conflicts need attention.',
routeFallbackCatchAll: routeFallbackCatchAll: '{{route}} is the catch-all route.',
'{{route}} is the catch-all route. Routes with higher priority run first.',
routeFallbackIgnored: routeFallbackIgnored:
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.', 'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
testRoute: 'Test route', testRoute: 'Test route',
+5 -8
View File
@@ -390,8 +390,7 @@ const jaJP = {
routingConnectionDescription: routingConnectionDescription:
'このボットのメッセージを処理するパイプラインを紐付け', 'このボットのメッセージを処理するパイプラインを紐付け',
eventRouting: 'イベントルーティング', eventRouting: 'イベントルーティング',
eventRoutingDescription: eventRoutingDescription: 'イベントごとの処理先を設定します。',
'このボットが受信した各イベントをどのプロセッサーに渡すかを選択します。対応する Agent または Pipeline の設定で処理ロジックを編集します。Pipeline はメッセージイベントのみ対応します。',
eventBindings: 'イベントルート', eventBindings: 'イベントルート',
addEventBinding: 'ルートを追加', addEventBinding: 'ルートを追加',
addBehavior: '動作を追加', addBehavior: '動作を追加',
@@ -432,18 +431,16 @@ const jaJP = {
disable: '無効化', disable: '無効化',
enable: '有効化', enable: '有効化',
disabledBindings: '無効', disabledBindings: '無効',
adapterEventsTitle: 'このアダプターが受信できるイベント', adapterEventsTitle: '対応イベント',
adapterEventsDescription: adapterEventsDescription: '{{count}} 種類',
'{{count}} 種類のイベントを利用できます。ルートは上から順に照合され、未一致のイベントはプロセッサーへ送られません。',
adapterEventsMore: 'ほか {{count}} 件', adapterEventsMore: 'ほか {{count}} 件',
advancedEventValues: '高度なイベント値', advancedEventValues: 'すべて表示',
eventGroup: 'グループ', eventGroup: 'グループ',
routeConflictTitle: '一部のルートが重複しています', routeConflictTitle: '一部のルートが重複しています',
routeConflictShadowed: routeConflictShadowed:
'{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。', '{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。',
routeConflictMore: 'ほか {{count}} 件のルート競合を確認してください。', routeConflictMore: 'ほか {{count}} 件のルート競合を確認してください。',
routeFallbackCatchAll: routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
'{{route}} はすべてのイベントを受けるフォールバックです。優先度の高いルートが先に実行されます。',
routeFallbackIgnored: routeFallbackIgnored:
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。', 'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
testRoute: 'ルートをテスト', testRoute: 'ルートをテスト',
+5 -8
View File
@@ -367,8 +367,7 @@ const zhHans = {
routingConnection: '路由与连接', routingConnection: '路由与连接',
routingConnectionDescription: '绑定处理此机器人消息的流水线', routingConnectionDescription: '绑定处理此机器人消息的流水线',
eventRouting: '事件路由', eventRouting: '事件路由',
eventRoutingDescription: eventRoutingDescription: '设置收到事件后交给哪个处理器。',
'选择此机器人收到不同事件时交给哪个处理器。在对应的 Agent 或 Pipeline 配置中编辑处理逻辑;Pipeline 仅支持消息事件。',
eventBindings: '事件路由', eventBindings: '事件路由',
addEventBinding: '添加路由', addEventBinding: '添加路由',
addBehavior: '添加行为', addBehavior: '添加行为',
@@ -404,18 +403,16 @@ const zhHans = {
disable: '禁用', disable: '禁用',
enable: '启用', enable: '启用',
disabledBindings: '已禁用', disabledBindings: '已禁用',
adapterEventsTitle: '此适配器可接收的事件', adapterEventsTitle: '支持的事件',
adapterEventsDescription: adapterEventsDescription: '共 {{count}} 类',
'已识别 {{count}} 类事件。路由会按顺序匹配,未命中时不会交给处理器。',
adapterEventsMore: '另有 {{count}} 类', adapterEventsMore: '另有 {{count}} 类',
advancedEventValues: '高级事件值', advancedEventValues: '查看全部',
eventGroup: '事件组', eventGroup: '事件组',
routeConflictTitle: '部分路由存在覆盖冲突', routeConflictTitle: '部分路由存在覆盖冲突',
routeConflictShadowed: routeConflictShadowed:
'{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。', '{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。',
routeConflictMore: '另有 {{count}} 个路由冲突需要处理。', routeConflictMore: '另有 {{count}} 个路由冲突需要处理。',
routeFallbackCatchAll: routeFallbackCatchAll: '{{route}} 是兜底路由。',
'{{route}} 是全局兜底路由,优先级更高的路由会先运行。',
routeFallbackIgnored: routeFallbackIgnored:
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。', '未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
testRoute: '测试路由', testRoute: '测试路由',
+31
View File
@@ -335,6 +335,37 @@ test.describe('frontend CRUD smoke flows', () => {
}); });
test.describe('bot advanced flows', () => { test.describe('bot advanced flows', () => {
test('keeps event routing compact and hides raw status errors', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ code: -1, msg: 'Internal server error' }),
}),
);
await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Route Status Bot');
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await expect(page.getByText('Supported events')).toBeVisible();
await expect(page.getByText('1 event types')).toBeVisible();
await expect(page.getByText('Internal server error')).toHaveCount(0);
await expect(
page.getByText('Events that match no route are ignored.'),
).toHaveCount(0);
await page.getByRole('button', { name: 'Refresh status' }).hover();
await expect(
page.getByText('Failed to refresh route status.'),
).toBeVisible();
});
test('toggles bot enable/disable state', async ({ page }) => { test('toggles bot enable/disable state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, { authenticated: true });