Files
LangBot/tests/unit_tests/api/service/test_pipeline_migration.py
T

954 lines
38 KiB
Python

"""Manual migration boundary tests; all configs are synthetic."""
import asyncio
import copy
import importlib
import importlib.util
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.authz import PermissionDeniedError, permissions_for_role
from langbot.pkg.api.http.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.core.taskmgr import AsyncTaskManager
MODULE = 'langbot.pkg.api.http.service.pipeline_migration'
RID = 'plugin:langbot-team/TestAgent/default'
WS = '00000000-0000-0000-0000-000000000001'
OTHER = '00000000-0000-0000-0000-000000000002'
SOURCE = {'ai': {'runner': 'test', 'secret': 'synthetic-secret'}, 'output': {'keep': True}}
def module():
assert importlib.util.find_spec(MODULE) is not None, 'manual migration service is missing'
return importlib.import_module(MODULE)
def context(role='developer'):
return RequestContext(
'instance',
1,
'request',
'user_token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
WorkspaceContext(WS, 'membership', role, permissions_for_role(role)),
)
def planner(config, extensions_preferences=None):
base = dict(
state='already_current',
legacy_runner=None,
target_runner_id=None,
target_plugin=None,
changed_paths=[],
blockers=[],
warnings=[],
)
if isinstance(config.get('ai', {}).get('runner'), str):
target = copy.deepcopy(config)
target['ai'] = {'runner': {'id': RID}, 'runner_config': {RID: {'api_key': config['ai']['secret']}}}
base.update(
state='ready',
legacy_runner='test',
target_runner_id=RID,
target_plugin={'author': 'langbot-team', 'name': 'TestAgent', 'version': '1.0'},
changed_paths=['ai.runner', 'ai.runner_config'],
config=target,
)
return base
class PM(PersistenceManager):
def __init__(self, engine):
super().__init__(NS())
self.db = NS(get_engine=lambda: engine)
@pytest.fixture
async def env(tmp_path, monkeypatch):
m = module()
monkeypatch.setattr(m, 'plan_legacy_pipeline', planner)
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "migration.db"}')
async with engine.begin() as conn:
names = {
'users',
'workspaces',
'workspace_memberships',
'workspace_execution_states',
'legacy_pipelines',
'plugin_settings',
'pipeline_migration_snapshots',
'agent_interaction',
}
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Base.metadata.tables[n] for n in names]))
await conn.execute(
sa.insert(User).values(
uuid='account',
user='test@example.invalid',
normalized_email='test@example.invalid',
password='synthetic-not-a-password',
)
)
await conn.execute(
sa.insert(Workspace),
[
dict(uuid=ws, instance_uuid='instance', name=ws, slug=ws, source='cloud_projection')
for ws in (WS, OTHER)
],
)
await conn.execute(
sa.insert(LegacyPipeline),
[
dict(
uuid=pid,
workspace_uuid=ws,
name=pid,
description='kept',
for_version='4.10',
stages=[],
config=SOURCE,
extensions_preferences={'enable_all_plugins': True},
)
for pid, ws in [('one', WS), ('two', WS), ('foreign', OTHER)]
],
)
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS,
plugin_author='langbot-team',
plugin_name='TestAgent',
enabled=True,
install_info={'version': '1.0'},
)
)
pm = PM(engine)
binding = NS(instance_uuid='instance', workspace_uuid=WS, placement_generation=1)
access = NS(
workspace=NS(uuid=WS),
membership=NS(uuid='membership', role='developer', projection_revision=0),
execution=binding,
)
ap = NS(
persistence_mgr=pm,
event_loop=asyncio.get_running_loop(),
instance_config=NS(data={}),
workspace_service=NS(get_execution_binding=AsyncMock(return_value=binding)),
workspace_collaboration_service=NS(resolve_account_workspace=AsyncMock(return_value=access)),
pipeline_mgr=NS(prepare_pipeline=AsyncMock(return_value='candidate'), publish_pipeline=Mock()),
sess_mgr=NS(session_list=[]),
runner_registry=NS(
list_runners=AsyncMock(
return_value=[
NS(
id=RID,
usages=['agent'],
plugin_version='1.0',
config_schema=[{'name': 'api_key', 'type': 'string', 'required': True}],
)
]
)
),
plugin_connector=NS(require_workspace_context=AsyncMock()),
)
ap.task_mgr = AsyncTaskManager(ap)
svc = m.PipelineMigrationService(ap)
yield NS(m=m, ap=ap, svc=svc, engine=engine, pm=pm, access=access)
await ap.task_mgr.wait_all()
await engine.dispose()
async def selection(env, ids=('one',)):
preview = await env.svc.preview(context())
return {
'confirmed': True,
'items': [
{k: item[k] for k in ('pipeline_uuid', 'preview_token')}
for item in preview['items']
if item['pipeline_uuid'] in ids
],
}
async def execute(env, body=None):
result = await env.svc.execute(context(), body or await selection(env))
task = env.ap.task_mgr.get_task_by_id(result['task_id'])
await task.task
return task
async def rows(env):
async with env.engine.connect() as conn:
configs = dict((await conn.execute(sa.select(LegacyPipeline.uuid, LegacyPipeline.config))).all())
backups = (await conn.execute(sa.select(env.m.PipelineMigrationSnapshot))).mappings().all()
return configs, backups
def test_strict_confirmation_and_selection():
m = module()
valid = {'confirmed': True, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'token'}]}
assert m.validate_execute_request(valid) == valid['items']
for body in [
None,
{},
{**valid, 'confirmed': 'true'},
{**valid, 'confirmed': 1},
{**valid, 'confirmed': False},
{**valid, 'workspace_uuid': WS},
{**valid, 'items': []},
{**valid, 'items': valid['items'] * 2},
{**valid, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'x', 'config': {}}]},
{**valid, 'items': [{'pipeline_uuid': str(i), 'preview_token': 'x'} for i in range(51)]},
]:
with pytest.raises(m.MigrationError):
m.validate_execute_request(body)
@pytest.mark.asyncio
async def test_preview_is_scoped_secret_free_and_read_only(env):
writes = []
def observe(_conn, _cursor, statement, *_args):
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
writes.append(statement)
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
result = await env.svc.preview(context('viewer'))
assert result['total'] == 2
assert {i['pipeline_uuid'] for i in result['items']} == {'one', 'two'}
assert all(i['state'] == 'ready' and i['preview_token'] for i in result['items'])
assert 'synthetic-secret' not in str(result)
assert writes == []
env.ap.runner_registry.list_runners.assert_not_awaited()
env.ap.plugin_connector.require_workspace_context.assert_not_awaited()
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_viewer_and_foreign_ids_rejected_before_runtime(env):
body = await selection(env)
with pytest.raises(PermissionDeniedError):
await env.svc.execute(context('viewer'), body)
body['items'].append({'pipeline_uuid': 'foreign', 'preview_token': 'x'})
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 404
assert not env.ap.task_mgr.tasks
env.ap.runner_registry.list_runners.assert_not_awaited()
@pytest.mark.asyncio
async def test_success_snapshot_atomic_preserves_source_and_task_scope(env):
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == planner(SOURCE)['config']
assert configs['two'] == SOURCE
assert len(backups) == 1 and backups[0]['source_snapshot']['config'] == SOURCE
assert backups[0]['state'] == 'active'
assert (task.instance_uuid, task.workspace_uuid, task.placement_generation) == ('instance', WS, 1)
assert task.task_context.metadata == {
'kind': 'pipeline_migration',
'results': [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}],
}
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@pytest.mark.asyncio
async def test_stale_preview_and_plugin_change_are_rejected(env):
body = await selection(env)
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config={'edit': True}))
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 409
body = await selection(env, ('two',))
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
with pytest.raises(env.m.MigrationError):
await env.svc.execute(context(), body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['prepare', 'schema', 'membership', 'generation', 'edit'])
async def test_precommit_failures_keep_original_without_backup(env, failure):
body = await selection(env)
if failure == 'prepare':
env.ap.pipeline_mgr.prepare_pipeline.side_effect = RuntimeError('synthetic-secret')
elif failure == 'schema':
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
async def prepare(*args, **kwargs):
assert env.pm.current_session() is None
if failure == 'membership':
env.access.membership.role = 'viewer'
if failure == 'generation':
env.ap.workspace_service.get_execution_binding.side_effect = RuntimeError('synthetic-secret')
if failure == 'edit':
async with env.engine.begin() as conn:
await conn.execute(
sa.update(LegacyPipeline)
.where(LegacyPipeline.uuid == 'one')
.values(extensions_preferences={'enable_all_plugins': False})
)
return 'candidate'
if failure not in ('prepare', 'schema'):
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env, body)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] in ('blocked', 'failed', 'stale')
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_activation_pending_explicit_retry_without_second_backup(env):
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
preview = await env.svc.preview(context())
assert preview['items'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.side_effect = None
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
_, backups = await rows(env)
assert len(backups) == 1 and backups[0]['state'] == 'active'
@pytest.mark.asyncio
async def test_double_execute_commits_once_and_partial_results(env):
body = await selection(env, ('one', 'two'))
original_prepare = env.ap.pipeline_mgr.prepare_pipeline
async def prepare(_ctx, entity):
assert env.pm.current_session() is None
if entity['uuid'] == 'two':
raise RuntimeError('synthetic-secret')
await asyncio.sleep(0)
return 'candidate'
original_prepare.side_effect = prepare
first, second = await asyncio.gather(env.svc.execute(context(), body), env.svc.execute(context(), body))
await env.ap.task_mgr.wait_all()
configs, backups = await rows(env)
assert len(backups) == 1
assert configs['two'] == SOURCE
results = [env.ap.task_mgr.get_task_by_id(r['task_id']).task_context.metadata['results'] for r in (first, second)]
assert sum(item['state'] == 'migrated' for batch in results for item in batch) == 1
assert all(batch[1]['state'] == 'failed' for batch in results)
@pytest.mark.asyncio
async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env):
from langbot.pkg.api.http.service.pipeline import PipelineService
service = PipelineService(env.ap)
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
async with env.pm.tenant_scope(WS):
with pytest.raises(ValueError, match='manual_migration_required'):
await service.update_pipeline(context(), 'one', {'config': planner(SOURCE)['config']})
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
@pytest.mark.asyncio
async def test_snapshot_commit_failure_rolls_back_config_and_backup(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
async def fail_write_commit(transaction):
# Fail the actual transaction after both statements have run.
result = await transaction.session.execute(
sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)
)
if result.scalar() > 0:
raise RuntimeError('synthetic-secret')
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_write_commit)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert task.task_context.metadata['results'][0]['state'] == 'failed'
assert 'synthetic-secret' not in str(task.to_dict())
@pytest.mark.asyncio
async def test_plugin_changes_while_preparing_prevent_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(runtime_revision=2))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'stale'
@pytest.mark.asyncio
async def test_scope_token_cannot_be_reused_by_other_identity_or_generation(env):
import dataclasses
body = await selection(env)
ctx = dataclasses.replace(context(), placement_generation=2)
env.access.execution.placement_generation = 2
with pytest.raises(env.m.MigrationError):
await env.svc.execute(ctx, body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_conversation_invalidation_is_workspace_scoped(env):
good = NS(workspace_uuid=WS, using_conversation=NS(pipeline_uuid='one'))
foreign = NS(workspace_uuid=OTHER, using_conversation=NS(pipeline_uuid='one'))
env.ap.sess_mgr.session_list = [good, foreign]
await execute(env)
assert good.using_conversation is None
assert foreign.using_conversation is not None
@pytest.mark.asyncio
async def test_runtime_schema_changes_during_prepare_prevent_commit(env):
async def prepare(*args):
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_real_planner_deerflow_schema_integration(env, monkeypatch):
import json
from pathlib import Path
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
source = {
'ai': {
'runner': {'runner': 'deerflow-api', 'expire-time': 60},
'deerflow-api': {'api-base': 'https://synthetic.invalid', 'api-key': 'synthetic-secret'},
},
'output': {'misc': {'remove-think': False}},
}
plan = plan_legacy_pipeline(source, {'enable_all_plugins': True})
assert plan['state'] == 'ready'
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config=source))
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='DeerFlowAgent', enabled=True
)
)
schema = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_deerflow_schema.json').read_text())
env.ap.runner_registry.list_runners.return_value = [
NS(
id=plan['target_runner_id'],
usages=['agent'],
plugin_version=plan['target_plugin']['version'],
config_schema=schema,
)
]
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
configs, backups = await rows(env)
assert configs['one'] == plan['config'] and backups[0]['source_snapshot']['config'] == source
@pytest.mark.asyncio
async def test_account_disabled_during_prepare_blocks_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(User).where(User.uuid == 'account').values(status='disabled'))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_plugin_disabled_after_commit_defers_activation(env, monkeypatch):
original = env.svc._commit
async def commit(*args):
result = await original(*args)
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
return result
monkeypatch.setattr(env.svc, '_commit', commit)
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_ambiguous_commit_acknowledgement_reports_pending(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
failed = False
async def fail_ack(transaction):
nonlocal failed
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
await original(transaction)
if count and not failed:
failed = True
raise RuntimeError('synthetic-secret')
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_ack)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True], ids=['before_commit', 'after_durable_commit'])
async def test_cancel_commit_reports_durable_outcome_and_stops_batch(env, monkeypatch, durable):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
cancelled = False
async def cancel_commit(transaction):
nonlocal cancelled
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not cancelled:
cancelled = True
if durable:
await original(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_commit)
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert cancelled
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
if durable:
assert backups[0]['state'] == 'activation_pending'
assert configs['two'] == SOURCE
assert task.task_context.metadata['results'] == [
{'pipeline_uuid': 'one', 'state': 'activation_pending' if durable else 'failed', 'code': 'operation_cancelled'},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True])
@pytest.mark.parametrize('initial_cancel', [False, True])
@pytest.mark.parametrize('reconcile_cancel', [False, True])
async def test_unavailable_commit_reconciliation_is_conservative(
env, monkeypatch, durable, initial_cancel, reconcile_cancel
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original_commit = AsyncSessionTransaction.commit
original_execute = env.pm.execute_async
interrupted = False
reconciliation_attempts = 0
async def interrupt_commit(transaction):
nonlocal interrupted
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not interrupted:
interrupted = True
if durable:
await original_commit(transaction)
if initial_cancel:
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
await original_commit(transaction)
async def unavailable_reconciliation(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted:
reconciliation_attempts += 1
if reconcile_cancel:
# Repeated real cancellation must stop the batch, not start a
# shield/retry loop or leave the current result as pending.
asyncio.current_task().cancel()
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', interrupt_commit)
monkeypatch.setattr(env.pm, 'execute_async', unavailable_reconciliation)
task_id = (await env.svc.execute(context(), await selection(env, ('one', 'two'))))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
assert task.task.cancelled() is reconcile_cancel
configs, backups = await rows(env)
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
assert task.task_context.metadata['results'][0] == {
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown',
}
if initial_cancel or reconcile_cancel:
assert reconciliation_attempts == 1
assert task.task_context.metadata['results'][1] == {
'pipeline_uuid': 'two',
'state': 'failed',
'code': 'operation_cancelled',
}
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
assert configs['two'] == SOURCE
assert 'synthetic-secret' not in str(task.task_context.metadata)
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable_retry_ack', [False, True])
@pytest.mark.parametrize('reconcile_failure', [None, 'error', 'cancel'])
async def test_cancel_activation_retry_reconciles_original_snapshot(
env, monkeypatch, durable_retry_ack, reconcile_failure
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
await execute(env)
before_configs, before_backups = await rows(env)
body = await selection(env, ('one', 'two'))
env.ap.pipeline_mgr.publish_pipeline.reset_mock(side_effect=True)
env.ap.pipeline_mgr.prepare_pipeline.reset_mock()
original_commit = AsyncSessionTransaction.commit
original_service_commit = env.svc._commit
original_execute = env.pm.execute_async
interrupted = False
in_commit = False
reconciliation_attempts = 0
async def service_commit(*args):
nonlocal in_commit
in_commit = True
try:
return await original_service_commit(*args)
finally:
in_commit = False
async def cancel_ack(transaction):
nonlocal interrupted
if in_commit and not interrupted:
interrupted = True
if durable_retry_ack:
await original_commit(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original_commit(transaction)
async def reconcile(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted and not in_commit:
reconciliation_attempts += 1
if reconcile_failure == 'cancel':
asyncio.current_task().cancel()
await asyncio.sleep(0)
if reconcile_failure == 'error':
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(env.svc, '_commit', service_commit)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_ack)
monkeypatch.setattr(env.pm, 'execute_async', reconcile)
task_id = (await env.svc.execute(context(), body))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
configs, backups = await rows(env)
assert interrupted and configs == before_configs
assert backups == before_backups
assert backups[0]['state'] == 'activation_pending'
assert reconciliation_attempts == 1
assert task.task.cancelled() is (reconcile_failure == 'cancel')
assert task.task_context.metadata['results'] == [
{
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown' if reconcile_failure else 'operation_cancelled',
},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert 'synthetic-secret' not in str(task.task_context.metadata)
@pytest.mark.asyncio
async def test_cancel_during_prepare_keeps_original_and_stops_batch(env):
async def cancel_prepare(*args):
asyncio.current_task().cancel()
await asyncio.sleep(0)
env.ap.pipeline_mgr.prepare_pipeline.side_effect = cancel_prepare
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert configs['one'] == configs['two'] == SOURCE and not backups
assert all(
r['state'] == 'failed' and r['code'] == 'operation_cancelled' for r in task.task_context.metadata['results']
)
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
async def execute_all(env, install_plugins=True):
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': install_plugins})
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
await task.task
return response, task.task_context.metadata
@pytest.mark.asyncio
async def test_all_mode_migrates_workspace_and_skips_completed_on_retry(env):
response, metadata = await execute_all(env)
assert response['pipeline_uuids'] == ['one', 'two']
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
configs, backups = await rows(env)
assert configs['foreign'] == SOURCE
assert len(backups) == 2
with pytest.raises(env.m.MigrationError, match='nothing_to_migrate'):
await execute_all(env)
@pytest.mark.asyncio
async def test_all_data_only_without_plugins_never_contacts_runtime_or_marketplace(env):
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.runner_registry.list_runners.side_effect = AssertionError('offline must not query runtime')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=AssertionError('offline must not install'))
_, metadata = await execute_all(env, install_plugins=False)
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
assert all(r['code'] == 'data_only' for r in metadata['results'])
configs, backups = await rows(env)
assert len(backups) == 2
assert configs['one']['ai']['runner']['id'] == RID
assert configs['foreign'] == SOURCE
env.ap.plugin_connector.install_plugin.assert_not_awaited()
env.ap.runner_registry.list_runners.assert_not_awaited()
assert env.ap.pipeline_mgr.publish_pipeline.call_count == 2
@pytest.mark.asyncio
async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env):
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
async def install(source, info, task_context):
assert info['plugin_version'] == '1.0'
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
)
)
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
_, metadata = await execute_all(env)
assert [r['state'] for r in metadata['results']] == ['migrated', 'migrated']
env.ap.plugin_connector.install_plugin.assert_awaited_once()
@pytest.mark.asyncio
async def test_all_install_failure_preserves_sources_and_never_exposes_upstream_error(env):
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=RuntimeError('secret-token'))
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_install_failed' for r in metadata['results'])
assert 'secret-token' not in str(metadata)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
env.ap.plugin_connector.install_plugin.assert_awaited_once()
@pytest.mark.asyncio
async def test_all_installation_cannot_migrate_edits_made_while_installing(env):
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
async def install(*args, **kwargs):
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='TestAgent', enabled=True
)
)
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(name='edited'))
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
_, metadata = await execute_all(env)
assert metadata['results'][0]['code'] == 'preview_stale'
assert metadata['results'][1]['state'] == 'migrated'
configs, backups = await rows(env)
assert configs['one'] == SOURCE
assert len(backups) == 1
@pytest.mark.asyncio
async def test_all_mode_has_no_fifty_pipeline_limit(env):
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(LegacyPipeline),
[
dict(
uuid=f'extra-{i}',
workspace_uuid=WS,
name=f'extra-{i}',
description='bulk fixture',
for_version='4.10',
stages=[],
config=SOURCE,
extensions_preferences={'enable_all_plugins': True},
)
for i in range(51)
],
)
response, metadata = await execute_all(env, install_plugins=False)
assert len(response['pipeline_uuids']) == 53
assert all(r['state'] == 'migrated' for r in metadata['results'])
@pytest.mark.asyncio
async def test_all_mode_rejects_duplicate_tasks_and_unconfirmed_requests(env):
for body in [
{'all': True, 'confirmed': False, 'install_plugins': True},
{'all': True, 'confirmed': True, 'install_plugins': 'false'},
{'all': True, 'confirmed': True, 'install_plugins': False, 'workspace_uuid': OTHER},
]:
with pytest.raises(env.m.MigrationError):
await env.svc.execute(context(), body)
env.svc._all_tasks.add(WS)
with pytest.raises(env.m.MigrationError, match='migration_running'):
await execute_all(env)
env.svc._all_tasks.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize('stage', ['discovery', 'install', 'validation'])
async def test_disconnected_runtime_has_specific_error_without_modifying_sources(env, stage):
from langbot.pkg.plugin.connector import PluginRuntimeNotConnectedError
error = PluginRuntimeNotConnectedError('private connection diagnostics')
if stage == 'discovery':
env.ap.runner_registry.list_runners.side_effect = error
elif stage == 'install':
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=error)
else:
env.svc._verify_runtime = AsyncMock(side_effect=error)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_runtime_unavailable' for r in metadata['results'])
assert 'private connection diagnostics' not in str(metadata)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and configs['two'] == SOURCE
assert not backups
def test_install_progress_records_stages_without_exposing_diagnostics():
from langbot.pkg.api.http.service.pipeline_migration import MigrationInstallContext
ctx = MigrationInstallContext({'author': 'team', 'name': 'Runner', 'version': '1.0'})
ctx.trace('private-token', action='downloading plugin package')
ctx.metadata.update(download_current=120, download_total=240, progress_percent=15, url='private-token')
ctx.set_current_action('validating plugin package')
ctx.metadata['progress_percent'] = float('nan')
ctx.finish('plugin_install_failed')
assert ctx.progress['download_current'] == 120
assert ctx.progress['progress_percent'] == 15
assert [step['stage'] for step in ctx.progress['steps']] == ['checking', 'downloading', 'validating']
assert all(step['finished_at'] is not None for step in ctx.progress['steps'])
assert ctx.progress['status'] == 'failed'
assert 'private-token' not in str(ctx.progress)
@pytest.mark.asyncio
async def test_install_progress_visible_while_download_is_running(env):
import asyncio
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
started, release = asyncio.Event(), asyncio.Event()
async def install(source, info, task_context):
task_context.set_current_action('downloading plugin package')
task_context.metadata.update(download_current=123, download_total=456)
started.set()
await release.wait()
raise RuntimeError('private-token')
env.ap.plugin_connector.install_plugin = AsyncMock(side_effect=install)
response = await env.svc.execute(context(), {'confirmed': True, 'all': True, 'install_plugins': True})
task = env.ap.task_mgr.get_task_by_id(response['task_id'])
try:
await asyncio.wait_for(started.wait(), 3)
await asyncio.sleep(0.25)
progress = task.task_context.metadata['installations'][0]
assert progress['status'] == 'installing'
assert progress['download_current'] == 123
assert progress['stage'] == 'downloading'
finally:
release.set()
await task.task
assert len(task.task_context.metadata['installations']) == 1
assert progress['status'] == 'failed'
assert 'private-token' not in str(task.task_context.to_dict())
@pytest.mark.asyncio
async def test_missing_marketplace_release_has_specific_migration_result(env):
from langbot.pkg.plugin.connector import MarketplacePluginVersionNotFoundError
async with env.engine.begin() as conn:
await conn.execute(sa.delete(PluginSetting))
env.ap.plugin_connector.install_plugin = AsyncMock(
side_effect=MarketplacePluginVersionNotFoundError('private-token')
)
_, metadata = await execute_all(env)
assert all(r['code'] == 'plugin_version_unavailable' for r in metadata['results'])
assert metadata['installations'][0]['code'] == 'plugin_version_unavailable'
assert 'private-token' not in str(metadata)