mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
- Maintain counts and display snapshots in source transactions with replay-safe deduplication. - Read session lists exclusively from summaries and remove execution-only filters. - Automatically initialize historical summaries in bounded, resumable batches during OSS startup. - Add behavioral contracts covering accounting, retention, query cost and PostgreSQL concurrency. Verified with the required session_summary suite and real container upgrade startup.
145 lines
6.6 KiB
Python
145 lines
6.6 KiB
Python
"""Enforce the read boundary and the schema needed for bounded repair."""
|
|
|
|
import ast
|
|
import datetime
|
|
import importlib
|
|
import importlib.util
|
|
import inspect
|
|
|
|
import pytest
|
|
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
|
|
from langbot.pkg.entity.persistence.base import Base
|
|
from langbot.pkg.entity.persistence import monitoring as monitoring_models
|
|
from langbot.pkg.entity.persistence.monitoring import MonitoringSession, MonitoringSessionReceipt
|
|
from langbot.pkg.entity.persistence.workspace import Workspace
|
|
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
|
from langbot.pkg.persistence import session_summary_reader, session_summary_contract
|
|
from langbot.pkg.persistence.tenant_uow import TENANT_TABLE_COLUMNS
|
|
|
|
|
|
pytestmark = pytest.mark.session_summary
|
|
|
|
|
|
def assert_summary_dependencies(source, package):
|
|
"""Restrict historical and application capabilities, not utility/type imports."""
|
|
forbidden = (
|
|
'langbot.pkg.agent',
|
|
'langbot.pkg.api',
|
|
'langbot.pkg.core',
|
|
'langbot.pkg.pipeline',
|
|
'langbot.pkg.persistence.mgr',
|
|
'langbot.pkg.persistence.session_summary_backfill',
|
|
'langbot.pkg.persistence.session_summary_writer',
|
|
'langbot.pkg.entity.persistence',
|
|
)
|
|
for node in ast.walk(ast.parse(source)):
|
|
if isinstance(node, ast.ImportFrom):
|
|
target = importlib.util.resolve_name('.' * node.level + (node.module or ''), package)
|
|
imports = [target + '.' + alias.name for alias in node.names]
|
|
if target == monitoring_models.__name__:
|
|
for alias in node.names:
|
|
model = getattr(monitoring_models, alias.name, None)
|
|
table = getattr(model, '__table__', None)
|
|
assert alias.name != '*' and (table is None or table.name == 'monitoring_sessions')
|
|
continue
|
|
elif isinstance(node, ast.Import):
|
|
imports = [alias.name for alias in node.names]
|
|
else:
|
|
continue
|
|
for target in imports:
|
|
assert not any(target == prefix or target.startswith(prefix + '.') for prefix in forbidden), target
|
|
assert target != monitoring_models.__name__, 'Import the summary model without the history namespace'
|
|
|
|
|
|
@pytest.mark.contract('reader_dependencies')
|
|
def test_list_modules_have_no_history_or_application_capabilities():
|
|
for module in (session_summary_contract, session_summary_reader):
|
|
assert_summary_dependencies(inspect.getsource(module), module.__package__)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'extra_import', ['from typing import Any', 'import functools', 'from datetime import timezone']
|
|
)
|
|
def test_summary_dependency_check_allows_utility_and_type_imports(extra_import):
|
|
assert_summary_dependencies(extra_import, session_summary_reader.__package__)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'extra_import',
|
|
[
|
|
'from ..entity.persistence.monitoring import MonitoringMessage',
|
|
'from .session_summary_backfill import SessionSummaryBackfill',
|
|
'from ..entity.persistence import event_log',
|
|
'from ..entity.persistence.agent_run import AgentRun',
|
|
],
|
|
)
|
|
def test_summary_dependency_check_rejects_history_and_repair(extra_import):
|
|
with pytest.raises(AssertionError):
|
|
assert_summary_dependencies(extra_import, session_summary_reader.__package__)
|
|
|
|
|
|
@pytest.mark.contract('summary_schema')
|
|
def test_summary_migration_is_idempotent_and_preserves_legacy_counts():
|
|
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0040_session_summaries')
|
|
engine = sa.create_engine('sqlite:///:memory:')
|
|
|
|
@sa.event.listens_for(engine, 'connect')
|
|
def foreign_keys(connection, _record):
|
|
connection.execute('PRAGMA foreign_keys=ON')
|
|
|
|
try:
|
|
with engine.begin() as connection:
|
|
Base.metadata.create_all(connection)
|
|
with Operations.context(MigrationContext.configure(connection)):
|
|
migration.downgrade()
|
|
connection.execute(sa.insert(Workspace).values(uuid='w', instance_uuid='i', name='W', slug='w'))
|
|
connection.execute(
|
|
sa.text(
|
|
'INSERT INTO monitoring_sessions (workspace_uuid,bot_id,session_id,bot_name,pipeline_id,pipeline_name,'
|
|
'message_count,start_time,last_activity,is_active,user_id,user_name,platform) '
|
|
"VALUES ('w','b','s','Bot','legacy','Legacy Pipeline',99,:now,:now,1,'legacy-user','Legacy User','legacy')"
|
|
),
|
|
{'now': datetime.datetime(2026, 1, 1)},
|
|
)
|
|
with Operations.context(MigrationContext.configure(connection)):
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
row = connection.execute(sa.select(MonitoringSession)).mappings().one()
|
|
assert row['message_count'] == 99 and row['source_count'] == 0
|
|
assert row['metadata_role'] is None and row['metadata_timestamp'] is None
|
|
assert row['metadata_source_key'] is None and row['base_snapshot'] is None
|
|
assert (row['user_id'], row['user_name'], row['platform']) == ('legacy-user', 'Legacy User', 'legacy')
|
|
assert (row['pipeline_id'], row['pipeline_name']) == ('legacy', 'Legacy Pipeline')
|
|
assert connection.execute(sa.select(WorkspaceMetadata.value)).scalar_one() == '{"source": "messages"}'
|
|
inspector = sa.inspect(connection)
|
|
assert inspector.get_pk_constraint('monitoring_session_receipts')['constrained_columns'] == [
|
|
'workspace_uuid',
|
|
'bot_id',
|
|
'source_key',
|
|
]
|
|
assert inspector.get_foreign_keys('monitoring_session_receipts')[0]['options']['ondelete'] == 'CASCADE'
|
|
assert TENANT_TABLE_COLUMNS['monitoring_session_receipts'] == 'workspace_uuid'
|
|
for table, index, fields in migration.INDEXES:
|
|
indexes = {item['name']: item['column_names'] for item in inspector.get_indexes(table)}
|
|
assert indexes[index] == fields
|
|
connection.execute(
|
|
sa.insert(MonitoringSessionReceipt).values(
|
|
workspace_uuid='w',
|
|
bot_id='b',
|
|
source_key='event:detached',
|
|
role='event',
|
|
covered=True,
|
|
timestamp=datetime.datetime(2026, 1, 1),
|
|
)
|
|
)
|
|
connection.execute(sa.delete(Workspace).where(Workspace.uuid == 'w'))
|
|
assert (
|
|
connection.execute(sa.select(sa.func.count()).select_from(MonitoringSessionReceipt)).scalar_one() == 0
|
|
)
|
|
finally:
|
|
engine.dispose()
|