mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
Require a valid owner and unexpired lease for pending Pipeline messages to appear as active tasks. Exclude historical ingress records without rewriting their stored status. Project only the fields needed by snapshots, skip linkage queries for empty results, and resolve linked runs from selected message IDs instead of a cross-table OR join that scans historical messages. Cover lease filtering, terminal transitions, Workspace isolation, payload exclusion, and indexed lookup plans with regression tests.
1058 lines
43 KiB
Python
1058 lines
43 KiB
Python
"""Execution-view tests for the monitoring service.
|
|
|
|
Covers the unified agent-run + pipeline-query execution list, its rollup
|
|
summary, and single-execution traces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
|
from langbot.pkg.entity.persistence import agent_interaction as persistence_interaction
|
|
from langbot.pkg.entity.persistence import agent_run as persistence_agent_run
|
|
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
|
|
from langbot.pkg.entity.persistence import workspace as persistence_workspace # noqa: F401
|
|
from langbot.pkg.entity.persistence.base import Base
|
|
|
|
WORKSPACE = 'workspace-1'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('has_log', [False, True])
|
|
async def test_event_properties_survive_detail_snapshot_fallback(engine, service, has_log):
|
|
import json
|
|
from langbot.pkg.entity.persistence.event_log import EventLog
|
|
|
|
data = {'member': {'id': 'u1', 'active': False}, 'count': 0, 'custom': {'reason': 'joined'}}
|
|
async with AsyncSession(engine) as session:
|
|
session.add(
|
|
persistence_agent_run.AgentRun(
|
|
run_id='custom-run',
|
|
event_id='custom-event',
|
|
workspace_id=WORKSPACE,
|
|
runner_id='runner',
|
|
status='completed',
|
|
created_at=_dt(10),
|
|
metadata_json=json.dumps(
|
|
{
|
|
'event_type': 'group.member_joined',
|
|
'input_event': data,
|
|
'input': {'text': '', 'contents': [], 'attachments': []},
|
|
}
|
|
),
|
|
)
|
|
)
|
|
if has_log:
|
|
session.add(
|
|
EventLog(
|
|
event_id='custom-event',
|
|
event_type='group.member_joined',
|
|
source='webui',
|
|
workspace_id=WORKSPACE,
|
|
run_id='custom-run',
|
|
input_json='{"text":"","contents":[]}',
|
|
created_at=_dt(10),
|
|
)
|
|
)
|
|
session.add(
|
|
EventLog(
|
|
event_id='later-event',
|
|
event_type='message.received',
|
|
source='webui',
|
|
workspace_id=WORKSPACE,
|
|
run_id='custom-run',
|
|
input_json='{"text":"later"}',
|
|
created_at=_dt(10, 1),
|
|
)
|
|
)
|
|
await session.commit()
|
|
detail = await service.get_execution_detail(WORKSPACE, 'agent', 'custom-run')
|
|
items = detail['pages']['inputs']['items']
|
|
assert items[0]['content']['event']['member'] == data['member']
|
|
assert items[0]['content']['event']['count'] == 0
|
|
assert items[0]['content']['event']['custom'] == data['custom']
|
|
if has_log:
|
|
assert items[1]['content'] == {'text': 'later'}
|
|
|
|
|
|
def test_event_content_preserves_conflicting_custom_input_fields():
|
|
from langbot.pkg.api.http.service.monitoring_execution_details import event_content
|
|
|
|
assert event_content({'text': 'normalized'}, {'text': {'custom': False}}) == {
|
|
'text': 'event',
|
|
'event': {'text': {'custom': False}},
|
|
'input': {'text': 'normalized'},
|
|
}
|
|
|
|
|
|
class FakePersistenceManager:
|
|
"""Runs statements against a real in-memory SQLite engine."""
|
|
|
|
def __init__(self, engine):
|
|
self._engine = engine
|
|
self._factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
|
def get_db_engine(self):
|
|
return self._engine
|
|
|
|
def tenant_uow(self, workspace_uuid):
|
|
from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork
|
|
|
|
return TenantUnitOfWork(self._engine, workspace_uuid)
|
|
|
|
async def execute_async(self, statement):
|
|
# Match PersistenceManager's connection-level result shape.
|
|
async with self._engine.connect() as connection:
|
|
return await connection.execute(statement)
|
|
|
|
@staticmethod
|
|
def serialize_model(model, data):
|
|
out = {}
|
|
for column in model.__table__.columns:
|
|
value = getattr(data, column.name)
|
|
out[column.name] = value.isoformat() if isinstance(value, datetime.datetime) else value
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine(tmp_path):
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "monitoring_executions.db"}')
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def service(engine):
|
|
ap = SimpleNamespace(
|
|
persistence_mgr=FakePersistenceManager(engine),
|
|
instance_config=None,
|
|
)
|
|
return MonitoringService(ap)
|
|
|
|
|
|
def _dt(hour: int, minute: int = 0, second: int = 0) -> datetime.datetime:
|
|
return datetime.datetime(2026, 1, 1, hour, minute, second)
|
|
|
|
|
|
async def _seed(engine) -> None:
|
|
factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
async with factory() as session:
|
|
session.add_all(
|
|
[
|
|
persistence_agent_run.AgentRun(
|
|
run_id='run-done',
|
|
event_id='evt-1',
|
|
agent_id='agent-1',
|
|
binding_id='agent_agent-1_default',
|
|
runner_id='runner-1',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
status='completed',
|
|
created_at=_dt(10),
|
|
started_at=_dt(10, 0, 1),
|
|
finished_at=_dt(10, 0, 3),
|
|
usage_json='{"total_tokens": 42}',
|
|
),
|
|
persistence_agent_run.AgentRun(
|
|
run_id='run-failed',
|
|
binding_id='agent_agent-1_default',
|
|
runner_id='runner-1',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
status='failed',
|
|
created_at=_dt(10, 5),
|
|
),
|
|
persistence_agent_run.AgentRun(
|
|
run_id='run-debug',
|
|
event_id='debug:agent-1:e1',
|
|
agent_id='agent-1',
|
|
binding_id='debug:agent-1:runner-1',
|
|
runner_id='runner-1',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
status='completed',
|
|
created_at=_dt(10, 6),
|
|
started_at=_dt(10, 6, 0),
|
|
finished_at=_dt(10, 6, 1),
|
|
),
|
|
persistence_agent_run.AgentRunEvent(
|
|
run_id='run-done',
|
|
sequence=1,
|
|
type='run.completed',
|
|
data_json='{"message": {"role": "assistant", "content": "hi"}}',
|
|
),
|
|
persistence_interaction.AgentInteraction(
|
|
interaction_id='int-1',
|
|
run_id='run-done',
|
|
binding_id='agent_agent-1_default',
|
|
runner_id='runner-1',
|
|
processor_type='agent',
|
|
processor_id='agent-1',
|
|
workspace_id=WORKSPACE,
|
|
status='pending',
|
|
request_json='{}',
|
|
callback_token_hash='hash-1',
|
|
),
|
|
]
|
|
)
|
|
for message in [
|
|
dict(
|
|
id='msg-user',
|
|
timestamp=_dt(10, 2),
|
|
session_id='session-1',
|
|
message_content='{"chain": [{"type": "Plain", "text": "hello there"}]}',
|
|
status='success',
|
|
role='user',
|
|
),
|
|
dict(
|
|
id='msg-assistant',
|
|
timestamp=_dt(10, 3),
|
|
session_id='session-1',
|
|
message_content='{"chain": [{"type": "Plain", "text": "reply"}]}',
|
|
status='success',
|
|
role='assistant',
|
|
),
|
|
dict(
|
|
id='msg-discarded',
|
|
timestamp=_dt(10, 4),
|
|
session_id='session-2',
|
|
message_content='{"chain": [{"type": "Plain", "text": "ignored"}]}',
|
|
status='discarded',
|
|
role='user',
|
|
),
|
|
]:
|
|
session.add(
|
|
persistence_monitoring.MonitoringMessage(
|
|
workspace_uuid=WORKSPACE,
|
|
bot_id='bot-1',
|
|
bot_name='Bot One',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Pipeline One',
|
|
level='info',
|
|
**message,
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
persistence_monitoring.MonitoringLLMCall(
|
|
id='llm-1',
|
|
workspace_uuid=WORKSPACE,
|
|
timestamp=_dt(10, 2, 30),
|
|
model_name='gpt',
|
|
input_tokens=10,
|
|
output_tokens=20,
|
|
total_tokens=30,
|
|
duration=100,
|
|
status='success',
|
|
bot_id='bot-1',
|
|
bot_name='Bot One',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Pipeline One',
|
|
session_id='session-1',
|
|
message_id='msg-user',
|
|
),
|
|
persistence_monitoring.MonitoringLLMCall(
|
|
id='llm-2',
|
|
workspace_uuid=WORKSPACE,
|
|
timestamp=_dt(10, 4, 30),
|
|
model_name='gpt',
|
|
input_tokens=0,
|
|
output_tokens=0,
|
|
total_tokens=0,
|
|
duration=50,
|
|
status='success',
|
|
bot_id='bot-1',
|
|
bot_name='Bot One',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Pipeline One',
|
|
session_id='session-2',
|
|
message_id='msg-discarded',
|
|
),
|
|
]
|
|
)
|
|
session.add(
|
|
persistence_monitoring.MonitoringSession(
|
|
workspace_uuid=WORKSPACE,
|
|
bot_id='bot-1',
|
|
session_id='session-1',
|
|
bot_name='Bot One',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Pipeline One',
|
|
message_count=2,
|
|
start_time=_dt(10, 2),
|
|
last_activity=_dt(10, 5),
|
|
is_active=True,
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_executions_merge_and_order_both_sources(engine, service):
|
|
await _seed(engine)
|
|
|
|
result = await service.get_executions(WORKSPACE, limit=50)
|
|
|
|
assert result['total'] == 5
|
|
ids = [(item['source'], item['id']) for item in result['items']]
|
|
# Assistant replies are outputs, not executions.
|
|
assert ('pipeline', 'msg-assistant') not in ids
|
|
# Newest first across both object types.
|
|
assert ids == [
|
|
('agent', 'run-debug'),
|
|
('agent', 'run-failed'),
|
|
('pipeline', 'msg-discarded'),
|
|
('pipeline', 'msg-user'),
|
|
('agent', 'run-done'),
|
|
]
|
|
|
|
by_id = {item['id']: item for item in result['items']}
|
|
assert result['summary']['executions']['total'] == 5
|
|
assert result['summary']['executions']['denominator'] == 4
|
|
assert result['summary']['tokens']['calls'] == 2
|
|
assert result['summary']['tokens']['calls_with_usage'] == 1
|
|
assert by_id['run-done']['status_group'] == 'completed'
|
|
assert by_id['run-failed']['status_group'] == 'failed'
|
|
assert by_id['run-debug']['debug'] is True
|
|
assert by_id['run-done']['debug'] is False
|
|
assert by_id['run-done']['usage'] == {'total_tokens': 42}
|
|
assert by_id['run-done']['duration_ms'] == 2000
|
|
assert by_id['msg-user']['status_group'] == 'completed'
|
|
assert by_id['msg-discarded']['status_group'] == 'ignored'
|
|
assert by_id['msg-user']['title'] == 'hello there'
|
|
assert by_id['msg-user']['target_id'] == 'pipeline-1'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_executions_source_status_and_paging(engine, service):
|
|
await _seed(engine)
|
|
|
|
only_agents = await service.get_executions(WORKSPACE, source='agent')
|
|
assert {item['source'] for item in only_agents['items']} == {'agent'}
|
|
assert only_agents['total'] == 3
|
|
|
|
pipeline_only = await service.get_executions(WORKSPACE, source='pipeline')
|
|
assert {item['source'] for item in pipeline_only['items']} == {'pipeline'}
|
|
assert pipeline_only['total'] == 2
|
|
|
|
# A single-object target selection narrows the union to that source.
|
|
by_pipeline = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1'])
|
|
assert {item['source'] for item in by_pipeline['items']} == {'pipeline'}
|
|
|
|
failed = await service.get_executions(WORKSPACE, statuses=['failed'])
|
|
assert [item['id'] for item in failed['items']] == ['run-failed']
|
|
|
|
# `ignored` only exists for pipeline queries: it must not widen the agent
|
|
# filter back to "every status".
|
|
ignored = await service.get_executions(WORKSPACE, statuses=['ignored'])
|
|
assert [item['id'] for item in ignored['items']] == ['msg-discarded']
|
|
|
|
debug_only = await service.get_executions(WORKSPACE, mode='debug')
|
|
assert [item['id'] for item in debug_only['items']] == ['run-debug']
|
|
|
|
first_page = await service.get_executions(WORKSPACE, limit=2)
|
|
assert len(first_page['items']) == 2
|
|
assert first_page['has_more'] is True
|
|
second_page = await service.get_executions(WORKSPACE, limit=2, offset=2)
|
|
assert first_page['items'][0]['id'] != second_page['items'][0]['id']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execution_summary_uses_explicit_denominator(engine, service):
|
|
await _seed(engine)
|
|
|
|
summary = (await service.get_executions(WORKSPACE))['summary']
|
|
executions = summary['executions']
|
|
|
|
# completed = run-done + run-debug + msg-user; failed = run-failed.
|
|
assert executions['by_source'] == {'agent': 3, 'pipeline': 2}
|
|
assert executions['completed'] == 3
|
|
assert executions['failed'] == 1
|
|
assert executions['ignored'] == 1
|
|
assert executions['denominator'] == 4
|
|
assert executions['success_rate'] == 75.0
|
|
assert executions['debug'] == 1
|
|
assert executions['real'] == 4
|
|
assert executions['waiting'] == 1
|
|
assert executions['p50_duration_ms'] == 1000
|
|
assert executions['p95_duration_ms'] == 2000
|
|
# Only run-done and run-debug are completed runs with a start and a finish.
|
|
assert executions['duration_sample'] == 2
|
|
|
|
# Token coverage is explicit: llm-2 succeeded without usage.
|
|
assert summary['tokens'] == {'total_tokens': 30, 'calls': 2, 'calls_with_usage': 1}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overview_metrics_keep_message_rollup(engine, service):
|
|
await _seed(engine)
|
|
|
|
overview = await service.get_overview_metrics(WORKSPACE)
|
|
|
|
assert overview['total_messages'] == 3
|
|
assert overview['llm_calls'] == 2
|
|
assert overview['model_calls'] == 2
|
|
assert overview['active_sessions'] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execution_detail_for_agent_and_pipeline(engine, service):
|
|
await _seed(engine)
|
|
|
|
agent_detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done')
|
|
assert agent_detail['run']['run_id'] == 'run-done'
|
|
assert agent_detail['events'][0]['type'] == 'run.completed'
|
|
|
|
pipeline_detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
|
|
assert pipeline_detail['message']['id'] == 'msg-user'
|
|
assert [call['id'] for call in pipeline_detail['llm_calls']] == ['llm-1']
|
|
assert pipeline_detail['tool_calls'] == []
|
|
|
|
with pytest.raises(ValueError):
|
|
await service.get_execution_detail(WORKSPACE, 'agent', 'missing-run')
|
|
|
|
with pytest.raises(ValueError):
|
|
await service.get_execution_detail(WORKSPACE, 'pipeline', 'missing-message')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_deliveries_context_and_tenant_isolation(engine, service):
|
|
import sqlalchemy as sa
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_monitoring.MonitoringMessage)
|
|
.where(persistence_monitoring.MonitoringMessage.id == 'msg-assistant')
|
|
.values(parent_message_id='msg-user')
|
|
)
|
|
await session.commit()
|
|
detail = await service.get_execution_detail(WORKSPACE, 'auto', 'msg-assistant')
|
|
assert detail['row']['id'] == 'msg-user'
|
|
assert [m['id'] for m in detail['pages']['deliveries']['items']] == ['msg-assistant']
|
|
assert detail['pages']['inputs']['items'][0]['id'] == 'msg-user'
|
|
assert detail['pages']['conversation']['items']
|
|
with pytest.raises(ValueError, match='not found'):
|
|
await service.get_execution_detail('another-workspace', 'auto', 'msg-assistant')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_replies_are_context_not_guessed_deliveries(engine, service):
|
|
await _seed(engine)
|
|
detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
|
|
assert detail['legacy_context'] is True
|
|
assert detail['pages']['deliveries']['items'] == []
|
|
assert 'msg-assistant' in [m['id'] for m in detail['pages']['conversation']['items']]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fanout_steering_orphans_and_paged_trace(engine, service):
|
|
import json
|
|
import sqlalchemy as sa
|
|
from langbot.pkg.entity.persistence.event_log import EventLog
|
|
from langbot.pkg.entity.persistence.transcript import Transcript
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_agent_run.AgentRun)
|
|
.where(persistence_agent_run.AgentRun.run_id == 'run-done')
|
|
.values(
|
|
conversation_id='shared',
|
|
thread_id='thread-1',
|
|
metadata_json=json.dumps({'processor_type': 'event_processor'}),
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
EventLog(
|
|
event_id='evt-1',
|
|
event_type='message.received',
|
|
source='platform',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
input_json='{"text":"original input"}',
|
|
actor_name='Alice',
|
|
created_at=_dt(10),
|
|
),
|
|
EventLog(
|
|
event_id='steer',
|
|
event_type='message.received',
|
|
source='platform',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
run_id='run-done',
|
|
input_json='{"text":"additional input"}',
|
|
created_at=_dt(10, 1),
|
|
),
|
|
EventLog(
|
|
event_id='unrouted',
|
|
event_type='group.member.joined',
|
|
source='platform',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
metadata_json='{"status":"ignored"}',
|
|
created_at=_dt(10, 7),
|
|
),
|
|
persistence_agent_run.AgentRun(
|
|
run_id='sibling',
|
|
event_id='evt-1',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
runner_id='plugin',
|
|
status='completed',
|
|
created_at=_dt(10),
|
|
),
|
|
Transcript(
|
|
transcript_id='output',
|
|
event_id='generated',
|
|
conversation_id='shared',
|
|
thread_id='thread-1',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
role='assistant',
|
|
content='generated only',
|
|
run_id='run-done',
|
|
seq=1,
|
|
),
|
|
Transcript(
|
|
transcript_id='other-thread',
|
|
event_id='other',
|
|
conversation_id='shared',
|
|
thread_id='thread-2',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot-1',
|
|
role='user',
|
|
content='private other thread',
|
|
seq=2,
|
|
),
|
|
]
|
|
)
|
|
session.add_all(
|
|
[
|
|
persistence_agent_run.AgentRunEvent(run_id='run-done', sequence=i, type='message.delta', data_json='{}')
|
|
for i in range(2, 206)
|
|
]
|
|
)
|
|
await session.commit()
|
|
result = await service.get_executions(WORKSPACE)
|
|
assert 'unrouted' in [r['id'] for r in result['items']]
|
|
assert 'steer' not in [r['id'] for r in result['items']]
|
|
ignored = await service.get_executions(WORKSPACE, statuses=['ignored'])
|
|
assert 'unrouted' in [r['id'] for r in ignored['items']]
|
|
detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done')
|
|
assert detail['row']['target_kind'] == 'event_processor'
|
|
assert detail['row']['user_name'] == 'Alice'
|
|
assert [i['id'] for i in detail['pages']['inputs']['items']] == ['evt-1', 'steer']
|
|
assert detail['pages']['outputs']['items'][0]['content'] == 'generated only'
|
|
assert [i['id'] for i in detail['pages']['related']['items']] == ['sibling']
|
|
assert 'other-thread' not in [i['id'] for i in detail['pages']['conversation']['items']]
|
|
assert len(detail['pages']['events']['items']) == 100
|
|
assert detail['pages']['events']['has_more']
|
|
final = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done', section='events', offset=200)
|
|
assert len(final['pages']['events']['items']) == 5
|
|
assert not final['pages']['events']['has_more']
|
|
orphan = await service.get_execution_detail(WORKSPACE, 'event', 'unrouted')
|
|
assert orphan['pages']['deliveries']['items'] == []
|
|
assert orphan['pages']['conversation']['items'] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_runner_is_not_counted_twice(engine, service):
|
|
import sqlalchemy as sa
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_monitoring.MonitoringMessage)
|
|
.where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
|
|
.values(run_id='run-done')
|
|
)
|
|
await session.commit()
|
|
result = await service.get_executions(WORKSPACE)
|
|
assert result['total'] == 4
|
|
assert result['summary']['executions']['total'] == 4
|
|
assert 'run-done' not in [r['id'] for r in result['items']]
|
|
detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user')
|
|
assert detail['row']['duration_ms'] == 2000
|
|
assert detail['events'][0]['type'] == 'run.completed'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_log_fanout_is_idempotent_and_cannot_cross_tenants(engine):
|
|
from langbot.pkg.agent.runner.event_log_store import EventLogStore
|
|
|
|
store = EventLogStore(engine)
|
|
await store.append_event('event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot')
|
|
await store.append_event(
|
|
'event-id',
|
|
'message.received',
|
|
'platform',
|
|
workspace_id=WORKSPACE,
|
|
bot_id='bot',
|
|
run_id='first-run',
|
|
input_json={'text': 'input', 'attachments': []},
|
|
)
|
|
await store.append_event(
|
|
'event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot', run_id='second-run'
|
|
)
|
|
event = await store.get_event('event-id')
|
|
assert event['run_id'] == 'first-run'
|
|
assert event['input_json']['text'] == 'input'
|
|
with pytest.raises(Exception):
|
|
await store.append_event('event-id', 'message.received', 'platform', workspace_id='other', bot_id='bot')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processor_filter_scopes_each_kind_and_legacy_calls(engine, service):
|
|
import json
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
session.add(
|
|
persistence_agent_run.AgentRun(
|
|
run_id='event-run',
|
|
workspace_id=WORKSPACE,
|
|
runner_id='event-runner',
|
|
status='completed',
|
|
created_at=_dt(11),
|
|
metadata_json=json.dumps({'processor_type': 'event_processor', 'processor_id': 'event-processor-1'}),
|
|
)
|
|
)
|
|
session.add(
|
|
persistence_monitoring.MonitoringLLMCall(
|
|
id='event-call',
|
|
bot_id='bot-1',
|
|
bot_name='Bot One',
|
|
pipeline_name='Event Processor',
|
|
session_id='session-event',
|
|
input_tokens=6,
|
|
output_tokens=6,
|
|
duration=1,
|
|
timestamp=_dt(11),
|
|
workspace_uuid=WORKSPACE,
|
|
model_name='model',
|
|
message_id='event-run',
|
|
pipeline_id='',
|
|
status='success',
|
|
total_tokens=12,
|
|
)
|
|
)
|
|
await session.commit()
|
|
for processor, expected in [
|
|
('agent-1', {'run-done', 'run-debug'}),
|
|
('pipeline-1', {'msg-user', 'msg-discarded'}),
|
|
('event-processor-1', {'event-run'}),
|
|
('missing', set()),
|
|
]:
|
|
result = await service.get_executions(WORKSPACE, pipeline_ids=[processor])
|
|
assert {row['id'] for row in result['items']} == expected
|
|
assert result['total'] == result['summary']['executions']['total'] == len(expected)
|
|
calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['event-processor-1'])
|
|
assert total == 1
|
|
assert [row['id'] for row in calls] == ['event-call']
|
|
other = await service.get_executions('another-workspace', pipeline_ids=['event-processor-1'])
|
|
assert other['total'] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execution_filters_also_scope_cards_calls_and_traffic(engine, service):
|
|
import sqlalchemy as sa
|
|
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_monitoring.MonitoringLLMCall)
|
|
.where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1')
|
|
.values(message_id='run-failed', pipeline_id='')
|
|
)
|
|
await session.commit()
|
|
failed = await service.get_executions(WORKSPACE, statuses=['failed'])
|
|
assert failed['total'] == failed['summary']['executions']['total'] == 1
|
|
assert failed['summary']['executions']['waiting'] == 0
|
|
calls, total = await service.get_llm_calls(WORKSPACE, execution_statuses=['failed'])
|
|
assert total == 1 and calls[0]['id'] == 'llm-1'
|
|
# A successful model call may belong to a failed execution.
|
|
assert calls[0]['status'] == 'success'
|
|
overview = await service.get_overview_metrics(WORKSPACE, execution_statuses=['failed'])
|
|
assert overview['llm_calls'] == 1 and overview['total_messages'] == 0
|
|
traffic = await get_traffic_series(service.ap, WORKSPACE, execution_statuses=['failed'])
|
|
assert sum(p['llm_calls'] for p in traffic['points']) == 1
|
|
assert sum(p['messages'] for p in traffic['points']) == 0
|
|
|
|
debug = await service.get_executions(WORKSPACE, mode='debug')
|
|
assert debug['total'] == debug['summary']['executions']['total'] == 1
|
|
real = await service.get_executions(WORKSPACE, mode='real')
|
|
assert real['total'] == real['summary']['executions']['total'] == 4
|
|
traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug')
|
|
assert sum(p['messages'] for p in traffic['points']) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(engine, service):
|
|
import sqlalchemy as sa
|
|
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
|
|
|
await _seed(engine)
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_monitoring.MonitoringMessage)
|
|
.where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
|
|
.values(bot_id='websocket-proxy-bot')
|
|
)
|
|
await session.execute(
|
|
sa.update(persistence_monitoring.MonitoringLLMCall)
|
|
.where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1')
|
|
.values(message_id='msg-user', pipeline_id='pipeline-1')
|
|
)
|
|
await session.execute(
|
|
sa.update(persistence_agent_run.AgentRun)
|
|
.where(persistence_agent_run.AgentRun.run_id == 'run-done')
|
|
.values(bot_id='websocket-proxy-bot')
|
|
)
|
|
await session.commit()
|
|
debug = await service.get_executions(WORKSPACE, mode='debug')
|
|
ids = {r['id'] for r in debug['items']}
|
|
assert ids == {'msg-user', 'run-done', 'run-debug'}
|
|
assert all(r['debug'] for r in debug['items'])
|
|
assert debug['summary']['executions']['total'] == 3
|
|
assert debug['summary']['executions']['debug'] == 3
|
|
real = await service.get_executions(WORKSPACE, mode='real')
|
|
assert not ids.intersection(r['id'] for r in real['items'])
|
|
assert real['summary']['executions']['debug'] == 0
|
|
filtered = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug')
|
|
assert [r['id'] for r in filtered['items']] == ['msg-user']
|
|
calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug')
|
|
assert total == 1 and calls[0]['id'] == 'llm-1'
|
|
real_rows, real_calls = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='real')
|
|
assert real_calls == 1 and real_rows[0]['id'] == 'llm-2'
|
|
traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug', pipeline_ids=['pipeline-1'])
|
|
assert sum(p['llm_calls'] for p in traffic['points']) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_excludes_unowned_and_expired_messages_without_rewriting_history(engine, service):
|
|
import sqlalchemy as sa
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
Message = persistence_monitoring.MonitoringMessage
|
|
async with AsyncSession(engine) as session:
|
|
for message_id, owner, expiry, timestamp in [
|
|
('legacy', None, None, _dt(1)),
|
|
('recent-unowned', None, now + datetime.timedelta(minutes=1), now),
|
|
('expired', 'stopped-host', now - datetime.timedelta(seconds=1), _dt(1)),
|
|
('long-running', 'live-host', now + datetime.timedelta(minutes=1), _dt(1)),
|
|
]:
|
|
session.add(
|
|
Message(
|
|
id=message_id,
|
|
workspace_uuid=WORKSPACE,
|
|
timestamp=timestamp,
|
|
bot_id='bot',
|
|
bot_name='Bot',
|
|
pipeline_id='pipeline',
|
|
pipeline_name='Pipeline',
|
|
session_id='session',
|
|
message_content='historical message',
|
|
status='pending',
|
|
level='info',
|
|
execution_owner_id=owner,
|
|
execution_lease_expires_at=expiry,
|
|
)
|
|
)
|
|
session.add(
|
|
persistence_agent_run.AgentRun(
|
|
run_id='remote-queue',
|
|
workspace_id=WORKSPACE,
|
|
runner_id='runner',
|
|
status='queued',
|
|
queue_name='remote',
|
|
created_at=_dt(1),
|
|
)
|
|
)
|
|
await session.commit()
|
|
# A previous connection must not reintroduce unowned pending rows either.
|
|
snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'legacy'}])
|
|
assert snapshot['active_total'] == 2
|
|
assert {row['id'] for row in snapshot['items']} == {'long-running', 'remote-queue'}
|
|
async with AsyncSession(engine) as session:
|
|
statuses = (await session.execute(sa.select(Message.status))).scalars().all()
|
|
assert statuses == ['pending'] * 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('source', ['all', 'pipeline'])
|
|
@pytest.mark.parametrize('statuses', [['running'], ['running', 'queued']])
|
|
async def test_active_inflight_count_and_page_use_the_indexed_lease_range(engine, service, source, statuses):
|
|
import sqlalchemy as sa
|
|
|
|
await _seed(engine)
|
|
async with engine.begin() as connection:
|
|
await connection.execute(
|
|
sa.update(persistence_monitoring.MonitoringMessage)
|
|
.where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
|
|
.values(status='pending', message_content='historical payload ' * 8192)
|
|
)
|
|
statements = []
|
|
|
|
def capture(connection, cursor, statement, parameters, context, executemany):
|
|
if statement.startswith('SELECT '):
|
|
statements.append((statement, parameters))
|
|
|
|
sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture)
|
|
try:
|
|
result = await service.get_executions(
|
|
WORKSPACE, statuses=statuses, source=source, include_summary=False, for_inflight=True
|
|
)
|
|
finally:
|
|
sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture)
|
|
|
|
assert result == {'items': [], 'total': 0, 'has_more': False}
|
|
assert len(statements) == 2 # Both COUNT and the sorted page must skip historical payloads.
|
|
async with engine.connect() as connection:
|
|
for statement, parameters in statements:
|
|
plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all()
|
|
assert any(
|
|
'ix_monitoring_messages_workspace_status_lease' in row[3] and 'execution_lease_expires_at>?' in row[3]
|
|
for row in plan
|
|
), plan
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_inflight_snapshot_skips_linked_history_and_clears_tracking(engine, service, monkeypatch):
|
|
import sqlalchemy as sa
|
|
from langbot.pkg.utils.inflight import inflight_hub
|
|
|
|
await _seed(engine)
|
|
monkeypatch.setattr(inflight_hub, 'run_workspaces', {'finished-run': WORKSPACE, 'other-run': 'other-workspace'})
|
|
statements = []
|
|
|
|
def capture(connection, cursor, statement, parameters, context, executemany):
|
|
statements.append(statement)
|
|
|
|
sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture)
|
|
try:
|
|
snapshot = await service.get_inflight_snapshot(WORKSPACE)
|
|
finally:
|
|
sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture)
|
|
|
|
assert snapshot == {'items': [], 'active_total': 0, 'truncated': False}
|
|
assert inflight_hub.run_workspaces == {'other-run': 'other-workspace'}
|
|
assert not any('JOIN monitoring_messages ON' in query or 'FROM agent_run_event' in query for query in statements)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_never_loads_payloads_in_active_recent_or_terminal_rows(engine, service, monkeypatch):
|
|
import json
|
|
import sqlalchemy as sa
|
|
from langbot.pkg.entity.persistence.event_log import EventLog
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
|
payload = json.dumps({'text': 'full input', 'attachment': 'x' * (2 * 1024 * 1024)})
|
|
async with AsyncSession(engine) as session:
|
|
for message_id, status, timestamp in [
|
|
('active-message', 'pending', _dt(1)),
|
|
('recent-message', 'success', now),
|
|
('finished-message', 'success', _dt(1)),
|
|
]:
|
|
session.add(
|
|
persistence_monitoring.MonitoringMessage(
|
|
id=message_id,
|
|
workspace_uuid=WORKSPACE,
|
|
timestamp=timestamp,
|
|
bot_id='bot',
|
|
bot_name='Bot',
|
|
pipeline_id='pipeline',
|
|
pipeline_name='Pipeline',
|
|
session_id='session',
|
|
message_content=payload,
|
|
variables=payload,
|
|
status=status,
|
|
level='info',
|
|
event_id=message_id,
|
|
run_id='linked-run' if status == 'pending' else None,
|
|
execution_owner_id='host' if status == 'pending' else None,
|
|
execution_lease_expires_at=now + datetime.timedelta(minutes=1) if status == 'pending' else None,
|
|
)
|
|
)
|
|
session.add(
|
|
EventLog(
|
|
event_id=message_id,
|
|
workspace_id=WORKSPACE,
|
|
event_type='message.received',
|
|
source='platform',
|
|
input_summary='small summary',
|
|
input_json=payload,
|
|
metadata_json=json.dumps({'platform': 'test', 'unused_payload': payload}),
|
|
)
|
|
)
|
|
for run_id in ['linked-run', 'standalone-run']:
|
|
session.add(
|
|
persistence_agent_run.AgentRun(
|
|
run_id=run_id,
|
|
workspace_id=WORKSPACE,
|
|
runner_id='runner',
|
|
status='running',
|
|
created_at=_dt(1),
|
|
authorization_json=payload,
|
|
metadata_json=json.dumps(
|
|
{'event_type': 'message.received', 'agent_name': 'Agent', 'input': {'text': payload}}
|
|
),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
linkage_queries = []
|
|
|
|
def reject_payload_reads(connection, cursor, statement, parameters, context, executemany):
|
|
for column in [
|
|
'monitoring_messages.message_content',
|
|
'monitoring_messages.variables',
|
|
'agent_run.authorization_json',
|
|
'event_log.input_json',
|
|
]:
|
|
assert column not in statement
|
|
if statement.startswith('SELECT agent_run.run_id,') and 'monitoring_messages.id AS message_id' in statement:
|
|
linkage_queries.append((statement, parameters))
|
|
|
|
deserialize = service._deserialize_json
|
|
|
|
def bounded_metadata(raw):
|
|
assert not raw or len(raw) < 10000
|
|
return deserialize(raw)
|
|
|
|
monkeypatch.setattr(service, '_deserialize_json', bounded_metadata)
|
|
sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
|
|
try:
|
|
snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'finished-message'}])
|
|
rows = {row['id']: row for row in snapshot['items']}
|
|
assert rows['active-message']['input_preview'] == 'small summary'
|
|
assert rows['recent-message']['status_group'] == 'completed'
|
|
assert rows['finished-message']['status_group'] == 'completed'
|
|
assert rows['standalone-run']['target_name'] == 'Agent'
|
|
assert snapshot['active_total'] == 2
|
|
finally:
|
|
sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
|
|
assert linkage_queries
|
|
async with engine.connect() as connection:
|
|
for statement, parameters in linkage_queries:
|
|
plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all()
|
|
message_lookups = [row[3] for row in plan if 'SEARCH monitoring_messages' in row[3]]
|
|
assert message_lookups and all('(id=?)' in lookup for lookup in message_lookups), plan
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_snapshot_is_scoped_and_keeps_terminal_transition(engine, service):
|
|
import sqlalchemy as sa
|
|
from unittest.mock import AsyncMock
|
|
|
|
service._get_execution_summary = AsyncMock(side_effect=AssertionError('No dashboard aggregates'))
|
|
service._get_token_coverage = AsyncMock(side_effect=AssertionError('No dashboard aggregates'))
|
|
async with AsyncSession(engine) as session:
|
|
for workspace, run_id in [(WORKSPACE, 'flight-visible'), ('other-workspace', 'flight-secret')]:
|
|
session.add(
|
|
persistence_agent_run.AgentRun(
|
|
run_id=run_id,
|
|
workspace_id=workspace,
|
|
runner_id='runner',
|
|
status='running',
|
|
created_at=_dt(1),
|
|
)
|
|
)
|
|
session.add(persistence_agent_run.AgentRunEvent(run_id='flight-visible', sequence=1, type='tool.started'))
|
|
await session.commit()
|
|
# SSE loaders execute after the HTTP handler's tenant scope has exited.
|
|
# Cloud persistence rejects implicit execute_async calls in that context.
|
|
from contextlib import asynccontextmanager
|
|
|
|
original_execute = service.ap.persistence_mgr.execute_async
|
|
unscoped_execute = AsyncMock(side_effect=AssertionError('Cloud persistence requires an explicit tenant scope'))
|
|
|
|
@asynccontextmanager
|
|
async def tenant_scope(workspace):
|
|
assert workspace == WORKSPACE
|
|
service.ap.persistence_mgr.execute_async = original_execute
|
|
try:
|
|
yield
|
|
finally:
|
|
service.ap.persistence_mgr.execute_async = unscoped_execute
|
|
|
|
service.ap.persistence_mgr.tenant_scope = tenant_scope
|
|
service.ap.persistence_mgr.execute_async = unscoped_execute
|
|
snapshot = await service.get_inflight_snapshot(WORKSPACE)
|
|
rows = {r['id']: r for r in snapshot['items']}
|
|
assert 'flight-secret' not in rows
|
|
assert rows['flight-visible']['progress_event'] == 'tool.started'
|
|
async with AsyncSession(engine) as session:
|
|
await session.execute(
|
|
sa.update(persistence_agent_run.AgentRun)
|
|
.where(persistence_agent_run.AgentRun.run_id == 'flight-visible')
|
|
.values(status='completed')
|
|
)
|
|
await session.commit()
|
|
next_snapshot = await service.get_inflight_snapshot(WORKSPACE, [rows['flight-visible']])
|
|
assert next(r for r in next_snapshot['items'] if r['id'] == 'flight-visible')['status_group'] == 'completed'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_viewers_share_work_and_cleanup():
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from langbot.pkg.utils.inflight import InflightHub
|
|
|
|
hub = InflightHub()
|
|
loader = AsyncMock(return_value={'items': []})
|
|
first = hub.watch('one', loader)
|
|
second = hub.watch('one', loader)
|
|
assert (await anext(first))['kind'] == 'snapshot'
|
|
assert (await anext(second))['kind'] == 'snapshot'
|
|
assert loader.await_count == 1
|
|
other_loader = AsyncMock(return_value={'items': ['private']})
|
|
other = hub.watch('two', other_loader)
|
|
assert (await anext(other))['data']['items'] == ['private']
|
|
hub.track('one', ['run'])
|
|
hub.notify_run('run', 'tool.started')
|
|
loader.return_value = {'items': ['updated']}
|
|
frame = await asyncio.wait_for(anext(first), 2)
|
|
assert frame['data']['items'] == ['updated']
|
|
assert (await anext(second)) == frame
|
|
await first.aclose()
|
|
assert 'one' in hub.channels
|
|
await second.aclose()
|
|
assert 'one' not in hub.channels and 'run' not in hub.run_workspaces
|
|
await other.aclose()
|
|
assert not hub.channels
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inflight_snapshot_failure_is_logged_and_recovers(caplog):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from langbot.pkg.utils.inflight import InflightHub
|
|
|
|
hub = InflightHub()
|
|
snapshot = {'items': [], 'active_total': 0, 'truncated': False}
|
|
loader = AsyncMock(side_effect=[RuntimeError('snapshot database failure'), snapshot])
|
|
stream = hub.watch('workspace', loader)
|
|
try:
|
|
assert await asyncio.wait_for(anext(stream), 2) == {'kind': 'unavailable'}
|
|
errors = [
|
|
record for record in caplog.records if record.message == 'Failed to load in-flight monitoring snapshot'
|
|
]
|
|
assert len(errors) == 1
|
|
assert errors[0].exc_info[0] is RuntimeError
|
|
hub.notify('workspace')
|
|
assert await asyncio.wait_for(anext(stream), 2) == {'kind': 'snapshot', 'data': snapshot}
|
|
finally:
|
|
await stream.aclose()
|
|
assert not hub.channels
|