mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-26 19:36:35 +08:00
517 lines
21 KiB
Python
517 lines
21 KiB
Python
"""Upgrade real historical ORM schemas through both published migration heads.
|
|
|
|
The fixtures are DDL snapshots, not current metadata stamped as an old version.
|
|
Every ancestor migration runs before we seed branch-specific data and merge.
|
|
PostgreSQL cases use a unique task-owned schema; no existing tables are dropped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import uuid
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from langbot_plugin.entities.io.context import PluginExecutionMode
|
|
|
|
from langbot.pkg.entity import persistence
|
|
from langbot.pkg.entity.persistence.base import Base
|
|
from langbot.pkg.persistence.alembic_runner import (
|
|
get_alembic_current,
|
|
get_alembic_head,
|
|
run_alembic_downgrade,
|
|
run_alembic_upgrade,
|
|
)
|
|
from langbot.pkg.plugin.certification import execution_mode_for_persisted_installation
|
|
from langbot.pkg.utils import importutil
|
|
|
|
|
|
pytestmark = pytest.mark.integration
|
|
importutil.import_modules_in_pkg(persistence)
|
|
|
|
FIXTURES = Path(__file__).with_name('fixtures')
|
|
WORKSPACE = '41100000-0000-4000-8000-000000000001'
|
|
ACCOUNT = '41100000-0000-4000-8000-000000000002'
|
|
NOW = datetime.datetime(2026, 9, 1)
|
|
|
|
|
|
@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=pytest.mark.slow)])
|
|
async def convergence_engine(request, tmp_path):
|
|
if request.param == 'sqlite':
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "convergence.db"}')
|
|
try:
|
|
yield engine
|
|
finally:
|
|
await engine.dispose()
|
|
return
|
|
|
|
url = os.environ.get('TEST_POSTGRES_URL')
|
|
if not url:
|
|
pytest.skip('TEST_POSTGRES_URL not set')
|
|
schema = f'lb_convergence_{uuid.uuid4().hex}'
|
|
admin = create_async_engine(url)
|
|
engine = create_async_engine(url, connect_args={'server_settings': {'search_path': f'{schema},public'}})
|
|
try:
|
|
async with admin.begin() as conn:
|
|
await conn.exec_driver_sql(f'CREATE SCHEMA {schema}')
|
|
yield engine
|
|
finally:
|
|
await engine.dispose()
|
|
async with admin.begin() as conn:
|
|
await conn.exec_driver_sql(f'DROP SCHEMA IF EXISTS {schema} CASCADE')
|
|
await admin.dispose()
|
|
|
|
|
|
async def _insert(conn, table_name, **values):
|
|
table = await conn.run_sync(lambda sync: sa.Table(table_name, sa.MetaData(), autoload_with=sync))
|
|
# Historical baseline tables predate Workspace identity and event bindings.
|
|
await conn.execute(table.insert().values(**{key: value for key, value in values.items() if key in table.c}))
|
|
|
|
|
|
async def _seed_common(engine):
|
|
async with engine.begin() as conn:
|
|
tables = await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
|
|
await _insert(conn, 'metadata', key='instance_uuid', value='migration-convergence')
|
|
await _insert(
|
|
conn, 'users', uuid=ACCOUNT, user='owner@example.com', normalized_email='owner@example.com', password='hash'
|
|
)
|
|
if 'workspaces' in tables:
|
|
await _insert(
|
|
conn,
|
|
'workspaces',
|
|
uuid=WORKSPACE,
|
|
instance_uuid='migration-convergence',
|
|
name='Fixture',
|
|
slug='fixture',
|
|
created_by_account_uuid=ACCOUNT,
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'workspace_memberships',
|
|
uuid='41100000-0000-4000-8000-000000000003',
|
|
workspace_uuid=WORKSPACE,
|
|
account_uuid=ACCOUNT,
|
|
role='owner',
|
|
source='local',
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'model_providers',
|
|
uuid='provider-1',
|
|
workspace_uuid=WORKSPACE,
|
|
name='Fixture provider',
|
|
requester='openai',
|
|
base_url='https://fixture.invalid',
|
|
api_keys=['fixture-key'],
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'legacy_pipelines',
|
|
uuid='pipeline-1',
|
|
workspace_uuid=WORKSPACE,
|
|
name='Fixture pipeline',
|
|
description='preserve pipeline',
|
|
for_version='4',
|
|
is_default=True,
|
|
stages=[],
|
|
config={},
|
|
extensions_preferences={},
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'bots',
|
|
uuid='bot-1',
|
|
workspace_uuid=WORKSPACE,
|
|
name='Fixture bot',
|
|
description='preserve bot',
|
|
adapter='fixture',
|
|
adapter_config={},
|
|
enable=False,
|
|
use_pipeline_uuid='pipeline-1',
|
|
event_bindings=[
|
|
{
|
|
'id': 'route-1',
|
|
'event_pattern': 'message.*',
|
|
'target_type': 'pipeline',
|
|
'target_uuid': 'pipeline-1',
|
|
'enabled': True,
|
|
}
|
|
],
|
|
plugin_processors=[{'processor_uuid': 'agent-1', 'enabled': True}],
|
|
)
|
|
for index, bot in enumerate(('bot-1', 'bot-2')):
|
|
await _insert(
|
|
conn,
|
|
'monitoring_messages',
|
|
id=f'message-{index}',
|
|
workspace_uuid=WORKSPACE,
|
|
timestamp=NOW,
|
|
bot_id=bot,
|
|
bot_name=bot,
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Fixture pipeline',
|
|
message_content=f'preserve message {index}',
|
|
session_id='shared-session',
|
|
status='success',
|
|
level='info',
|
|
role='user',
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'monitoring_sessions',
|
|
workspace_uuid=WORKSPACE,
|
|
session_id='shared-session',
|
|
bot_id='bot-1',
|
|
bot_name='bot-1',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Fixture pipeline',
|
|
message_count=1,
|
|
start_time=NOW,
|
|
last_activity=NOW,
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
async def _seed_branch_data(engine, source):
|
|
async with engine.begin() as conn:
|
|
if source == 'master':
|
|
await _insert(
|
|
conn,
|
|
'passkey_credentials',
|
|
uuid='41100000-0000-4000-8000-000000000004',
|
|
account_uuid=ACCOUNT,
|
|
name='Fixture passkey',
|
|
credential_id='fixture-credential',
|
|
public_key='fixture-public-key',
|
|
sign_count=7,
|
|
backed_up=True,
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'codex_credentials',
|
|
provider_uuid='provider-1',
|
|
workspace_uuid=WORKSPACE,
|
|
payload={'fixture': 'preserve-codex'},
|
|
version=3,
|
|
lease_until=0.0,
|
|
)
|
|
# Master already supports colliding bot sessions before this merge.
|
|
await _insert(
|
|
conn,
|
|
'monitoring_sessions',
|
|
workspace_uuid=WORKSPACE,
|
|
session_id='shared-session',
|
|
bot_id='bot-2',
|
|
bot_name='bot-2',
|
|
pipeline_id='pipeline-1',
|
|
pipeline_name='Fixture pipeline',
|
|
message_count=1,
|
|
start_time=NOW,
|
|
last_activity=NOW,
|
|
is_active=True,
|
|
)
|
|
if source == 'beta':
|
|
await _insert(
|
|
conn,
|
|
'agents',
|
|
uuid='agent-1',
|
|
workspace_uuid=WORKSPACE,
|
|
name='Fixture agent',
|
|
description='preserve agent',
|
|
kind='event_processor',
|
|
component_ref='fixture:Runner',
|
|
config={'fixture': 'preserve-agent'},
|
|
supported_event_patterns=['message.*'],
|
|
)
|
|
await _insert(
|
|
conn,
|
|
'runner_state',
|
|
runner_id='fixture:Runner',
|
|
binding_identity='agent-1',
|
|
scope='bot',
|
|
scope_key='fixture-scope',
|
|
state_key='fixture-state',
|
|
value_json='{"sentinel": 411}',
|
|
bot_id='bot-1',
|
|
workspace_id=WORKSPACE,
|
|
created_at=NOW,
|
|
updated_at=NOW,
|
|
)
|
|
|
|
|
|
async def _assert_merged_schema_and_data(engine, source):
|
|
assert await get_alembic_current(engine) == get_alembic_head()
|
|
async with engine.connect() as conn:
|
|
inspector_data = await conn.run_sync(
|
|
lambda sync: {
|
|
'tables': set(sa.inspect(sync).get_table_names()),
|
|
'bot_columns': {c['name'] for c in sa.inspect(sync).get_columns('bots')},
|
|
'agent_columns': {c['name'] for c in sa.inspect(sync).get_columns('agents')},
|
|
'session_pk': sa.inspect(sync).get_pk_constraint('monitoring_sessions')['constrained_columns'],
|
|
}
|
|
)
|
|
assert {'agents', 'runner_state', 'codex_credentials', 'passkey_credentials'} <= inspector_data['tables']
|
|
assert {'event_bindings', 'plugin_processors'} <= inspector_data['bot_columns']
|
|
assert 'enabled' not in inspector_data['agent_columns']
|
|
assert inspector_data['session_pk'] == ['workspace_uuid', 'bot_id', 'session_id']
|
|
assert await conn.scalar(sa.text('SELECT password FROM users')) == 'hash'
|
|
assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships')) == 1
|
|
assert await conn.scalar(sa.text('SELECT name FROM model_providers')) == 'Fixture provider'
|
|
assert await conn.scalar(sa.text('SELECT description FROM legacy_pipelines')) == 'preserve pipeline'
|
|
assert (
|
|
await conn.execute(sa.text('SELECT message_content FROM monitoring_messages ORDER BY id'))
|
|
).scalars().all() == [
|
|
'preserve message 0',
|
|
'preserve message 1',
|
|
]
|
|
assert (
|
|
await conn.execute(sa.text('SELECT bot_id FROM monitoring_sessions ORDER BY bot_id'))
|
|
).scalars().all() == [
|
|
'bot-1',
|
|
'bot-2',
|
|
]
|
|
bots = await conn.run_sync(lambda sync: sa.Table('bots', sa.MetaData(), autoload_with=sync))
|
|
row = (await conn.execute(sa.select(bots.c.event_bindings, bots.c.plugin_processors))).one()
|
|
assert row.event_bindings[0]['target_uuid'] == 'pipeline-1'
|
|
if source == 'beta':
|
|
assert row.plugin_processors == [{'processor_uuid': 'agent-1', 'enabled': True}]
|
|
assert await conn.scalar(sa.text('SELECT value_json FROM runner_state')) == '{"sentinel": 411}'
|
|
assert await conn.scalar(sa.text('SELECT name FROM agents')) == 'Fixture agent'
|
|
if source == 'master':
|
|
assert await conn.scalar(sa.text('SELECT sign_count FROM passkey_credentials')) == 7
|
|
credentials = await conn.run_sync(
|
|
lambda sync: sa.Table('codex_credentials', sa.MetaData(), autoload_with=sync)
|
|
)
|
|
assert await conn.scalar(sa.select(credentials.c.payload)) == {'fixture': 'preserve-codex'}
|
|
if engine.dialect.name == 'postgresql':
|
|
rows = (
|
|
await conn.execute(
|
|
sa.text(
|
|
'SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, '
|
|
'EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid '
|
|
"AND p.polname = 'langbot_workspace_isolation') AS policy "
|
|
'FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace '
|
|
"WHERE n.nspname = current_schema() AND c.relname IN ('agents', 'codex_credentials', 'monitoring_sessions')"
|
|
)
|
|
)
|
|
).all()
|
|
assert len(rows) == 3
|
|
assert all(row.relrowsecurity and row.relforcerowsecurity and row.policy for row in rows)
|
|
else:
|
|
assert (await conn.exec_driver_sql('PRAGMA foreign_key_check')).all() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('source', ['baseline', 'master', 'beta'])
|
|
async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source):
|
|
engine = convergence_engine
|
|
fixture = json.loads((FIXTURES / f'{source}_schema.json').read_text())
|
|
async with engine.begin() as conn:
|
|
for statement in fixture['dialects'][engine.dialect.name]:
|
|
await conn.exec_driver_sql(statement)
|
|
# Execute the published ancestors; never stamp an empty or current schema.
|
|
await run_alembic_upgrade(engine, fixture['revision'])
|
|
assert await get_alembic_current(engine) == fixture['revision']
|
|
async with engine.connect() as conn:
|
|
tables = await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
|
|
if source == 'master':
|
|
assert {'codex_credentials', 'passkey_credentials'} <= tables
|
|
assert 'agents' not in tables and 'runner_state' not in tables
|
|
elif source == 'beta':
|
|
assert {'agents', 'runner_state'} <= tables
|
|
assert 'codex_credentials' not in tables and 'passkey_credentials' not in tables
|
|
assert await conn.run_sync(
|
|
lambda sync: sa.inspect(sync).get_pk_constraint('monitoring_sessions')['constrained_columns']
|
|
) == ['workspace_uuid', 'session_id']
|
|
else:
|
|
assert 'workspaces' not in tables
|
|
await _seed_common(engine)
|
|
await _seed_branch_data(engine, source)
|
|
if source == 'baseline' and engine.dialect.name == 'postgresql':
|
|
# Match PersistenceManager's staged legacy bootstrap: expand the
|
|
# account/resource contract before creating deferred tenant tables.
|
|
# RLS 0011 requires those tables (including monitoring_tool_calls).
|
|
await run_alembic_upgrade(engine, '0010_scope_resources')
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await run_alembic_upgrade(engine, 'head')
|
|
await _assert_merged_schema_and_data(engine, source)
|
|
await run_alembic_upgrade(engine, 'head')
|
|
await _assert_merged_schema_and_data(engine, source)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_database_startup_schema_then_real_migrations(convergence_engine):
|
|
engine = convergence_engine
|
|
async with engine.begin() as conn:
|
|
assert await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) == []
|
|
# This is the documented fresh-install contract: create_all precedes Alembic.
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await run_alembic_upgrade(engine, 'head')
|
|
assert await get_alembic_current(engine) == get_alembic_head()
|
|
|
|
|
|
def _legacy_shared_certification(**overrides):
|
|
certification = {
|
|
'normalized_digest': 'B' * 64,
|
|
'verification': 'valid',
|
|
'certificate_runtime_profile': 'shared-runtime-v1',
|
|
'certificate_id': 'ed25519:trusted-issuer',
|
|
'runtime_profile': 'shared-runtime-v1',
|
|
'admission_code': 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE',
|
|
'preserved_certificate_key': {'nested': True},
|
|
}
|
|
certification.update(overrides)
|
|
return certification
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_certification_artifact_digest_backfill_is_safe_and_enables_shared_placement(convergence_engine):
|
|
engine = convergence_engine
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await run_alembic_upgrade(engine, '0031_merge_totp_assistant')
|
|
|
|
valid_digest = 'a' * 64
|
|
rows = {
|
|
'eligible-a': (_legacy_shared_certification(), valid_digest),
|
|
'eligible-b': (_legacy_shared_certification(), valid_digest),
|
|
'present-matching': (_legacy_shared_certification(artifact_digest=valid_digest), valid_digest),
|
|
'present-mismatched': (_legacy_shared_certification(artifact_digest='c' * 64), valid_digest),
|
|
'invalid': (_legacy_shared_certification(verification='invalid'), valid_digest),
|
|
'incomplete': (_legacy_shared_certification(certificate_id=' '), valid_digest),
|
|
'missing-fact': (
|
|
{
|
|
key: value
|
|
for key, value in _legacy_shared_certification().items()
|
|
if key != 'certificate_runtime_profile'
|
|
},
|
|
valid_digest,
|
|
),
|
|
'malformed-normalized': (_legacy_shared_certification(normalized_digest='g' * 64), valid_digest),
|
|
'dedicated-certificate': (
|
|
_legacy_shared_certification(certificate_runtime_profile='dedicated'),
|
|
valid_digest,
|
|
),
|
|
'dedicated': (_legacy_shared_certification(runtime_profile='dedicated'), valid_digest),
|
|
'wrong-admission': (_legacy_shared_certification(admission_code='SHARED_ELIGIBLE'), valid_digest),
|
|
'uppercase-row-digest': (_legacy_shared_certification(), 'A' * 64),
|
|
'nonhex-row-digest': (_legacy_shared_certification(), 'g' * 64),
|
|
}
|
|
plugin_settings = Base.metadata.tables['plugin_settings']
|
|
workspaces = Base.metadata.tables['workspaces']
|
|
async with engine.begin() as conn:
|
|
for index, (name, (certification, artifact_digest)) in enumerate(rows.items(), start=1):
|
|
workspace_uuid = f'41100000-0000-4000-8000-{index:012d}'
|
|
await conn.execute(
|
|
workspaces.insert().values(
|
|
uuid=workspace_uuid,
|
|
instance_uuid=f'cert-backfill-{index}',
|
|
name=name,
|
|
slug=name,
|
|
)
|
|
)
|
|
await conn.execute(
|
|
plugin_settings.insert().values(
|
|
workspace_uuid=workspace_uuid,
|
|
plugin_author='langbot',
|
|
plugin_name=name,
|
|
installation_uuid=f'51100000-0000-4000-8000-{index:012d}',
|
|
artifact_digest=artifact_digest,
|
|
runtime_revision=1,
|
|
install_info={
|
|
'_certification': certification,
|
|
'preserved_install_key': ['keep', {'nested': True}],
|
|
},
|
|
)
|
|
)
|
|
|
|
await run_alembic_upgrade(engine, 'head')
|
|
# Re-running the data revision itself must also be harmless.
|
|
migration = __import__(
|
|
'langbot.pkg.persistence.alembic.versions.0032_certification_artifact_digest_backfill',
|
|
fromlist=['upgrade'],
|
|
)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(lambda sync: migration.backfill_certification_artifact_digests(sync))
|
|
stored = {
|
|
name: (artifact_digest, install_info)
|
|
for name, artifact_digest, install_info in (
|
|
await conn.execute(
|
|
sa.select(
|
|
plugin_settings.c.plugin_name,
|
|
plugin_settings.c.artifact_digest,
|
|
plugin_settings.c.install_info,
|
|
)
|
|
)
|
|
).all()
|
|
}
|
|
|
|
for name in ('eligible-a', 'eligible-b'):
|
|
eligible_digest, eligible_info = stored[name]
|
|
assert eligible_info['preserved_install_key'] == ['keep', {'nested': True}]
|
|
assert eligible_info['_certification']['preserved_certificate_key'] == {'nested': True}
|
|
assert eligible_info['_certification']['artifact_digest'] == eligible_digest == valid_digest
|
|
assert (
|
|
execution_mode_for_persisted_installation(
|
|
artifact_digest=eligible_digest,
|
|
install_info=eligible_info,
|
|
)
|
|
is PluginExecutionMode.SHARED_CERTIFIED
|
|
)
|
|
|
|
for name, (original_certification, artifact_digest) in rows.items():
|
|
if name in {'eligible-a', 'eligible-b'}:
|
|
continue
|
|
stored_digest, stored_info = stored[name]
|
|
assert stored_digest == artifact_digest
|
|
assert stored_info['_certification'] == original_certification
|
|
if name != 'present-matching':
|
|
assert (
|
|
execution_mode_for_persisted_installation(
|
|
artifact_digest=stored_digest,
|
|
install_info=stored_info,
|
|
)
|
|
is PluginExecutionMode.DEDICATED
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_certification_artifact_digest_backfill_accepts_fresh_current_schema(convergence_engine):
|
|
engine = convergence_engine
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
await run_alembic_upgrade(engine, 'head')
|
|
await run_alembic_upgrade(engine, 'head')
|
|
|
|
assert await get_alembic_current(engine) == get_alembic_head()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_only_downgrade_preserves_both_branch_schemas(convergence_engine):
|
|
engine = convergence_engine
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await run_alembic_upgrade(engine, 'head')
|
|
await _seed_common(engine)
|
|
await _seed_branch_data(engine, 'master')
|
|
await _seed_branch_data(engine, 'beta')
|
|
# Targeting a parent removes only the merge, leaving both parent heads.
|
|
# A relative -1 is ambiguous at a merge point. Neither feature is reverted.
|
|
await run_alembic_downgrade(engine, '0024_passkey_credentials')
|
|
async with engine.connect() as conn:
|
|
revisions = set((await conn.execute(sa.text('SELECT version_num FROM alembic_version'))).scalars())
|
|
knowledge_columns = await conn.run_sync(
|
|
lambda sync: {column['name'] for column in sa.inspect(sync).get_columns('knowledge_bases')}
|
|
)
|
|
assert revisions == {'0024_passkey_credentials', '0026_knowledge_base_drafts'}
|
|
assert 'initialized' in knowledge_columns
|
|
await run_alembic_upgrade(engine, 'head')
|
|
await _assert_merged_schema_and_data(engine, 'master')
|
|
await _assert_merged_schema_and_data(engine, 'beta')
|